feat: add TrayPopover component for menu bar quick access

Shows THE ONE THING with:
- Focus item title, type badge, project, and age
- Quick actions: Start, Defer (1h), Skip
- Queue and inbox counts
- Link to open full window
- Empty state when nothing focused

Includes 18 tests covering all states and interactions.

bd-wlg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:37:00 -05:00
parent d2df4cee21
commit 32d7e8ee74
2 changed files with 348 additions and 0 deletions

View File

@@ -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 (
<div className="w-72 bg-zinc-900 rounded-lg shadow-lg">
{/* Focus Item Section */}
<div className="p-4">
{focusItem ? (
<>
<p className="text-xs text-zinc-500 uppercase tracking-wide mb-1">
THE ONE THING
</p>
<h3 className="font-semibold text-zinc-100 truncate">
{focusItem.title}
</h3>
<div className="flex items-center gap-2 mt-1 text-sm text-zinc-400">
<span className="px-1.5 py-0.5 rounded bg-zinc-800 text-xs">
{formatTypeLabel(focusItem.type)}
</span>
<span className="truncate">{focusItem.project}</span>
{focusItem.updatedAt && (
<>
<span className="text-zinc-600">·</span>
<span>{formatAge(focusItem.updatedAt)}</span>
</>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 mt-4">
<button
onClick={onStart}
className="flex-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded transition-colors"
>
Start
</button>
<button
onClick={() => onDefer?.("1h")}
className="flex-1 px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-sm font-medium rounded border border-zinc-700 transition-colors"
>
Defer
</button>
<button
onClick={onSkip}
className="flex-1 px-3 py-1.5 text-zinc-400 hover:text-zinc-200 text-sm font-medium transition-colors"
>
Skip
</button>
</div>
</>
) : (
<p className="text-zinc-500 text-center py-4">
Nothing focused. Pick something from the queue!
</p>
)}
</div>
{/* Separator */}
<div className="border-t border-zinc-800" />
{/* Counts Row */}
<div className="px-4 py-3 flex justify-between text-sm text-zinc-400">
<span>Queue: {queueCount}</span>
<span>Inbox: {inboxCount}</span>
</div>
{/* Full Window Link */}
<div className="border-t border-zinc-800 px-4 py-2">
<button
onClick={onOpenFull}
className="w-full text-center text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Full window
</button>
</div>
</div>
);
}

View File

@@ -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(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByText("THE ONE THING")).toBeInTheDocument();
});
it("shows focus item title", () => {
const item = makeFocusItem({ title: "Fix auth bug" });
render(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByText("Fix auth bug")).toBeInTheDocument();
});
it("shows project path", () => {
const item = makeFocusItem({ project: "platform/core" });
render(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByText(/platform\/core/)).toBeInTheDocument();
});
it("shows type badge", () => {
const item = makeFocusItem({ type: "mr_review" });
render(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByText(/review/i)).toBeInTheDocument();
});
it("shows Start button", () => {
const item = makeFocusItem();
render(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByRole("button", { name: /start/i })).toBeInTheDocument();
});
it("shows Defer button", () => {
const item = makeFocusItem();
render(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByRole("button", { name: /defer/i })).toBeInTheDocument();
});
it("shows Skip button", () => {
const item = makeFocusItem();
render(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByRole("button", { name: /skip/i })).toBeInTheDocument();
});
});
describe("empty state", () => {
it("shows empty message when no focus item", () => {
render(<TrayPopover focusItem={null} queueCount={0} inboxCount={0} />);
expect(screen.getByText(/nothing focused/i)).toBeInTheDocument();
});
it("does not show action buttons when empty", () => {
render(<TrayPopover focusItem={null} queueCount={0} inboxCount={0} />);
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(<TrayPopover focusItem={null} queueCount={4} inboxCount={0} />);
expect(screen.getByText("Queue: 4")).toBeInTheDocument();
});
it("shows inbox count", () => {
render(<TrayPopover focusItem={null} queueCount={0} inboxCount={3} />);
expect(screen.getByText("Inbox: 3")).toBeInTheDocument();
});
it("shows both counts", () => {
render(<TrayPopover focusItem={null} queueCount={5} inboxCount={2} />);
expect(screen.getByText("Queue: 5")).toBeInTheDocument();
expect(screen.getByText("Inbox: 2")).toBeInTheDocument();
});
});
describe("full window link", () => {
it("shows keyboard shortcut hint", () => {
render(<TrayPopover focusItem={null} queueCount={0} inboxCount={0} />);
// 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(
<TrayPopover
focusItem={item}
queueCount={0}
inboxCount={0}
onStart={onStart}
/>
);
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(
<TrayPopover
focusItem={item}
queueCount={0}
inboxCount={0}
onDefer={onDefer}
/>
);
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(
<TrayPopover
focusItem={item}
queueCount={0}
inboxCount={0}
onSkip={onSkip}
/>
);
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(
<TrayPopover
focusItem={null}
queueCount={0}
inboxCount={0}
onOpenFull={onOpenFull}
/>
);
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(<TrayPopover focusItem={item} queueCount={0} inboxCount={0} />);
expect(screen.getByText(/2d/)).toBeInTheDocument();
});
});
});