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:
150
src/components/TrayPopover.tsx
Normal file
150
src/components/TrayPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
tests/components/TrayPopover.test.tsx
Normal file
198
tests/components/TrayPopover.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user