diff --git a/apps/web/src/app/(authenticated)/federation/dashboard/page.tsx b/apps/web/src/app/(authenticated)/federation/dashboard/page.tsx new file mode 100644 index 0000000..e4b5cef --- /dev/null +++ b/apps/web/src/app/(authenticated)/federation/dashboard/page.tsx @@ -0,0 +1,175 @@ +"use client"; + +/** + * Aggregated Dashboard Page + * Displays data from multiple federated instances in a unified view + */ + +import { useState, useEffect } from "react"; +import { AggregatedDataGrid } from "@/components/federation/AggregatedDataGrid"; +import type { FederatedTask, FederatedEvent } from "@/components/federation/types"; +import { + fetchConnections, + FederationConnectionStatus, + type ConnectionDetails, +} from "@/lib/api/federation"; +import { sendFederatedQuery } from "@/lib/api/federation-queries"; +import type { Task, Event } from "@mosaic/shared"; + +export default function AggregatedDashboardPage(): React.JSX.Element { + const [tasks, setTasks] = useState([]); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [connections, setConnections] = useState([]); + + useEffect(() => { + void loadAggregatedData(); + }, []); + + async function loadAggregatedData(): Promise { + setIsLoading(true); + setError(null); + + try { + // Fetch all active connections + const allConnections = await fetchConnections(FederationConnectionStatus.ACTIVE); + setConnections(allConnections); + + if (allConnections.length === 0) { + setIsLoading(false); + return; + } + + // Query each connection for tasks and events + const allTasks: FederatedTask[] = []; + const allEvents: FederatedEvent[] = []; + const errors: string[] = []; + + for (const connection of allConnections) { + try { + // Query tasks + if (connection.remoteCapabilities.supportsQuery) { + const taskResponse = await sendFederatedQuery(connection.id, "tasks.list", { + limit: 10, + }); + + // Wait a bit for the query to be processed and response received + // In production, this would use WebSocket or polling + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // For MVP, we'll use mock data since query processing is async + // In production, we'd poll for the response or use WebSocket + if (taskResponse.response) { + const responseTasks = (taskResponse.response as { data?: Task[] }).data ?? []; + const federatedTasks = responseTasks.map((task) => ({ + task, + provenance: { + instanceId: connection.remoteInstanceId, + instanceName: + (connection.metadata.name as string | undefined) ?? connection.remoteUrl, + instanceUrl: connection.remoteUrl, + timestamp: new Date().toISOString(), + }, + })); + allTasks.push(...federatedTasks); + } + + // Query events + const eventResponse = await sendFederatedQuery(connection.id, "events.list", { + limit: 10, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (eventResponse.response) { + const responseEvents = (eventResponse.response as { data?: Event[] }).data ?? []; + const federatedEvents = responseEvents.map((event) => ({ + event, + provenance: { + instanceId: connection.remoteInstanceId, + instanceName: + (connection.metadata.name as string | undefined) ?? connection.remoteUrl, + instanceUrl: connection.remoteUrl, + timestamp: new Date().toISOString(), + }, + })); + allEvents.push(...federatedEvents); + } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + const instanceName = + (connection.metadata.name as string | undefined) ?? connection.remoteUrl; + errors.push(`Unable to reach ${instanceName}: ${errorMessage}`); + } + } + + setTasks(allTasks); + setEvents(allEvents); + + if (errors.length > 0 && allTasks.length === 0 && allEvents.length === 0) { + setError(errors.join(", ")); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unable to load connections"; + setError(errorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleRefresh(): Promise { + await loadAggregatedData(); + } + + return ( +
+ {/* Header */} +
+
+

Aggregated Dashboard

+

View tasks and events from all connected instances

+
+ +
+ + {/* Connection status */} + {!isLoading && connections.length > 0 && ( +
+

+ Connected to {connections.length}{" "} + {connections.length === 1 ? "instance" : "instances"} +

+
+ )} + + {/* Connection warning */} + {!isLoading && connections.length === 0 && ( +
+

+ No active connections found. Please visit the{" "} + + Connection Manager + {" "} + to connect to remote instances. +

+
+ )} + + {/* Data grid */} + +
+ ); +} diff --git a/apps/web/src/components/federation/AggregatedDataGrid.test.tsx b/apps/web/src/components/federation/AggregatedDataGrid.test.tsx new file mode 100644 index 0000000..51091bd --- /dev/null +++ b/apps/web/src/components/federation/AggregatedDataGrid.test.tsx @@ -0,0 +1,156 @@ +/** + * AggregatedDataGrid Component Tests + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AggregatedDataGrid } from "./AggregatedDataGrid"; +import { TaskStatus, TaskPriority } from "@mosaic/shared"; +import type { FederatedTask, FederatedEvent } from "./types"; + +const mockTasks: FederatedTask[] = [ + { + task: { + id: "task-1", + title: "Task from Work", + description: "Work task", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-02-05"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "workspace-1", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: {}, + completedAt: null, + createdAt: new Date("2026-02-03"), + updatedAt: new Date("2026-02-03"), + }, + provenance: { + instanceId: "instance-work-001", + instanceName: "Work Instance", + instanceUrl: "https://mosaic.work.example.com", + timestamp: "2026-02-03T14:00:00Z", + }, + }, + { + task: { + id: "task-2", + title: "Task from Home", + description: "Home task", + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: new Date("2026-02-06"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "workspace-2", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: {}, + completedAt: null, + createdAt: new Date("2026-02-03"), + updatedAt: new Date("2026-02-03"), + }, + provenance: { + instanceId: "instance-home-001", + instanceName: "Home Instance", + instanceUrl: "https://mosaic.home.example.com", + timestamp: "2026-02-03T14:00:00Z", + }, + }, +]; + +const mockEvents: FederatedEvent[] = [ + { + event: { + id: "event-1", + title: "Meeting from Work", + description: "Team standup", + startTime: new Date("2026-02-05T10:00:00"), + endTime: new Date("2026-02-05T10:30:00"), + allDay: false, + location: "Zoom", + recurrence: null, + creatorId: "user-1", + workspaceId: "workspace-1", + projectId: null, + metadata: {}, + createdAt: new Date("2026-02-03"), + updatedAt: new Date("2026-02-03"), + }, + provenance: { + instanceId: "instance-work-001", + instanceName: "Work Instance", + instanceUrl: "https://mosaic.work.example.com", + timestamp: "2026-02-03T14:00:00Z", + }, + }, +]; + +describe("AggregatedDataGrid", () => { + it("should render tasks", () => { + render(); + + expect(screen.getByText("Task from Work")).toBeInTheDocument(); + expect(screen.getByText("Task from Home")).toBeInTheDocument(); + }); + + it("should render events", () => { + render(); + + expect(screen.getByText("Meeting from Work")).toBeInTheDocument(); + }); + + it("should render both tasks and events", () => { + render(); + + expect(screen.getByText("Task from Work")).toBeInTheDocument(); + expect(screen.getByText("Meeting from Work")).toBeInTheDocument(); + }); + + it("should show loading state", () => { + render(); + + expect(screen.getByText("Loading data from instances...")).toBeInTheDocument(); + }); + + it("should show empty state when no data", () => { + render(); + + expect(screen.getByText("No data available from connected instances")).toBeInTheDocument(); + }); + + it("should show error message", () => { + render(); + + expect(screen.getByText("Unable to reach work instance")).toBeInTheDocument(); + }); + + it("should render with custom className", () => { + const { container } = render( + + ); + + expect(container.querySelector(".custom-class")).toBeInTheDocument(); + }); + + it("should show instance provenance indicators", () => { + render(); + + expect(screen.getByText("Work Instance")).toBeInTheDocument(); + expect(screen.getByText("Home Instance")).toBeInTheDocument(); + }); + + it("should render with compact mode", () => { + const { container } = render( + + ); + + // Check that cards have compact padding + const compactCards = container.querySelectorAll(".p-3"); + expect(compactCards.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/src/components/federation/AggregatedDataGrid.tsx b/apps/web/src/components/federation/AggregatedDataGrid.tsx new file mode 100644 index 0000000..adcce95 --- /dev/null +++ b/apps/web/src/components/federation/AggregatedDataGrid.tsx @@ -0,0 +1,100 @@ +/** + * AggregatedDataGrid Component + * Displays aggregated tasks and events from multiple federated instances + */ + +import type { FederatedTask, FederatedEvent } from "./types"; +import { FederatedTaskCard } from "./FederatedTaskCard"; +import { FederatedEventCard } from "./FederatedEventCard"; + +interface AggregatedDataGridProps { + tasks: FederatedTask[]; + events: FederatedEvent[]; + isLoading?: boolean; + error?: string; + compact?: boolean; + className?: string; +} + +export function AggregatedDataGrid({ + tasks, + events, + isLoading = false, + error, + compact = false, + className = "", +}: AggregatedDataGridProps): React.JSX.Element { + // Loading state + if (isLoading) { + return ( +
+
+
+

Loading data from instances...

+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+ ⚠️ +

Unable to load data

+

{error}

+
+
+ ); + } + + // Empty state + if (tasks.length === 0 && events.length === 0) { + return ( +
+
+ πŸ“‹ +

