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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
import { EntryTypeBadge } from "@/components/journals/entry-type-badge";
|
import { EntryTypeBadge } from "@/components/journals/entry-type-badge";
|
||||||
|
import { CopyButton } from "@/components/ui/copy-button";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -148,7 +149,13 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
|||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
<span>Journal</span>
|
<span>Journal</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -159,14 +166,18 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
|||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
<span>Related Task</span>
|
<span>Related Task</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
|
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="hover:bg-muted cursor-pointer"
|
className="hover:bg-muted cursor-pointer"
|
||||||
|
title={entry.task_id}
|
||||||
>
|
>
|
||||||
Task #{entry.task_id.slice(0, 8)}
|
Task #{entry.task_id.slice(0, 8)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
|
<CopyButton value={entry.task_id} className="px-1 py-0.5" />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
AgentUsageChart,
|
AgentUsageChart,
|
||||||
TeamUsageChart,
|
TeamUsageChart,
|
||||||
TaskStatusChart,
|
TaskStatusChart,
|
||||||
|
TaskStatusTiles,
|
||||||
SessionsTable,
|
SessionsTable,
|
||||||
} from "@/components/metrics";
|
} from "@/components/metrics";
|
||||||
import {
|
import {
|
||||||
@@ -330,34 +331,36 @@ function PerformanceTabContent() {
|
|||||||
{/* Task Status */}
|
{/* Task Status */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
|
<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="grid gap-4 lg:grid-cols-2">
|
||||||
<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">
|
<TaskStatusTiles
|
||||||
<MetricCard
|
tiles={[
|
||||||
title="Pending"
|
{
|
||||||
value={pending}
|
label: "Pending",
|
||||||
icon={<Clock className="h-4 w-4 text-gray-500" />}
|
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" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
<TaskStatusChart
|
<TaskStatusChart
|
||||||
slices={[
|
slices={[
|
||||||
{ name: "Pending", value: pending },
|
{ 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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
|
import { CopyButton } from "@/components/ui/copy-button";
|
||||||
import { EntryTypeBadge } from "./entry-type-badge";
|
import { EntryTypeBadge } from "./entry-type-badge";
|
||||||
import { Clock, Tag, Link2, ChevronRight } from "lucide-react";
|
import { Clock, Tag, Link2, ChevronRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -24,9 +25,16 @@ function formatTime(timestamp: string): string {
|
|||||||
|
|
||||||
export function EntryCard({ entry }: EntryCardProps) {
|
export function EntryCard({ entry }: EntryCardProps) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/journals/${entry.id}`} prefetch={false}>
|
<Card className="hover:shadow-md transition-shadow group">
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer group">
|
|
||||||
<CardContent className="pt-4">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -53,9 +61,10 @@ export function EntryCard({ entry }: EntryCardProps) {
|
|||||||
<div className="text-sm text-muted-foreground line-clamp-4">
|
<div className="text-sm text-muted-foreground line-clamp-4">
|
||||||
<Markdown>{entry.content}</Markdown>
|
<Markdown>{entry.content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
|
<div className="flex items-center gap-4 mt-3 pt-3 border-t flex-wrap">
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{entry.tags.length > 0 && (
|
{entry.tags.length > 0 && (
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
@@ -73,16 +82,24 @@ export function EntryCard({ entry }: EntryCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Related Task */}
|
{/* Related Task — quick-link + copy full id */}
|
||||||
{entry.task_id && (
|
{entry.task_id && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<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" />
|
<Link2 className="h-3 w-3 mr-1" />
|
||||||
Task #{entry.task_id.slice(0, 8)}
|
Task #{entry.task_id.slice(0, 8)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
<CopyButton value={entry.task_id} className="px-1 py-0.5" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { AgentUsageChart } from "./agent-usage-chart";
|
||||||
export { TeamUsageChart } from "./team-usage-chart";
|
export { TeamUsageChart } from "./team-usage-chart";
|
||||||
export { TaskStatusChart } from "./task-status-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";
|
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