Show tasks in more of a card view (#56)

* Show tasks in more of a card view

* PR feedback
This commit is contained in:
Matt Rubens 2025-05-30 23:45:48 -04:00 committed by GitHub
parent d920576ae8
commit c002decfe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 181 additions and 113 deletions

View file

@ -8,7 +8,7 @@ import {
} from '@roo-code/types';
import type { TimePeriod } from '@/types';
import { type Task, taskSchema } from '@/types/analytics';
import { taskSchema } from '@/types/analytics';
import { analytics } from '@/lib/server';
import { type User, getUsersById } from '@/db/server';
@ -225,30 +225,48 @@ export const getModelUsage = async ({
* getTasks
*/
const taskWithTitleSchema = taskSchema.extend({
title: z.string().nullable(),
});
export type TaskWithTitle = z.infer<typeof taskWithTitleSchema>;
export type TaskWithUser = TaskWithTitle & { user: User };
export const getTasks = async ({
orgId,
}: {
orgId?: string | null;
}): Promise<(Task & { user: User })[]> => {
}): Promise<TaskWithUser[]> => {
if (!orgId) {
return [];
}
const results = await analytics.query({
query: `
WITH first_messages AS (
SELECT
taskId,
argMin(text, ts) as title
FROM messages
WHERE orgId = {orgId: String}
GROUP BY taskId
)
SELECT
taskId,
userId,
apiProvider AS provider,
modelId as model,
MAX(CASE WHEN type = 'Task Completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN type = 'LLM Completion' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens,
SUM(CASE WHEN type = 'LLM Completion' THEN COALESCE(cost, 0) ELSE 0 END) AS cost,
MIN(timestamp) AS timestamp
FROM events
e.taskId,
e.userId,
e.apiProvider AS provider,
e.modelId as model,
MAX(CASE WHEN e.type = 'Task Completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN e.type = 'LLM Completion' THEN COALESCE(e.inputTokens, 0) + COALESCE(e.outputTokens, 0) ELSE 0 END) AS tokens,
SUM(CASE WHEN e.type = 'LLM Completion' THEN COALESCE(e.cost, 0) ELSE 0 END) AS cost,
MIN(e.timestamp) AS timestamp,
any(fm.title) AS title
FROM events e
LEFT JOIN first_messages fm ON e.taskId = fm.taskId
WHERE
orgId = {orgId: String}
AND type IN ({types: Array(String)})
e.orgId = {orgId: String}
AND e.type IN ({types: Array(String)})
GROUP BY 1, 2, 3, 4
ORDER BY timestamp DESC
`,
@ -263,11 +281,11 @@ export const getTasks = async ({
},
});
const tasks = z.array(taskSchema).parse(await results.json());
const tasks = z.array(taskWithTitleSchema).parse(await results.json());
const users = await getUsersById(tasks.map(({ userId }) => userId));
return tasks
.map((usage) => ({ ...usage, user: users[usage.userId] }))
.filter((usage): usage is Task & { user: User } => !!usage.user);
.filter((usage): usage is TaskWithUser => !!usage.user);
};

View file

@ -1,10 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { X } from 'lucide-react';
import type { User } from '@/db/server';
import type { Task } from '@/types/analytics';
import type { TaskWithUser } from '@/actions/analytics';
import { getMessages } from '@/actions/analytics';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { generateFallbackTitle } from '@/lib/taskUtils';
import {
Drawer,
DrawerContent,
@ -18,7 +18,7 @@ import { Status } from './Status';
import { Messages } from './Messages';
type TaskDrawerProps = {
task: Task & { user: User };
task: TaskWithUser;
onClose: () => void;
};
@ -32,7 +32,7 @@ export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => {
<Drawer open={true} onOpenChange={onClose} direction="right">
<DrawerContent className="flex flex-col h-full">
<DrawerHeader className="flex-shrink-0">
<DrawerTitle>Task</DrawerTitle>
<DrawerTitle>{task.title || generateFallbackTitle(task)}</DrawerTitle>
<DrawerDescription>{task.taskId}</DrawerDescription>
<div className="absolute top-2 right-2">
<Button variant="ghost" size="sm" onClick={onClose}>

View file

@ -1,17 +1,13 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import type { User } from '@/db/server';
import type { Task } from '@/types/analytics';
import type { TaskWithUser } from '@/actions/analytics';
import { getTasks } from '@/actions/analytics';
import { formatNumber, formatCurrency } from '@/lib/formatters';
import { Button, Skeleton } from '@/components/ui';
import { DataTable } from '@/components/layout';
import { Skeleton } from '@/components/ui';
import { TaskCard } from '@/components/usage';
import type { Filter } from './types';
import { Status } from './Status';
export const Tasks = ({
filter,
@ -20,11 +16,11 @@ export const Tasks = ({
}: {
filter: Filter | null;
onFilter: (filter: Filter) => void;
onTaskSelected: (task: Task & { user: User }) => void;
onTaskSelected: (task: TaskWithUser) => void;
}) => {
const { orgId } = useAuth();
const { data = Array(3).fill({}), isPending } = useQuery({
const { data = [], isPending } = useQuery({
queryKey: ['getTasks', orgId],
queryFn: () => getTasks({ orgId }),
enabled: !!orgId,
@ -42,87 +38,36 @@ export const Tasks = ({
);
}, [filter, data]);
const cols: ColumnDef<Task & { user: User }>[] = useMemo(
() => [
{
header: 'Task ID',
cell: ({ row: { original: task } }) => (
<Button
variant="link"
onClick={() => onTaskSelected(task)}
className="px-0"
>
{task.taskId.slice(0, 8)}
</Button>
),
},
{
header: 'Developer',
cell: ({ row }) => (
<Button
variant="link"
onClick={() =>
onFilter({
type: 'userId',
value: row.original.userId,
label: row.original.user.name,
})
}
className="px-0"
>
{row.original.user.name}
</Button>
),
},
{
header: 'Model',
cell: ({ row }) => (
<Button
variant="link"
onClick={() =>
onFilter({
type: 'model',
value: row.original.model,
label: row.original.model,
})
}
className="px-0"
>
{row.original.model}
</Button>
),
},
{
header: 'Tokens',
cell: ({ row }) => formatNumber(row.original.tokens),
},
{
header: 'Cost (USD)',
cell: ({ row }) => formatCurrency(row.original.cost),
},
{
header: 'Date',
cell: ({ row }) =>
new Date(1000 * row.original.timestamp).toLocaleString(),
},
{
header: 'Status',
cell: ({ row }) => <Status completed={row.original.completed} />,
},
],
[onFilter, onTaskSelected],
);
if (isPending) {
return (
<div className="space-y-3">
{Array(8)
.fill(0)
.map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
);
}
const columns = useMemo(
() =>
isPending
? cols.map((col) => ({
...col,
cell: () => <Skeleton className="h-9 w-full" />,
}))
: cols,
[isPending, cols],
);
if (tasks.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No tasks found
</div>
);
}
return <DataTable columns={columns} data={tasks} />;
return (
<div className="space-y-3">
{tasks.map((task) => (
<TaskCard
key={task.taskId}
task={task}
onFilter={onFilter}
onTaskSelected={onTaskSelected}
/>
))}
</div>
);
};

View file

@ -4,8 +4,7 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X } from 'lucide-react';
import type { User } from '@/db/server';
import type { Task } from '@/types/analytics';
import type { TaskWithUser } from '@/actions/analytics';
import { Badge, Button } from '@/components/ui';
import { UsageCard } from '@/components/usage';
@ -19,7 +18,7 @@ export const Usage = () => {
const t = useTranslations('Analytics');
const [viewMode, setViewMode] = useState<ViewMode>('tasks');
const [filter, setFilter] = useState<Filter | null>(null);
const [task, setTask] = useState<(Task & { user: User }) | null>(null);
const [task, setTask] = useState<TaskWithUser | null>(null);
const onFilter = useCallback((filter: Filter) => {
setFilter(filter);
@ -62,7 +61,7 @@ export const Usage = () => {
<Tasks
filter={filter}
onFilter={onFilter}
onTaskSelected={(task: Task & { user: User }) => setTask(task)}
onTaskSelected={(task: TaskWithUser) => setTask(task)}
/>
) : viewMode === 'developers' ? (
<Developers onFilter={onFilter} />

View file

@ -0,0 +1,83 @@
import {
formatNumber,
formatCurrency,
formatTimestamp,
} from '@/lib/formatters';
import { generateFallbackTitle } from '@/lib/taskUtils';
import { Card, CardContent, Button } from '@/components/ui';
import type { TaskWithUser } from '@/actions/analytics';
import type { Filter } from '@/app/(authenticated)/usage/types';
import { Status } from '@/app/(authenticated)/usage/Status';
type TaskCardProps = {
task: TaskWithUser;
onFilter: (filter: Filter) => void;
onTaskSelected: (task: TaskWithUser) => void;
};
export const TaskCard = ({ task, onFilter, onTaskSelected }: TaskCardProps) => {
return (
<Card
className="cursor-pointer hover:shadow-md transition-shadow py-0"
onClick={() => onTaskSelected(task)}
>
<CardContent className="p-0">
<div className="px-4 py-2">
{/* First line - Title and Status */}
<div className="flex items-center justify-between gap-2 mb-1">
<h3 className="font-medium text-sm truncate flex-1 leading-none">
{task.title || generateFallbackTitle(task)}
</h3>
<Status completed={task.completed} />
</div>
{/* Second line - All metadata */}
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span>{formatTimestamp(task.timestamp)}</span>
<Button
variant="link"
size="sm"
onClick={(e) => {
e.stopPropagation();
onFilter({
type: 'userId',
value: task.userId,
label: task.user.name,
});
}}
className="px-0 h-auto text-xs font-normal text-muted-foreground hover:text-foreground"
>
{task.user.name}
</Button>
<Button
variant="link"
size="sm"
onClick={(e) => {
e.stopPropagation();
onFilter({
type: 'model',
value: task.model,
label: task.model,
});
}}
className="px-0 h-auto text-xs font-normal font-mono text-muted-foreground hover:text-foreground"
>
{task.model}
</Button>
</div>
{/* Metrics on the right */}
<div className="flex items-center gap-3">
<span className="font-medium">
{formatNumber(task.tokens)} tokens
</span>
<span className="font-medium">{formatCurrency(task.cost)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
};

View file

@ -1 +1,2 @@
export { UsageCard } from './UsageCard';
export { TaskCard } from './TaskCard';

22
src/lib/taskUtils.ts Normal file
View file

@ -0,0 +1,22 @@
import { format, getHours } from 'date-fns';
import type { TaskWithUser } from '@/actions/analytics';
export const generateFallbackTitle = (task: TaskWithUser) => {
const date = new Date(task.timestamp * 1000);
const dayOfWeek = format(date, 'EEEE'); // Full day name (e.g., 'Monday')
const hour = getHours(date);
// Determine time of day
let timeOfDay;
if (hour >= 5 && hour < 12) {
timeOfDay = 'morning';
} else if (hour >= 12 && hour < 17) {
timeOfDay = 'afternoon';
} else if (hour >= 17 && hour < 21) {
timeOfDay = 'evening';
} else {
timeOfDay = 'night';
}
return `${dayOfWeek} ${timeOfDay} task`;
};