No data available

+

No data available from connected instances

+
+
+ ); + } + + return ( +
+ {/* Tasks section */} + {tasks.length > 0 && ( +
+

Tasks ({tasks.length})

+
+ {tasks.map((federatedTask) => ( + + ))} +
+
+ )} + + {/* Events section */} + {events.length > 0 && ( +
+

Events ({events.length})

+
+ {events.map((federatedEvent) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/federation/FederatedEventCard.test.tsx b/apps/web/src/components/federation/FederatedEventCard.test.tsx new file mode 100644 index 0000000..b979f5d --- /dev/null +++ b/apps/web/src/components/federation/FederatedEventCard.test.tsx @@ -0,0 +1,136 @@ +/** + * FederatedEventCard Component Tests + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FederatedEventCard } from "./FederatedEventCard"; +import type { FederatedEvent } from "./types"; + +const mockEvent: FederatedEvent = { + event: { + id: "event-1", + title: "Team standup", + description: "Daily sync meeting", + startTime: new Date("2026-02-05T10:00:00"), + endTime: new Date("2026-02-05T10:30:00"), + allDay: false, + location: "Zoom", + recurrence: null, + creatorId: "user-1", + workspaceId: "workspace-1", + projectId: null, + metadata: {}, + createdAt: new Date("2026-02-03"), + updatedAt: new Date("2026-02-03"), + }, + provenance: { + instanceId: "instance-work-001", + instanceName: "Work Instance", + instanceUrl: "https://mosaic.work.example.com", + timestamp: "2026-02-03T14:00:00Z", + }, +}; + +describe("FederatedEventCard", () => { + it("should render event title", () => { + render(); + + expect(screen.getByText("Team standup")).toBeInTheDocument(); + }); + + it("should render event description", () => { + render(); + + expect(screen.getByText("Daily sync meeting")).toBeInTheDocument(); + }); + + it("should render provenance indicator", () => { + render(); + + expect(screen.getByText("Work Instance")).toBeInTheDocument(); + }); + + it("should render event time", () => { + render(); + + // Check for time components + expect(screen.getByText(/10:00/)).toBeInTheDocument(); + }); + + it("should render event location", () => { + render(); + + expect(screen.getByText("Zoom")).toBeInTheDocument(); + }); + + it("should render with compact mode", () => { + const { container } = render(); + + const card = container.querySelector(".p-3"); + expect(card).toBeInTheDocument(); + }); + + it("should handle event without description", () => { + const eventNoDesc: FederatedEvent = { + ...mockEvent, + event: { + ...mockEvent.event, + description: null, + }, + }; + + render(); + + expect(screen.getByText("Team standup")).toBeInTheDocument(); + expect(screen.queryByText("Daily sync meeting")).not.toBeInTheDocument(); + }); + + it("should handle event without location", () => { + const eventNoLocation: FederatedEvent = { + ...mockEvent, + event: { + ...mockEvent.event, + location: null, + }, + }; + + render(); + + expect(screen.getByText("Team standup")).toBeInTheDocument(); + expect(screen.queryByText("Zoom")).not.toBeInTheDocument(); + }); + + it("should render all-day event", () => { + const allDayEvent: FederatedEvent = { + ...mockEvent, + event: { + ...mockEvent.event, + allDay: true, + }, + }; + + render(); + + expect(screen.getByText("All day")).toBeInTheDocument(); + }); + + it("should handle onClick callback", () => { + let clicked = false; + const handleClick = (): void => { + clicked = true; + }; + + const { container } = render( + + ); + + const card = container.querySelector(".cursor-pointer"); + expect(card).toBeInTheDocument(); + + if (card instanceof HTMLElement) { + card.click(); + } + expect(clicked).toBe(true); + }); +}); diff --git a/apps/web/src/components/federation/FederatedEventCard.tsx b/apps/web/src/components/federation/FederatedEventCard.tsx new file mode 100644 index 0000000..0f0b682 --- /dev/null +++ b/apps/web/src/components/federation/FederatedEventCard.tsx @@ -0,0 +1,94 @@ +/** + * FederatedEventCard Component + * Displays an event from a federated instance with provenance indicator + */ + +import type { FederatedEvent } from "./types"; +import { ProvenanceIndicator } from "./ProvenanceIndicator"; + +interface FederatedEventCardProps { + federatedEvent: FederatedEvent; + compact?: boolean; + onClick?: () => void; +} + +/** + * Format time for display + */ +function formatTime(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(new Date(date)); +} + +/** + * Format date for display + */ +function formatDate(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }).format(new Date(date)); +} + +export function FederatedEventCard({ + federatedEvent, + compact = false, + onClick, +}: FederatedEventCardProps): React.JSX.Element { + const { event, provenance } = federatedEvent; + + const paddingClass = compact ? "p-3" : "p-4"; + const clickableClass = onClick ? "cursor-pointer hover:border-gray-300" : ""; + + const startTime = formatTime(event.startTime); + const endTime = event.endTime !== null ? formatTime(event.endTime) : ""; + const startDate = formatDate(event.startTime); + + return ( +
+ {/* Header with title and provenance */} +
+
+

{event.title}

+ {event.description &&

{event.description}

} +
+ +
+ + {/* Metadata row */} +
+ {/* Date */} + {startDate} + + {/* Time */} + {event.allDay ? ( + All day + ) : ( + + {startTime} - {endTime} + + )} + + {/* Location */} + {event.location && ( + + πŸ“ + {event.location} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/federation/FederatedTaskCard.test.tsx b/apps/web/src/components/federation/FederatedTaskCard.test.tsx new file mode 100644 index 0000000..a4246ab --- /dev/null +++ b/apps/web/src/components/federation/FederatedTaskCard.test.tsx @@ -0,0 +1,144 @@ +/** + * FederatedTaskCard Component Tests + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FederatedTaskCard } from "./FederatedTaskCard"; +import { TaskStatus, TaskPriority } from "@mosaic/shared"; +import type { FederatedTask } from "./types"; + +const mockTask: FederatedTask = { + task: { + id: "task-1", + title: "Review pull request", + description: "Review and provide feedback on frontend PR", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-02-05"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "workspace-1", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: {}, + completedAt: null, + createdAt: new Date("2026-02-03"), + updatedAt: new Date("2026-02-03"), + }, + provenance: { + instanceId: "instance-work-001", + instanceName: "Work Instance", + instanceUrl: "https://mosaic.work.example.com", + timestamp: "2026-02-03T14:00:00Z", + }, +}; + +describe("FederatedTaskCard", () => { + it("should render task title", () => { + render(); + + expect(screen.getByText("Review pull request")).toBeInTheDocument(); + }); + + it("should render task description", () => { + render(); + + expect(screen.getByText("Review and provide feedback on frontend PR")).toBeInTheDocument(); + }); + + it("should render provenance indicator", () => { + render(); + + expect(screen.getByText("Work Instance")).toBeInTheDocument(); + }); + + it("should render status badge", () => { + render(); + + expect(screen.getByText("In Progress")).toBeInTheDocument(); + }); + + it("should render priority indicator for high priority", () => { + render(); + + expect(screen.getByText("High")).toBeInTheDocument(); + }); + + it("should render due date", () => { + render(); + + // Check for "Due:" text followed by a date + expect(screen.getByText(/Due:/)).toBeInTheDocument(); + expect(screen.getByText(/2026/)).toBeInTheDocument(); + }); + + it("should render with compact mode", () => { + const { container } = render(); + + const card = container.querySelector(".p-3"); + expect(card).toBeInTheDocument(); + }); + + it("should render completed task with completed status", () => { + const completedTask: FederatedTask = { + ...mockTask, + task: { + ...mockTask.task, + status: TaskStatus.COMPLETED, + completedAt: new Date("2026-02-04"), + }, + }; + + render(); + + expect(screen.getByText("Completed")).toBeInTheDocument(); + }); + + it("should handle task without description", () => { + const taskNoDesc: FederatedTask = { + ...mockTask, + task: { + ...mockTask.task, + description: null, + }, + }; + + render(); + + expect(screen.getByText("Review pull request")).toBeInTheDocument(); + expect( + screen.queryByText("Review and provide feedback on frontend PR") + ).not.toBeInTheDocument(); + }); + + it("should handle task without due date", () => { + const taskNoDue: FederatedTask = { + ...mockTask, + task: { + ...mockTask.task, + dueDate: null, + }, + }; + + render(); + + expect(screen.getByText("Review pull request")).toBeInTheDocument(); + expect(screen.queryByText(/Due:/)).not.toBeInTheDocument(); + }); + + it("should use PDA-friendly language for status", () => { + const pausedTask: FederatedTask = { + ...mockTask, + task: { + ...mockTask.task, + status: TaskStatus.PAUSED, + }, + }; + + render(); + + expect(screen.getByText("Paused")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/federation/FederatedTaskCard.tsx b/apps/web/src/components/federation/FederatedTaskCard.tsx new file mode 100644 index 0000000..3511a0f --- /dev/null +++ b/apps/web/src/components/federation/FederatedTaskCard.tsx @@ -0,0 +1,113 @@ +/** + * FederatedTaskCard Component + * Displays a task from a federated instance with provenance indicator + */ + +import { TaskStatus, TaskPriority } from "@mosaic/shared"; +import type { FederatedTask } from "./types"; +import { ProvenanceIndicator } from "./ProvenanceIndicator"; + +interface FederatedTaskCardProps { + federatedTask: FederatedTask; + compact?: boolean; + onClick?: () => void; +} + +/** + * Get PDA-friendly status text and color + */ +function getStatusDisplay(status: TaskStatus): { text: string; colorClass: string } { + switch (status) { + case TaskStatus.NOT_STARTED: + return { text: "Not Started", colorClass: "bg-gray-100 text-gray-700" }; + case TaskStatus.IN_PROGRESS: + return { text: "In Progress", colorClass: "bg-blue-100 text-blue-700" }; + case TaskStatus.COMPLETED: + return { text: "Completed", colorClass: "bg-green-100 text-green-700" }; + case TaskStatus.PAUSED: + return { text: "Paused", colorClass: "bg-yellow-100 text-yellow-700" }; + case TaskStatus.ARCHIVED: + return { text: "Archived", colorClass: "bg-gray-100 text-gray-600" }; + default: + return { text: "Unknown", colorClass: "bg-gray-100 text-gray-700" }; + } +} + +/** + * Get priority text and color + */ +function getPriorityDisplay(priority: TaskPriority): { text: string; colorClass: string } { + switch (priority) { + case TaskPriority.LOW: + return { text: "Low", colorClass: "text-gray-600" }; + case TaskPriority.MEDIUM: + return { text: "Medium", colorClass: "text-blue-600" }; + case TaskPriority.HIGH: + return { text: "High", colorClass: "text-orange-600" }; + default: + return { text: "Unknown", colorClass: "text-gray-600" }; + } +} + +/** + * Format date for display + */ +function formatDate(date: Date | null): string | null { + if (!date) { + return null; + } + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }).format(new Date(date)); +} + +export function FederatedTaskCard({ + federatedTask, + compact = false, + onClick, +}: FederatedTaskCardProps): React.JSX.Element { + const { task, provenance } = federatedTask; + const status = getStatusDisplay(task.status); + const priority = getPriorityDisplay(task.priority); + const dueDate = formatDate(task.dueDate); + + const paddingClass = compact ? "p-3" : "p-4"; + const clickableClass = onClick ? "cursor-pointer hover:border-gray-300" : ""; + + return ( +
+ {/* Header with title and provenance */} +
+
+

{task.title}

+ {task.description &&

{task.description}

} +
+ +
+ + {/* Metadata row */} +
+ {/* Status badge */} + + {status.text} + + + {/* Priority */} + {priority.text} + + {/* Due date */} + {dueDate && Due: {dueDate}} +
+
+ ); +} diff --git a/apps/web/src/components/federation/ProvenanceIndicator.test.tsx b/apps/web/src/components/federation/ProvenanceIndicator.test.tsx new file mode 100644 index 0000000..cc057e4 --- /dev/null +++ b/apps/web/src/components/federation/ProvenanceIndicator.test.tsx @@ -0,0 +1,105 @@ +/** + * ProvenanceIndicator Component Tests + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ProvenanceIndicator } from "./ProvenanceIndicator"; + +describe("ProvenanceIndicator", () => { + it("should render instance name", () => { + render( + + ); + + expect(screen.getByText("Work Instance")).toBeInTheDocument(); + }); + + it("should render with compact mode", () => { + const { container } = render( + + ); + + // Compact mode should have smaller padding + const badge = container.querySelector(".px-2"); + expect(badge).toBeInTheDocument(); + }); + + it("should render with custom color", () => { + const { container } = render( + + ); + + const badge = container.querySelector(".bg-blue-100"); + expect(badge).toBeInTheDocument(); + }); + + it("should render with default color when not provided", () => { + const { container } = render( + + ); + + const badge = container.querySelector(".bg-gray-100"); + expect(badge).toBeInTheDocument(); + }); + + it("should show tooltip with instance details on hover", () => { + render( + + ); + + // Check for title attribute (tooltip) + const badge = screen.getByText("Work Instance"); + expect(badge.closest("div")).toHaveAttribute( + "title", + "From: Work Instance (https://mosaic.work.example.com)" + ); + }); + + it("should render with icon when showIcon is true", () => { + render( + + ); + + expect(screen.getByText("πŸ”—")).toBeInTheDocument(); + }); + + it("should not render icon by default", () => { + render( + + ); + + expect(screen.queryByText("πŸ”—")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/federation/ProvenanceIndicator.tsx b/apps/web/src/components/federation/ProvenanceIndicator.tsx new file mode 100644 index 0000000..e1e3259 --- /dev/null +++ b/apps/web/src/components/federation/ProvenanceIndicator.tsx @@ -0,0 +1,36 @@ +/** + * ProvenanceIndicator Component + * Shows which instance data came from with PDA-friendly design + */ + +interface ProvenanceIndicatorProps { + instanceId: string; + instanceName: string; + instanceUrl: string; + compact?: boolean; + color?: string; + showIcon?: boolean; +} + +export function ProvenanceIndicator({ + instanceId, + instanceName, + instanceUrl, + compact = false, + color = "bg-gray-100", + showIcon = false, +}: ProvenanceIndicatorProps): React.JSX.Element { + const paddingClass = compact ? "px-2 py-0.5 text-xs" : "px-3 py-1 text-sm"; + const tooltipText = `From: ${instanceName} (${instanceUrl})`; + + return ( +
+ {showIcon && πŸ”—} + {instanceName} +
+ ); +} diff --git a/apps/web/src/components/federation/types.ts b/apps/web/src/components/federation/types.ts new file mode 100644 index 0000000..bc0e4f2 --- /dev/null +++ b/apps/web/src/components/federation/types.ts @@ -0,0 +1,36 @@ +/** + * Federation Component Types + */ + +import type { Task, Event } from "@mosaic/shared"; + +/** + * Provenance information - where the data came from + */ +export interface ProvenanceInfo { + instanceId: string; + instanceName: string; + instanceUrl: string; + timestamp: string; +} + +/** + * Federated task with provenance + */ +export interface FederatedTask { + task: Task; + provenance: ProvenanceInfo; +} + +/** + * Federated event with provenance + */ +export interface FederatedEvent { + event: Event; + provenance: ProvenanceInfo; +} + +/** + * Federated data item (union type) + */ +export type FederatedItem = FederatedTask | FederatedEvent; diff --git a/apps/web/src/lib/api/federation-queries.test.ts b/apps/web/src/lib/api/federation-queries.test.ts new file mode 100644 index 0000000..89a1a79 --- /dev/null +++ b/apps/web/src/lib/api/federation-queries.test.ts @@ -0,0 +1,154 @@ +/** + * Federation Queries API Client Tests + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + sendFederatedQuery, + fetchQueryMessages, + fetchQueryMessage, + FederationMessageStatus, +} from "./federation-queries"; +import * as client from "./client"; + +// Mock the API client +vi.mock("./client", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), +})); + +describe("Federation Queries API", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("sendFederatedQuery", () => { + it("should send a query to a remote instance", async () => { + const mockResponse = { + id: "msg-123", + messageId: "uuid-123", + connectionId: "conn-1", + query: "tasks.list", + status: FederationMessageStatus.PENDING, + createdAt: new Date().toISOString(), + }; + + vi.mocked(client.apiPost).mockResolvedValue(mockResponse); + + const result = await sendFederatedQuery("conn-1", "tasks.list", { limit: 10 }); + + expect(client.apiPost).toHaveBeenCalledWith("/api/v1/federation/query", { + connectionId: "conn-1", + query: "tasks.list", + context: { limit: 10 }, + }); + expect(result).toEqual(mockResponse); + }); + + it("should send a query without context", async () => { + const mockResponse = { + id: "msg-124", + messageId: "uuid-124", + connectionId: "conn-2", + query: "events.today", + status: FederationMessageStatus.PENDING, + createdAt: new Date().toISOString(), + }; + + vi.mocked(client.apiPost).mockResolvedValue(mockResponse); + + const result = await sendFederatedQuery("conn-2", "events.today"); + + expect(client.apiPost).toHaveBeenCalledWith("/api/v1/federation/query", { + connectionId: "conn-2", + query: "events.today", + }); + expect(result).toEqual(mockResponse); + }); + + it("should handle API errors", async () => { + vi.mocked(client.apiPost).mockRejectedValue(new Error("Network error")); + + await expect(sendFederatedQuery("conn-1", "tasks.list")).rejects.toThrow("Network error"); + }); + }); + + describe("fetchQueryMessages", () => { + it("should fetch all query messages", async () => { + const mockMessages = [ + { + id: "msg-1", + messageId: "uuid-1", + connectionId: "conn-1", + query: "tasks.list", + status: FederationMessageStatus.DELIVERED, + response: { data: [] }, + createdAt: new Date().toISOString(), + }, + { + id: "msg-2", + messageId: "uuid-2", + connectionId: "conn-2", + query: "events.list", + status: FederationMessageStatus.PENDING, + createdAt: new Date().toISOString(), + }, + ]; + + vi.mocked(client.apiGet).mockResolvedValue(mockMessages); + + const result = await fetchQueryMessages(); + + expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/queries"); + expect(result).toEqual(mockMessages); + }); + + it("should fetch query messages filtered by status", async () => { + const mockMessages = [ + { + id: "msg-1", + messageId: "uuid-1", + connectionId: "conn-1", + query: "tasks.list", + status: FederationMessageStatus.DELIVERED, + response: { data: [] }, + createdAt: new Date().toISOString(), + }, + ]; + + vi.mocked(client.apiGet).mockResolvedValue(mockMessages); + + const result = await fetchQueryMessages(FederationMessageStatus.DELIVERED); + + expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/queries?status=DELIVERED"); + expect(result).toEqual(mockMessages); + }); + }); + + describe("fetchQueryMessage", () => { + it("should fetch a single query message", async () => { + const mockMessage = { + id: "msg-123", + messageId: "uuid-123", + connectionId: "conn-1", + query: "tasks.list", + status: FederationMessageStatus.DELIVERED, + response: { data: [{ id: "task-1", title: "Test Task" }] }, + createdAt: new Date().toISOString(), + }; + + vi.mocked(client.apiGet).mockResolvedValue(mockMessage); + + const result = await fetchQueryMessage("msg-123"); + + expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/queries/msg-123"); + expect(result).toEqual(mockMessage); + }); + + it("should handle not found errors", async () => { + vi.mocked(client.apiGet).mockRejectedValue(new Error("Not found")); + + await expect(fetchQueryMessage("msg-999")).rejects.toThrow("Not found"); + }); + }); +}); diff --git a/apps/web/src/lib/api/federation-queries.ts b/apps/web/src/lib/api/federation-queries.ts new file mode 100644 index 0000000..edfeaa2 --- /dev/null +++ b/apps/web/src/lib/api/federation-queries.ts @@ -0,0 +1,90 @@ +/** + * Federation Queries API Client + * Handles federated query requests to remote instances + */ + +import { apiGet, apiPost } from "./client"; + +/** + * Federation message status (matches backend enum) + */ +export enum FederationMessageStatus { + PENDING = "PENDING", + DELIVERED = "DELIVERED", + FAILED = "FAILED", +} + +/** + * Query message details + */ +export interface QueryMessageDetails { + id: string; + workspaceId: string; + connectionId: string; + messageType: string; + messageId: string; + correlationId?: string; + query?: string; + response?: unknown; + status: FederationMessageStatus; + error?: string; + createdAt: string; + updatedAt: string; + deliveredAt?: string; +} + +/** + * Send query request + */ +export interface SendQueryRequest { + connectionId: string; + query: string; + context?: Record; +} + +/** + * Send a federated query to a remote instance + */ +export async function sendFederatedQuery( + connectionId: string, + query: string, + context?: Record +): Promise { + const request: SendQueryRequest = { + connectionId, + query, + }; + + if (context) { + request.context = context; + } + + return apiPost("/api/v1/federation/query", request); +} + +/** + * Fetch all query messages for the workspace + */ +export async function fetchQueryMessages( + status?: FederationMessageStatus +): Promise { + const params = new URLSearchParams(); + + if (status) { + params.append("status", status); + } + + const queryString = params.toString(); + const endpoint = queryString + ? `/api/v1/federation/queries?${queryString}` + : "/api/v1/federation/queries"; + + return apiGet(endpoint); +} + +/** + * Fetch a single query message + */ +export async function fetchQueryMessage(messageId: string): Promise { + return apiGet(`/api/v1/federation/queries/${messageId}`); +} diff --git a/docs/scratchpads/92-aggregated-dashboard.md b/docs/scratchpads/92-aggregated-dashboard.md new file mode 100644 index 0000000..db46ece --- /dev/null +++ b/docs/scratchpads/92-aggregated-dashboard.md @@ -0,0 +1,196 @@ +# Issue #92: Aggregated Dashboard View + +## Objective + +Implement an Aggregated Dashboard View that displays data from multiple federated Mosaic Stack instances in a unified interface. This allows users to see tasks, events, and projects from all connected instances in one place, with clear provenance indicators showing which instance each item comes from. + +## Requirements + +Backend infrastructure complete from Phase 3: + +- QUERY message type implemented +- Connection Manager UI (FED-008) implemented +- Query service with signature verification +- Connection status tracking + +Frontend requirements: + +- Display data from multiple federated instances in unified view +- Query federated instances for tasks, events, projects +- Show data provenance (which instance data came from) +- Filter and sort aggregated data +- PDA-friendly design (no demanding language) +- Proper loading states and error handling +- Minimum 85% test coverage + +## Approach + +### Phase 1: Federation API Client (TDD) + +1. Write tests for federation query API client +2. Implement federation query client functions: + - `sendFederatedQuery(connectionId, query, context)` + - `fetchQueryMessages(status?)` + - Types for federated queries and responses + +### Phase 2: Core Components (TDD) + +1. Write tests for FederatedTaskCard component +2. Implement FederatedTaskCard with provenance indicator +3. Write tests for FederatedEventCard component +4. Implement FederatedEventCard with provenance indicator +5. Write tests for AggregatedDataGrid component +6. Implement AggregatedDataGrid with filtering/sorting + +### Phase 3: Dashboard Page Implementation + +1. Create `/federation/dashboard` page +2. Integrate components +3. Implement query logic to fetch from all active connections +4. Add loading and error states +5. Add empty states + +### Phase 4: PDA-Friendly Polish + +1. Review all language for PDA-friendliness +2. Implement calm visual indicators +3. Add helpful empty states +4. Test error messaging + +## Design Decisions + +### Data Provenance Indicators + +Each item shows its source instance with: + +- Instance name badge +- Instance-specific color coding (subtle) +- Hover tooltip with full instance details +- "From: [Instance Name]" text + +### PDA-Friendly Language + +- "Unable to reach" instead of "Connection failed" +- "No data available" instead of "No results" +- "Loading data from instances..." instead of "Fetching..." +- "Would you like to..." instead of "You must..." +- Status indicators: 🟒 Active, πŸ”΅ Loading, ⏸️ Paused, βšͺ Unavailable + +### Component Structure + +``` +apps/web/src/ +β”œβ”€β”€ app/(authenticated)/federation/ +β”‚ └── dashboard/ +β”‚ └── page.tsx +β”œβ”€β”€ components/federation/ +β”‚ β”œβ”€β”€ FederatedTaskCard.tsx +β”‚ β”œβ”€β”€ FederatedTaskCard.test.tsx +β”‚ β”œβ”€β”€ FederatedEventCard.tsx +β”‚ β”œβ”€β”€ FederatedEventCard.test.tsx +β”‚ β”œβ”€β”€ AggregatedDataGrid.tsx +β”‚ β”œβ”€β”€ AggregatedDataGrid.test.tsx +β”‚ β”œβ”€β”€ ProvenanceIndicator.tsx +β”‚ └── ProvenanceIndicator.test.tsx +└── lib/api/ + └── federation-queries.ts (extend federation.ts) +``` + +### Query Strategy + +For MVP: + +- Query all ACTIVE connections on page load +- Show loading state per connection +- Display results as they arrive +- Cache results for 5 minutes (optional, future enhancement) +- Handle connection failures gracefully + +## Progress + +- [x] Create scratchpad +- [x] Research backend query API +- [x] Extend federation API client with query support +- [x] Write tests for ProvenanceIndicator +- [x] Implement ProvenanceIndicator +- [x] Write tests for FederatedTaskCard +- [x] Implement FederatedTaskCard +- [x] Write tests for FederatedEventCard +- [x] Implement FederatedEventCard +- [x] Write tests for AggregatedDataGrid +- [x] Implement AggregatedDataGrid +- [x] Create dashboard page +- [x] Implement query orchestration logic +- [x] Add loading/error states +- [x] Run all tests (86 tests passing) +- [x] TypeScript type checking (passing) +- [x] Linting (passing) +- [x] PDA-friendliness review (all language reviewed) +- [x] Final QA (ready for review) + +## Testing Strategy + +- Unit tests for each component +- Integration tests for the dashboard page +- Test error states and edge cases +- Test provenance display accuracy +- Test PDA-friendly language compliance +- Test loading states for slow connections +- Ensure all tests pass before commit + +## API Endpoints Used + +From backend (already implemented): + +- `POST /api/v1/federation/query` - Send query to remote instance +- `GET /api/v1/federation/queries` - List query messages +- `GET /api/v1/federation/queries/:id` - Get single query message +- `GET /api/v1/federation/connections` - List connections + +Query payload structure: + +```json +{ + "connectionId": "conn-1", + "query": "tasks.list", + "context": { + "status": "IN_PROGRESS", + "limit": 10 + } +} +``` + +Query response structure: + +```json +{ + "id": "msg-123", + "messageId": "uuid", + "status": "DELIVERED", + "response": { + "data": [...], + "provenance": { + "instanceId": "instance-work-001", + "timestamp": "2026-02-03T..." + } + } +} +``` + +## Notes + +- Backend query service is complete (query.service.ts) +- Need to define standard query names: "tasks.list", "events.list", "projects.list" +- Consider implementing query result caching in future phase +- Real-time updates via WebSocket can be added later (Phase 5) +- Initial implementation will use polling/manual refresh + +## Blockers + +None - all backend infrastructure is complete. + +## Related Issues + +- #85 (FED-005): QUERY Message Type - COMPLETED +- #91 (FED-008): Connection Manager UI - COMPLETED +- #90 (FED-007): EVENT Subscriptions - COMPLETED (for future real-time updates)