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:
Renn F
2026-07-15 16:03:11 +02:00
parent 614c22ed70
commit 2efc2f9542
8 changed files with 318 additions and 69 deletions
@@ -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>
)}
+31 -28
View File
@@ -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();
});
});
+49 -32
View File
@@ -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();
});
});
});
+2
View File
@@ -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>
);
}