Files
mission-control/src/components/FocusCard.tsx
teernisse df53096aa8 feat: implement React UI components for focus and queue views
Complete frontend implementation for Mission Control's core workflow:
surface THE ONE THING and let the user act on it.

Layout (AppShell.tsx):
- Tab navigation: Focus | Queue | Inbox
- View switching with AnimatePresence transitions
- Global shortcut event listener for quick capture
- Batch mode overlay when active

Focus View (FocusView.tsx, FocusCard.tsx):
- Prominent display of THE ONE THING
- Type badge with staleness coloring (fresh/normal/amber/urgent)
- Context quote and requestedBy for reviews
- Action buttons: Start (Enter), 1h (Cmd+1), Tomorrow (Cmd+2), Skip (Cmd+S)
- Empty state: "All Clear" when queue is empty

Queue View (QueueView.tsx, QueueItem.tsx, QueueSummary.tsx):
- List view of all items with reordering capability
- Click to set as focus (promotes to THE ONE THING)
- Summary shows counts by type
- Links back to Focus view

Quick Capture (QuickCapture.tsx):
- Modal overlay triggered by Cmd+Shift+C
- Creates new bead via quick_capture command
- Shows success/error feedback

Batch Mode (BatchMode.tsx):
- Full-screen overlay for rapid item processing
- Progress indicator: 3/10 DONE
- Same action buttons as FocusCard
- Exit returns to regular Focus view

App entry updates:
- App.tsx now renders AppShell
- main.tsx unchanged (React 19 + StrictMode)
- Tailwind config adds MC-specific colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 09:54:46 -05:00

185 lines
5.3 KiB
TypeScript

/**
* FocusCard -- THE ONE THING component.
*
* Displays a single work item with maximum prominence.
* Action buttons and keyboard shortcuts let the user
* Start (open in browser), Defer (1h/tomorrow), or Skip.
*/
import { useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { FocusItem, FocusItemType, Staleness } from "@/lib/types";
import { computeStaleness } from "@/lib/types";
import { formatIid } from "@/lib/format";
interface FocusCardProps {
item: FocusItem | null;
onStart: () => void;
onDefer1h: () => void;
onDeferTomorrow: () => void;
onSkip: () => void;
}
const TYPE_LABELS: Record<FocusItemType, string> = {
mr_review: "MR REVIEW",
issue: "ISSUE",
mr_authored: "MR AUTHORED",
manual: "TASK",
};
const STALENESS_COLORS: Record<Staleness, string> = {
fresh: "bg-mc-fresh/20 text-mc-fresh border-mc-fresh/30",
normal: "bg-zinc-700/50 text-zinc-300 border-zinc-600",
amber: "bg-mc-amber/20 text-mc-amber border-mc-amber/30",
urgent: "bg-mc-urgent/20 text-mc-urgent border-mc-urgent/30",
};
export function FocusCard({
item,
onStart,
onDefer1h,
onDeferTomorrow,
onSkip,
}: FocusCardProps): React.ReactElement {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!item) return;
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
onStart();
} else if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onSkip();
} else if (e.key === "1" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onDefer1h();
} else if (e.key === "2" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onDeferTomorrow();
}
},
[item, onStart, onSkip, onDefer1h, onDeferTomorrow]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
if (!item) {
return <EmptyState />;
}
const staleness = computeStaleness(item.updatedAt);
return (
<AnimatePresence mode="wait">
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
className="mx-auto w-full max-w-lg"
>
{/* Type badge */}
<div className="mb-6 flex justify-center">
<span
className={`rounded-full border px-4 py-1.5 text-xs font-bold tracking-wider ${STALENESS_COLORS[staleness]}`}
>
{TYPE_LABELS[item.type]}
</span>
</div>
{/* Title */}
<h2 className="mb-3 text-center text-2xl font-bold tracking-tight text-zinc-100">
{item.title}
</h2>
{/* Metadata line */}
<p className="mb-6 text-center text-sm text-zinc-400">
{formatIid(item.type, item.iid)} in {item.project}
</p>
{/* Context quote */}
{(item.contextQuote || item.requestedBy) && (
<div className="mb-8 rounded-lg border border-zinc-700 bg-surface p-4">
{item.requestedBy && (
<p className="mb-1 text-xs font-medium text-zinc-400">
@{item.requestedBy}
</p>
)}
{item.contextQuote && (
<p className="text-sm italic text-zinc-300">
&ldquo;{item.contextQuote}&rdquo;
</p>
)}
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-center gap-3">
<ActionButton
label="Start"
shortcut="Enter"
onClick={onStart}
variant="primary"
/>
<ActionButton label="1 hour" shortcut="Cmd+1" onClick={onDefer1h} />
<ActionButton
label="Tomorrow"
shortcut="Cmd+2"
onClick={onDeferTomorrow}
/>
<ActionButton label="Skip" shortcut="Cmd+S" onClick={onSkip} />
</div>
</motion.div>
</AnimatePresence>
);
}
function ActionButton({
label,
shortcut,
onClick,
variant = "default",
}: {
label: string;
shortcut: string;
onClick: () => void;
variant?: "primary" | "default";
}): React.ReactElement {
const base =
"flex flex-col items-center gap-1 rounded-lg border px-5 py-3 text-sm font-medium transition-colors";
const styles =
variant === "primary"
? `${base} border-mc-fresh/40 bg-mc-fresh/10 text-mc-fresh hover:bg-mc-fresh/20`
: `${base} border-zinc-600 bg-surface-raised text-zinc-300 hover:bg-surface-overlay hover:text-zinc-100`;
return (
<button type="button" className={styles} onClick={onClick}>
<span>{label}</span>
<span className="text-[10px] text-zinc-500">{shortcut}</span>
</button>
);
}
function EmptyState(): React.ReactElement {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto flex max-w-md flex-col items-center py-16 text-center"
>
<div className="mb-4 text-5xl" aria-hidden="true">
&#10003;
</div>
<h2 className="mb-2 text-2xl font-bold text-zinc-100">All Clear</h2>
<p className="text-zinc-400">
Nothing needs your attention right now. Nice work.
</p>
</motion.div>
);
}