Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #458.
This commit is contained in:
@@ -1,85 +1,55 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import DashboardPage from "./page";
|
import DashboardPage from "./page";
|
||||||
|
|
||||||
// Mock dashboard widgets
|
// Mock Phase 3 dashboard widgets
|
||||||
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
|
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
|
||||||
RecentTasksWidget: ({
|
DashboardMetrics: (): React.JSX.Element => (
|
||||||
tasks,
|
<div data-testid="dashboard-metrics">Dashboard Metrics</div>
|
||||||
isLoading,
|
|
||||||
}: {
|
|
||||||
tasks: unknown[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div data-testid="recent-tasks">
|
|
||||||
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
|
vi.mock("@/components/dashboard/OrchestratorSessions", () => ({
|
||||||
UpcomingEventsWidget: ({
|
OrchestratorSessions: (): React.JSX.Element => (
|
||||||
events,
|
<div data-testid="orchestrator-sessions">Orchestrator Sessions</div>
|
||||||
isLoading,
|
|
||||||
}: {
|
|
||||||
events: unknown[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div data-testid="upcoming-events">
|
|
||||||
{isLoading ? "Loading events" : `${String(events.length)} events`}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
|
vi.mock("@/components/dashboard/QuickActions", () => ({
|
||||||
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
|
QuickActions: (): React.JSX.Element => <div data-testid="quick-actions">Quick Actions</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
|
vi.mock("@/components/dashboard/ActivityFeed", () => ({
|
||||||
DomainOverviewWidget: ({
|
ActivityFeed: (): React.JSX.Element => <div data-testid="activity-feed">Activity Feed</div>,
|
||||||
tasks,
|
}));
|
||||||
isLoading,
|
|
||||||
}: {
|
vi.mock("@/components/dashboard/TokenBudget", () => ({
|
||||||
tasks: unknown[];
|
TokenBudget: (): React.JSX.Element => <div data-testid="token-budget">Token Budget</div>,
|
||||||
isLoading: boolean;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div data-testid="domain-overview">
|
|
||||||
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("DashboardPage", (): void => {
|
describe("DashboardPage", (): void => {
|
||||||
it("should render the page title", (): void => {
|
it("should render the DashboardMetrics widget", (): void => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
|
expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading state initially", (): void => {
|
it("should render the OrchestratorSessions widget", (): void => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("Loading tasks");
|
expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("Loading events");
|
|
||||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("Loading overview");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render all widgets with data after loading", async (): Promise<void> => {
|
it("should render the QuickActions widget", (): void => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
await waitFor((): void => {
|
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
|
|
||||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("3 events");
|
|
||||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("4 tasks overview");
|
|
||||||
expect(screen.getByTestId("quick-capture")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have proper layout structure", (): void => {
|
it("should render the ActivityFeed widget", (): void => {
|
||||||
const { container } = render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
const main = container.querySelector("main");
|
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
|
||||||
expect(main).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the welcome subtitle", (): void => {
|
it("should render the TokenBudget widget", (): void => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
|
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,13 +11,7 @@ export default function DashboardPage(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<DashboardMetrics />
|
<DashboardMetrics />
|
||||||
<div
|
<div className="dash-grid">
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 320px",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||||
<OrchestratorSessions />
|
<OrchestratorSessions />
|
||||||
<QuickActions />
|
<QuickActions />
|
||||||
|
|||||||
@@ -765,6 +765,62 @@ body::before {
|
|||||||
animation: scaleIn 0.1s ease-out;
|
animation: scaleIn 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Dashboard Layout — Responsive Grids
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
.metrics-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--ms-cols, 6), 1fr);
|
||||||
|
gap: 0;
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.metrics-strip {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(3n + 1) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.metrics-strip {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(3n + 1) {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell:nth-child(2n + 1) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dash-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Responsive Typography Adjustments
|
Responsive Typography Adjustments
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
gap: 10,
|
gap: 12,
|
||||||
padding: "8px 0",
|
padding: "10px 0",
|
||||||
borderBottom: "1px solid var(--border)",
|
borderBottom: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
|
||||||
|
|
||||||
interface DomainOverviewWidgetProps {
|
|
||||||
tasks: Task[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DomainOverviewWidget({
|
|
||||||
tasks,
|
|
||||||
isLoading,
|
|
||||||
}: DomainOverviewWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading overview...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: tasks.length,
|
|
||||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
|
||||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
|
||||||
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatCard = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}): React.JSX.Element => (
|
|
||||||
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
|
|
||||||
<div className="text-3xl font-bold text-white mb-1">{value}</div>
|
|
||||||
<div className="text-sm text-white/90">{label}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
|
|
||||||
<StatCard
|
|
||||||
label="In Progress"
|
|
||||||
value={stats.inProgress}
|
|
||||||
color="from-green-500 to-green-600"
|
|
||||||
/>
|
|
||||||
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
|
|
||||||
<StatCard
|
|
||||||
label="High Priority"
|
|
||||||
value={stats.highPriority}
|
|
||||||
color="from-red-500 to-red-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -169,7 +169,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
background: "var(--bg-mid)",
|
background: "var(--bg-mid)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "var(--r-md)",
|
borderRadius: "var(--r)",
|
||||||
padding: "12px 14px",
|
padding: "12px 14px",
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ interface QuickAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions: QuickAction[] = [
|
const actions: QuickAction[] = [
|
||||||
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
|
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.12)" },
|
||||||
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
|
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.12)" },
|
||||||
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
|
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.12)" },
|
||||||
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
|
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.12)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
@@ -36,24 +36,25 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: "16px 12px",
|
padding: "10px 12px",
|
||||||
borderRadius: "var(--r-md)",
|
borderRadius: "var(--r)",
|
||||||
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||||
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "border-color 0.15s, background 0.15s",
|
transition: "border-color 0.15s, background 0.15s, color 0.15s",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: hovered ? "var(--text)" : "var(--text-2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
borderRadius: 6,
|
borderRadius: 5,
|
||||||
background: action.iconBg,
|
background: action.iconBg,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -63,15 +64,7 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
|||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span>{action.label}</span>
|
||||||
style={{
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,7 +77,7 @@ export function QuickActions(): ReactElement {
|
|||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: 10,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@mosaic/ui";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ComingSoon } from "@/components/ui/ComingSoon";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we're in development mode (runtime check for testability)
|
|
||||||
*/
|
|
||||||
function isDevelopment(): boolean {
|
|
||||||
return process.env.NODE_ENV === "development";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal Quick Capture Widget implementation
|
|
||||||
*/
|
|
||||||
function QuickCaptureWidgetInternal(): React.JSX.Element {
|
|
||||||
const [idea, setIdea] = useState("");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!idea.trim()) return;
|
|
||||||
|
|
||||||
// TODO: Implement quick capture API call
|
|
||||||
// For now, just show a success indicator
|
|
||||||
console.log("Quick capture:", idea);
|
|
||||||
setIdea("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToTasks = (): void => {
|
|
||||||
router.push("/tasks");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">Quickly jot down ideas or brain dumps</p>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
<textarea
|
|
||||||
value={idea}
|
|
||||||
onChange={(e) => {
|
|
||||||
setIdea(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="What's on your mind?"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" variant="primary" size="sm">
|
|
||||||
Save Note
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={goToTasks}>
|
|
||||||
Create Task
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick Capture Widget (Dashboard version)
|
|
||||||
*
|
|
||||||
* In production: Shows Coming Soon placeholder
|
|
||||||
* In development: Full widget functionality
|
|
||||||
*/
|
|
||||||
export function QuickCaptureWidget(): React.JSX.Element {
|
|
||||||
// In production, show Coming Soon placeholder
|
|
||||||
if (!isDevelopment()) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<ComingSoon
|
|
||||||
feature="Quick Capture"
|
|
||||||
description="Quickly jot down ideas for later organization. This feature is currently under development."
|
|
||||||
className="!p-0 !min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In development, show full widget functionality
|
|
||||||
return <QuickCaptureWidgetInternal />;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskPriority } from "@mosaic/shared";
|
|
||||||
import { formatDate } from "@/lib/utils/date-format";
|
|
||||||
import { TaskStatus } from "@mosaic/shared";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface RecentTasksWidgetProps {
|
|
||||||
tasks: Task[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIcons: Record<TaskStatus, string> = {
|
|
||||||
[TaskStatus.NOT_STARTED]: "⚪",
|
|
||||||
[TaskStatus.IN_PROGRESS]: "🟢",
|
|
||||||
[TaskStatus.PAUSED]: "⏸️",
|
|
||||||
[TaskStatus.COMPLETED]: "✅",
|
|
||||||
[TaskStatus.ARCHIVED]: "💤",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentTasks = tasks.slice(0, 5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
|
|
||||||
<Link href="/tasks" className="text-sm text-blue-600 hover:text-blue-700">
|
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{recentTasks.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{recentTasks.map((task) => (
|
|
||||||
<li
|
|
||||||
key={task.id}
|
|
||||||
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
|
||||||
{statusIcons[task.status]}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm truncate">{task.title}</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
{task.priority !== TaskPriority.LOW && (
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
task.priority === TaskPriority.HIGH
|
|
||||||
? "bg-red-100 text-red-700"
|
|
||||||
: "bg-blue-100 text-blue-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.priority}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{task.dueDate && (
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(task.dueDate)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { Event } from "@mosaic/shared";
|
|
||||||
import { formatTime, formatDate } from "@/lib/utils/date-format";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface UpcomingEventsWidgetProps {
|
|
||||||
events: Event[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpcomingEventsWidget({
|
|
||||||
events,
|
|
||||||
isLoading,
|
|
||||||
}: UpcomingEventsWidgetProps): React.JSX.Element {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading events...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const upcomingEvents = events.slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
|
|
||||||
<Link href="/calendar" className="text-sm text-blue-600 hover:text-blue-700">
|
|
||||||
View calendar →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{upcomingEvents.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{upcomingEvents.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
|
|
||||||
<div className="text-xs text-gray-500 uppercase font-semibold">
|
|
||||||
{formatDate(event.startTime).split(",")[0]}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{formatTime(event.startTime)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm truncate">{event.title}</h3>
|
|
||||||
{event.location && (
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* QuickCaptureWidget (Dashboard) Component Tests
|
|
||||||
* Tests environment-based behavior
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: (): { push: () => void } => ({
|
|
||||||
push: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("QuickCaptureWidget (Dashboard)", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Development mode", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "development");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the widget form in development", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Should show the header
|
|
||||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
|
||||||
// Should show the textarea
|
|
||||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
||||||
// Should show the Save Note button
|
|
||||||
expect(screen.getByRole("button", { name: /save note/i })).toBeInTheDocument();
|
|
||||||
// Should show the Create Task button
|
|
||||||
expect(screen.getByRole("button", { name: /create task/i })).toBeInTheDocument();
|
|
||||||
// Should NOT show Coming Soon badge
|
|
||||||
expect(screen.queryByText("Coming Soon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have a placeholder for the textarea", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
const textarea = screen.getByRole("textbox");
|
|
||||||
expect(textarea).toHaveAttribute("placeholder", "What's on your mind?");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Production mode", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "production");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show Coming Soon placeholder in production", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Should show Coming Soon badge
|
|
||||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
|
||||||
// Should show feature name
|
|
||||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
|
||||||
// Should NOT show the textarea
|
|
||||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
||||||
// Should NOT show the buttons
|
|
||||||
expect(screen.queryByRole("button", { name: /save note/i })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: /create task/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show description in Coming Soon placeholder", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/jot down ideas for later organization/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Test mode (non-development)", (): void => {
|
|
||||||
beforeEach((): void => {
|
|
||||||
vi.stubEnv("NODE_ENV", "test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show Coming Soon placeholder in test mode", (): void => {
|
|
||||||
render(<QuickCaptureWidget />);
|
|
||||||
|
|
||||||
// Test mode is not development, so should show Coming Soon
|
|
||||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -356,7 +356,7 @@ function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "11px",
|
gap: "11px",
|
||||||
padding: "9px 10px",
|
padding: "9px 10px",
|
||||||
borderRadius: "6px",
|
borderRadius: "var(--r-sm)",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: isActive ? "var(--text)" : "var(--muted)",
|
color: isActive ? "var(--text)" : "var(--muted)",
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
**ID:** mosaic-stack-go-live-mvp-20260222
|
**ID:** mosaic-stack-go-live-mvp-20260222
|
||||||
**Statement:** Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion, one visible agent cycle, deployed and smoke-tested. Unblocks SagePHR, DYOR, Calibr, and downstream projects.
|
**Statement:** Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion, one visible agent cycle, deployed and smoke-tested. Unblocks SagePHR, DYOR, Calibr, and downstream projects.
|
||||||
**Phase:** Intake
|
**Phase:** Execution
|
||||||
**Current Milestone:** —
|
**Current Milestone:** phase-1 (Dashboard Polish + Theming)
|
||||||
**Progress:** 0 / 4 milestones
|
**Progress:** 0 / 4 milestones
|
||||||
**Status:** active
|
**Status:** active
|
||||||
**Last Updated:** 2026-02-22 23:35 UTC
|
**Last Updated:** 2026-02-22 23:51 UTC
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
@@ -34,12 +34,12 @@ This mission continues from that foundation.
|
|||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
| --- | ------- | -------------------------- | ------- | ------ | ----- | ------- | --------- |
|
| --- | ------- | -------------------------- | ----------- | ------------------- | ----- | ---------- | --------- |
|
||||||
| 1 | phase-1 | Dashboard Polish + Theming | pending | — | — | — | — |
|
| 1 | phase-1 | Dashboard Polish + Theming | in-progress | feat/phase-1-polish | #457 | 2026-02-22 | — |
|
||||||
| 2 | phase-2 | Task Ingestion Pipeline | pending | — | — | — | — |
|
| 2 | phase-2 | Task Ingestion Pipeline | pending | — | — | — | — |
|
||||||
| 3 | phase-3 | Agent Cycle Visibility | pending | — | — | — | — |
|
| 3 | phase-3 | Agent Cycle Visibility | pending | — | — | — | — |
|
||||||
| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — |
|
| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -59,8 +59,9 @@ This mission continues from that foundation.
|
|||||||
|
|
||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||||
| ------- | ------- | ------- | -------- | ------------ | --------- |
|
| ------- | ------- | ---------------- | -------- | ------------ | --------- |
|
||||||
|
| S1 | Claude | 2026-02-22 17:50 | — | — | — |
|
||||||
|
|
||||||
## Scratchpad
|
## Scratchpad
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,9 @@
|
|||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| id | status | milestone | description | pr | notes |
|
||||||
| --- | ------ | --------- | ----------- | --- | ----- |
|
| --------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | --- | -------------------------------------------------------- |
|
||||||
|
| MS-P1-001 | done | phase-1 | Fix broken test suites: Button.test.tsx (4 fails, old Tailwind classes) + page.test.tsx (5 fails, old widget refs) | — | issue #457, commit 8fa0b30 |
|
||||||
|
| MS-P1-002 | done | phase-1 | Remove legacy unused dashboard widgets: DomainOverviewWidget, RecentTasksWidget, UpcomingEventsWidget, QuickCaptureWidget | — | issue #457, commit 8fa0b30, 5 files deleted |
|
||||||
|
| MS-P1-003 | done | phase-1 | Visual + theme polish: audit current vs design reference, fix gaps, verify dark/light across all components, responsive verification | — | issue #457, commit d97a98b, review: approve (0 blockers) |
|
||||||
|
| MS-P1-004 | done | phase-1 | Phase verification: all quality gates pass (lint 8/8, typecheck 7/7, test 8/8) | — | issue #457, all gates green (forced, no cache) |
|
||||||
|
|||||||
@@ -6,15 +6,36 @@
|
|||||||
## Original Mission Prompt
|
## Original Mission Prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
(Paste the mission prompt here on first session)
|
Continue Mosaic Stack Go-Live MVP from existing state.
|
||||||
|
- Mission: Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion,
|
||||||
|
one visible agent cycle, deployed and smoke-tested.
|
||||||
|
- 4 milestones: Dashboard Polish+Theming, Task Ingestion Pipeline,
|
||||||
|
Agent Cycle Visibility, Deploy+Smoke Test
|
||||||
|
- Prior work: MS15-DashboardShell complete (PRs #451-454)
|
||||||
|
- Design ref: mosaic-stack-website/docs/designs/round-5/claude/01/dashboard.html
|
||||||
```
|
```
|
||||||
|
|
||||||
## Planning Decisions
|
## Planning Decisions
|
||||||
|
|
||||||
|
### 2026-02-22: Phase-1 Task Breakdown
|
||||||
|
|
||||||
|
Baseline assessment:
|
||||||
|
|
||||||
|
- Lint: PASS, Typecheck: PASS, Tests: FAIL (9 failures)
|
||||||
|
- UI Button.test.tsx: 4 fails (old Tailwind class assertions vs new CSS token Button)
|
||||||
|
- Web page.test.tsx: 5 fails (old widget layout vs Phase 3 rebuild)
|
||||||
|
- Design system + theming already substantially complete from MS15
|
||||||
|
- 5 live widgets all use mock data (real data integration is phase-2)
|
||||||
|
- 4 legacy widgets unused (hardcoded light theme, pre-Phase 3)
|
||||||
|
|
||||||
|
Tasks created: MS-P1-001 through MS-P1-004, issue #457, milestone Go-Live-MVP-Phase1 (0.0.16)
|
||||||
|
Estimated total: ~50K tokens
|
||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
| ------- | ---- | --------- | ---------- | ------- |
|
| ------- | ---------- | --------- | ---------- | ------------------------------------------- |
|
||||||
|
| S1 | 2026-02-22 | phase-1 | 0/4 | In progress — bootstrap complete, executing |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -16,19 +16,21 @@ describe("Button", () => {
|
|||||||
it("should apply primary variant styles by default", () => {
|
it("should apply primary variant styles by default", () => {
|
||||||
render(<Button>Primary</Button>);
|
render(<Button>Primary</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("bg-blue-600");
|
expect(button.style.background).toBe("var(--ms-blue-500)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply secondary variant styles", () => {
|
it("should apply secondary variant styles", () => {
|
||||||
render(<Button variant="secondary">Secondary</Button>);
|
render(<Button variant="secondary">Secondary</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("bg-gray-200");
|
expect(button.style.background).toBe("transparent");
|
||||||
|
expect(button.style.border).toBe("1px solid var(--border)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply danger variant styles", () => {
|
it("should apply danger variant styles", () => {
|
||||||
render(<Button variant="danger">Delete</Button>);
|
render(<Button variant="danger">Delete</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("bg-red-600");
|
expect(button.style.background).toBe("rgba(229, 72, 77, 0.12)");
|
||||||
|
expect(button.style.color).toBe("var(--danger)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +83,6 @@ describe("Button", () => {
|
|||||||
render(<Button className="custom-class">Custom</Button>);
|
render(<Button className="custom-class">Custom</Button>);
|
||||||
const button = screen.getByRole("button");
|
const button = screen.getByRole("button");
|
||||||
expect(button.className).toContain("custom-class");
|
expect(button.className).toContain("custom-class");
|
||||||
expect(button.className).toContain("bg-blue-600");
|
expect(button.style.background).toBe("var(--ms-blue-500)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface MetricsStripProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean }): ReactElement {
|
function MetricCellItem({ cell }: { cell: MetricCell }): ReactElement {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
const trendColor =
|
const trendColor =
|
||||||
@@ -28,6 +28,7 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="metric-cell"
|
||||||
onMouseEnter={(): void => {
|
onMouseEnter={(): void => {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
}}
|
}}
|
||||||
@@ -37,7 +38,6 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
|||||||
style={{
|
style={{
|
||||||
padding: "14px 16px",
|
padding: "14px 16px",
|
||||||
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
||||||
borderLeft: isFirst ? "none" : "1px solid var(--border)",
|
|
||||||
borderTop: `2px solid ${cell.color}`,
|
borderTop: `2px solid ${cell.color}`,
|
||||||
transition: "background 0.15s ease",
|
transition: "background 0.15s ease",
|
||||||
}}
|
}}
|
||||||
@@ -82,17 +82,15 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
|||||||
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
|
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={`metrics-strip ${className}`.trim()}
|
||||||
style={{
|
style={
|
||||||
display: "grid",
|
{
|
||||||
gridTemplateColumns: `repeat(${String(cells.length)}, 1fr)`,
|
"--ms-cols": String(cells.length),
|
||||||
borderRadius: "var(--r-lg)",
|
} as React.CSSProperties
|
||||||
overflow: "hidden",
|
}
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{cells.map((cell, index) => (
|
{cells.map((cell) => (
|
||||||
<MetricCellItem key={cell.label} cell={cell} isFirst={index === 0} />
|
<MetricCellItem key={cell.label} cell={cell} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user