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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user