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>
185 lines
5.3 KiB
TypeScript
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">
|
|
“{item.contextQuote}”
|
|
</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">
|
|
✓
|
|
</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>
|
|
);
|
|
}
|