diff --git a/src/components/TrayPopover.tsx b/src/components/TrayPopover.tsx new file mode 100644 index 0000000..dba3d63 --- /dev/null +++ b/src/components/TrayPopover.tsx @@ -0,0 +1,150 @@ +/** + * TrayPopover -- compact view of THE ONE THING for system tray. + * + * Shows: + * - Current focus item with quick actions (Start/Defer/Skip) + * - Queue and inbox counts + * - Link to open full window + */ + +import type { FocusItem, DeferDuration } from "@/lib/types"; + +interface TrayPopoverProps { + /** Current focus item, or null if nothing is focused */ + focusItem: FocusItem | null; + /** Number of items in the queue */ + queueCount: number; + /** Number of items in the inbox */ + inboxCount: number; + /** Called when Start is clicked */ + onStart?: () => void; + /** Called when Defer is clicked, with duration */ + onDefer?: (duration: DeferDuration) => void; + /** Called when Skip is clicked */ + onSkip?: () => void; + /** Called when "Full window" is clicked */ + onOpenFull?: () => void; +} + +/** + * Format a relative time string like "2d" for "2 days ago" + */ +function formatAge(isoDate: string | null): string { + if (!isoDate) return ""; + + const now = Date.now(); + const then = new Date(isoDate).getTime(); + const diff = now - then; + + if (Number.isNaN(diff)) return ""; + + const minutes = Math.floor(diff / (1000 * 60)); + if (minutes < 60) return `${minutes}m`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + + const days = Math.floor(hours / 24); + return `${days}d`; +} + +/** + * Format the type label for display + */ +function formatTypeLabel(type: FocusItem["type"]): string { + switch (type) { + case "mr_review": + return "Review"; + case "mr_authored": + return "MR"; + case "issue": + return "Issue"; + case "manual": + return "Task"; + } +} + +export function TrayPopover({ + focusItem, + queueCount, + inboxCount, + onStart, + onDefer, + onSkip, + onOpenFull, +}: TrayPopoverProps): React.ReactElement { + return ( +
+ {/* Focus Item Section */} +
+ {focusItem ? ( + <> +

+ THE ONE THING +

+

+ {focusItem.title} +

+
+ + {formatTypeLabel(focusItem.type)} + + {focusItem.project} + {focusItem.updatedAt && ( + <> + ยท + {formatAge(focusItem.updatedAt)} + + )} +
+ + {/* Action Buttons */} +
+ + + +
+ + ) : ( +

+ Nothing focused. Pick something from the queue! +

+ )} +
+ + {/* Separator */} +
+ + {/* Counts Row */} +
+ Queue: {queueCount} + Inbox: {inboxCount} +
+ + {/* Full Window Link */} +
+ +
+
+ ); +} diff --git a/tests/components/TrayPopover.test.tsx b/tests/components/TrayPopover.test.tsx new file mode 100644 index 0000000..f05f955 --- /dev/null +++ b/tests/components/TrayPopover.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TrayPopover } from "@/components/TrayPopover"; +import { makeFocusItem } from "../helpers/fixtures"; + +describe("TrayPopover", () => { + describe("with focus item", () => { + it("shows 'THE ONE THING' header", () => { + const item = makeFocusItem({ title: "Review MR !847" }); + render(); + + expect(screen.getByText("THE ONE THING")).toBeInTheDocument(); + }); + + it("shows focus item title", () => { + const item = makeFocusItem({ title: "Fix auth bug" }); + render(); + + expect(screen.getByText("Fix auth bug")).toBeInTheDocument(); + }); + + it("shows project path", () => { + const item = makeFocusItem({ project: "platform/core" }); + render(); + + expect(screen.getByText(/platform\/core/)).toBeInTheDocument(); + }); + + it("shows type badge", () => { + const item = makeFocusItem({ type: "mr_review" }); + render(); + + expect(screen.getByText(/review/i)).toBeInTheDocument(); + }); + + it("shows Start button", () => { + const item = makeFocusItem(); + render(); + + expect(screen.getByRole("button", { name: /start/i })).toBeInTheDocument(); + }); + + it("shows Defer button", () => { + const item = makeFocusItem(); + render(); + + expect(screen.getByRole("button", { name: /defer/i })).toBeInTheDocument(); + }); + + it("shows Skip button", () => { + const item = makeFocusItem(); + render(); + + expect(screen.getByRole("button", { name: /skip/i })).toBeInTheDocument(); + }); + }); + + describe("empty state", () => { + it("shows empty message when no focus item", () => { + render(); + + expect(screen.getByText(/nothing focused/i)).toBeInTheDocument(); + }); + + it("does not show action buttons when empty", () => { + render(); + + expect(screen.queryByRole("button", { name: /start/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /defer/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /skip/i })).not.toBeInTheDocument(); + }); + }); + + describe("counts", () => { + it("shows queue count", () => { + render(); + + expect(screen.getByText("Queue: 4")).toBeInTheDocument(); + }); + + it("shows inbox count", () => { + render(); + + expect(screen.getByText("Inbox: 3")).toBeInTheDocument(); + }); + + it("shows both counts", () => { + render(); + + expect(screen.getByText("Queue: 5")).toBeInTheDocument(); + expect(screen.getByText("Inbox: 2")).toBeInTheDocument(); + }); + }); + + describe("full window link", () => { + it("shows keyboard shortcut hint", () => { + render(); + + // Should show some reference to opening full window + expect(screen.getByText(/full window/i)).toBeInTheDocument(); + }); + }); + + describe("actions", () => { + it("calls onStart when Start clicked", async () => { + const user = userEvent.setup(); + const onStart = vi.fn(); + const item = makeFocusItem(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: /start/i })); + expect(onStart).toHaveBeenCalledTimes(1); + }); + + it("calls onDefer with duration when Defer clicked", async () => { + const user = userEvent.setup(); + const onDefer = vi.fn(); + const item = makeFocusItem(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: /defer/i })); + expect(onDefer).toHaveBeenCalledWith("1h"); + }); + + it("calls onSkip when Skip clicked", async () => { + const user = userEvent.setup(); + const onSkip = vi.fn(); + const item = makeFocusItem(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: /skip/i })); + expect(onSkip).toHaveBeenCalledTimes(1); + }); + + it("calls onOpenFull when full window link clicked", async () => { + const user = userEvent.setup(); + const onOpenFull = vi.fn(); + + render( + + ); + + await user.click(screen.getByText(/full window/i)); + expect(onOpenFull).toHaveBeenCalledTimes(1); + }); + }); + + describe("relative time", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-26T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows age for items", () => { + const item = makeFocusItem({ + updatedAt: new Date("2026-02-24T12:00:00Z").toISOString(), // 2 days ago + }); + + render(); + + expect(screen.getByText(/2d/)).toBeInTheDocument(); + }); + }); +});