mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): compact task-status tiles beside the donut; journal ids copyable + task-linked
Five near-empty stat cards become a 2x3 tile band sharing the row with the status donut. Journal entry cards and the entry page render full ids with the shared CopyButton and a /tasks quick-link badge, with the entry-card anchor restructured so links no longer nest.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { Suspense } from "react";
|
||||
import { JournalEntryType, type JournalEntry } from "@/types";
|
||||
|
||||
// CEO feedback: the journal/task ids on the entry detail page were shown
|
||||
// truncated with "..." and had no copy button. Guards the fix: no ellipsis,
|
||||
// a copy button for the full id on both the journal id and the task id, and
|
||||
// the existing task quick-link stays intact.
|
||||
|
||||
vi.mock("@/hooks/use-journals", () => ({
|
||||
useJournalEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks", () => ({
|
||||
usePageRefresh: () => ({ register: vi.fn(), unregister: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { useJournalEntry } from "@/hooks/use-journals";
|
||||
import JournalEntryPage from "../page";
|
||||
|
||||
const entry: JournalEntry = {
|
||||
id: "entry-1",
|
||||
journal_id: "a1b2c3d4-3333-4444-5555-666677778888",
|
||||
type: JournalEntryType.GENERAL,
|
||||
title: "Shipped the thing",
|
||||
content: "Body",
|
||||
task_id: "e27ef84d-1111-2222-3333-444455556666",
|
||||
session_id: null,
|
||||
timestamp: "2026-07-10T12:00:00Z",
|
||||
tags: [],
|
||||
sentiment: null,
|
||||
is_private: false,
|
||||
created_at: "2026-07-10T12:00:00Z",
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
async function renderPage() {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Suspense fallback={null}>
|
||||
<JournalEntryPage params={Promise.resolve({ entryId: "entry-1" })} />
|
||||
</Suspense>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("JournalEntryPage — id display", () => {
|
||||
it("shows the journal id8 without an ellipsis and with a copy button", async () => {
|
||||
vi.mocked(useJournalEntry).mockReturnValue({
|
||||
data: entry,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useJournalEntry>);
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(await screen.findByText("a1b2c3d4")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/a1b2c3d4\.\.\./)).not.toBeInTheDocument();
|
||||
// Two copy buttons: journal id + task id.
|
||||
expect(screen.getAllByRole("button", { name: /copy/i })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps the task quick-link and adds a copy button for the full task id", async () => {
|
||||
vi.mocked(useJournalEntry).mockReturnValue({
|
||||
data: entry,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useJournalEntry>);
|
||||
|
||||
await renderPage();
|
||||
|
||||
const taskLink = (await screen.findByText("Task #e27ef84d")).closest("a");
|
||||
expect(taskLink).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/e27ef84d-1111-2222-3333-444455556666",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { EntryTypeBadge } from "@/components/journals/entry-type-badge";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
@@ -148,7 +149,13 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
<User className="h-4 w-4" />
|
||||
<span>Journal</span>
|
||||
</div>
|
||||
<p className="font-medium">{entry.journal_id.slice(0, 8)}...</p>
|
||||
<p
|
||||
className="font-medium font-mono flex items-center gap-1"
|
||||
title={entry.journal_id}
|
||||
>
|
||||
{entry.journal_id.slice(0, 8)}
|
||||
<CopyButton value={entry.journal_id} className="px-1 py-0.5" />
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -159,14 +166,18 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span>Related Task</span>
|
||||
</div>
|
||||
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="hover:bg-muted cursor-pointer"
|
||||
>
|
||||
Task #{entry.task_id.slice(0, 8)}
|
||||
</Badge>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="hover:bg-muted cursor-pointer"
|
||||
title={entry.task_id}
|
||||
>
|
||||
Task #{entry.task_id.slice(0, 8)}
|
||||
</Badge>
|
||||
</Link>
|
||||
<CopyButton value={entry.task_id} className="px-1 py-0.5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
AgentUsageChart,
|
||||
TeamUsageChart,
|
||||
TaskStatusChart,
|
||||
TaskStatusTiles,
|
||||
SessionsTable,
|
||||
} from "@/components/metrics";
|
||||
import {
|
||||
@@ -330,34 +331,36 @@ function PerformanceTabContent() {
|
||||
{/* Task Status */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
|
||||
<div className="grid gap-4 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3">
|
||||
<div className="lg:col-span-2 grid gap-4 sm:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5">
|
||||
<MetricCard
|
||||
title="Pending"
|
||||
value={pending}
|
||||
icon={<Clock className="h-4 w-4 text-gray-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="In Progress"
|
||||
value={inProgress}
|
||||
icon={<Activity className="h-4 w-4 text-blue-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Blocked"
|
||||
value={blocked}
|
||||
icon={<AlertTriangle className="h-4 w-4 text-red-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Awaiting QA"
|
||||
value={awaitingQa}
|
||||
icon={<Timer className="h-4 w-4 text-yellow-500" />}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Completed"
|
||||
value={completed}
|
||||
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<TaskStatusTiles
|
||||
tiles={[
|
||||
{
|
||||
label: "Pending",
|
||||
value: pending,
|
||||
icon: <Clock className="h-3.5 w-3.5 text-gray-500" />,
|
||||
},
|
||||
{
|
||||
label: "In Progress",
|
||||
value: inProgress,
|
||||
icon: <Activity className="h-3.5 w-3.5 text-blue-500" />,
|
||||
},
|
||||
{
|
||||
label: "Blocked",
|
||||
value: blocked,
|
||||
icon: <AlertTriangle className="h-3.5 w-3.5 text-red-500" />,
|
||||
},
|
||||
{
|
||||
label: "Awaiting QA",
|
||||
value: awaitingQa,
|
||||
icon: <Timer className="h-3.5 w-3.5 text-yellow-500" />,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: completed,
|
||||
icon: <CheckCircle className="h-3.5 w-3.5 text-green-500" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<TaskStatusChart
|
||||
slices={[
|
||||
{ name: "Pending", value: pending },
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { EntryCard } from "../entry-card";
|
||||
import { JournalEntryType, type JournalEntry } from "@/types";
|
||||
|
||||
// CEO feedback: journal entry ids were shown truncated with no way to copy
|
||||
// the full id and no quick-link to the related task. Guards against
|
||||
// regressing either fix, and against re-nesting the task link/copy button
|
||||
// inside the card's own entry-detail <Link> (invalid nested anchors).
|
||||
|
||||
const baseEntry: JournalEntry = {
|
||||
id: "entry-11112222-3333-4444-5555-666677778888",
|
||||
journal_id: "journal-1",
|
||||
type: JournalEntryType.GENERAL,
|
||||
title: "Shipped the thing",
|
||||
content: "Body",
|
||||
task_id: "e27ef84d-1111-2222-3333-444455556666",
|
||||
session_id: null,
|
||||
timestamp: "2026-07-10T12:00:00Z",
|
||||
tags: [],
|
||||
sentiment: null,
|
||||
is_private: false,
|
||||
created_at: "2026-07-10T12:00:00Z",
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
describe("EntryCard — task id display", () => {
|
||||
it("shows the id8 prefix without an ellipsis", () => {
|
||||
render(<EntryCard entry={baseEntry} />);
|
||||
expect(screen.getByText("Task #e27ef84d")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/e27ef84d\.\.\./)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links the task badge to the task detail page", () => {
|
||||
render(<EntryCard entry={baseEntry} />);
|
||||
const taskLink = screen.getByText("Task #e27ef84d").closest("a");
|
||||
expect(taskLink).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/e27ef84d-1111-2222-3333-444455556666",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a copy button for the full task id, separate from the task link", () => {
|
||||
render(<EntryCard entry={baseEntry} />);
|
||||
const copyButton = screen.getByRole("button", { name: /copy/i });
|
||||
expect(copyButton.closest("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("links the card body to the entry detail page", () => {
|
||||
render(<EntryCard entry={baseEntry} />);
|
||||
const entryLink = screen.getByText("Shipped the thing").closest("a");
|
||||
expect(entryLink).toHaveAttribute(
|
||||
"href",
|
||||
"/journals/entry-11112222-3333-4444-5555-666677778888",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the task row entirely when there is no related task", () => {
|
||||
render(<EntryCard entry={{ ...baseEntry, task_id: null }} />);
|
||||
expect(screen.queryByText(/^Task #/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { JournalEntry } from "@/types";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
import { EntryTypeBadge } from "./entry-type-badge";
|
||||
import { Clock, Tag, Link2, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -24,9 +25,16 @@ function formatTime(timestamp: string): string {
|
||||
|
||||
export function EntryCard({ entry }: EntryCardProps) {
|
||||
return (
|
||||
<Link href={`/journals/${entry.id}`} prefetch={false}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer group">
|
||||
<CardContent className="pt-4">
|
||||
<Card className="hover:shadow-md transition-shadow group">
|
||||
<CardContent className="pt-4">
|
||||
{/* Clicking the header/title/content opens the entry. The footer's
|
||||
task badge + copy button are deliberately outside this link so
|
||||
they aren't nested anchors and don't trigger entry navigation. */}
|
||||
<Link
|
||||
href={`/journals/${entry.id}`}
|
||||
prefetch={false}
|
||||
className="block cursor-pointer"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -53,36 +61,45 @@ export function EntryCard({ entry }: EntryCardProps) {
|
||||
<div className="text-sm text-muted-foreground line-clamp-4">
|
||||
<Markdown>{entry.content}</Markdown>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
|
||||
{/* Tags */}
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
{entry.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{entry.tags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{entry.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t flex-wrap">
|
||||
{/* Tags */}
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
{entry.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{entry.tags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{entry.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Task */}
|
||||
{entry.task_id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Task #{entry.task_id.slice(0, 8)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
{/* Related Task — quick-link + copy full id */}
|
||||
{entry.task_id && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs cursor-pointer hover:bg-muted"
|
||||
title={entry.task_id}
|
||||
>
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Task #{entry.task_id.slice(0, 8)}
|
||||
</Badge>
|
||||
</Link>
|
||||
<CopyButton value={entry.task_id} className="px-1 py-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskStatusTiles } from "../task-status-tiles";
|
||||
|
||||
describe("TaskStatusTiles", () => {
|
||||
it("renders a tile per status with its label and value", () => {
|
||||
render(
|
||||
<TaskStatusTiles
|
||||
tiles={[
|
||||
{ label: "Pending", value: 3, icon: <span>icon</span> },
|
||||
{ label: "Completed", value: 12, icon: <span>icon</span> },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
expect(screen.getByText("12")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one tile for every entry passed", () => {
|
||||
render(
|
||||
<TaskStatusTiles
|
||||
tiles={[
|
||||
{ label: "A", value: 1, icon: <span>icon</span> },
|
||||
{ label: "B", value: 2, icon: <span>icon</span> },
|
||||
{ label: "C", value: 3, icon: <span>icon</span> },
|
||||
{ label: "D", value: 4, icon: <span>icon</span> },
|
||||
{ label: "E", value: 5, icon: <span>icon</span> },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
["A", "B", "C", "D", "E"].forEach((label) => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,4 +3,6 @@ export { ModelUsageDonut } from "./model-usage-donut";
|
||||
export { AgentUsageChart } from "./agent-usage-chart";
|
||||
export { TeamUsageChart } from "./team-usage-chart";
|
||||
export { TaskStatusChart } from "./task-status-chart";
|
||||
export { TaskStatusTiles } from "./task-status-tiles";
|
||||
export type { TaskStatusTileData } from "./task-status-tiles";
|
||||
export { SessionsTable } from "./sessions-table";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TaskStatusTileData {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TaskStatusTilesProps {
|
||||
tiles: TaskStatusTileData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact grid of task-status counts (label + number + status-colored icon
|
||||
* per tile) — replaces five full-size stat cards that sat mostly empty next
|
||||
* to the status donut (CEO feedback: "much smaller cards 2x3 or something").
|
||||
*/
|
||||
export function TaskStatusTiles({ tiles, className }: TaskStatusTilesProps) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 sm:grid-cols-3 gap-2", className)}>
|
||||
{tiles.map((tile) => (
|
||||
<Card key={tile.label} className="gap-1 py-3">
|
||||
<div className="flex items-center gap-1.5 px-3 text-xs text-muted-foreground">
|
||||
{tile.icon}
|
||||
<span className="truncate">{tile.label}</span>
|
||||
</div>
|
||||
<div className="px-3 text-xl font-bold">{tile.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user