diff --git a/apps/web/src/components/widgets/CalendarWidget.tsx b/apps/web/src/components/widgets/CalendarWidget.tsx index 8273fc6..b758f13 100644 --- a/apps/web/src/components/widgets/CalendarWidget.tsx +++ b/apps/web/src/components/widgets/CalendarWidget.tsx @@ -4,61 +4,43 @@ import { useState, useEffect } from "react"; import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react"; -import type { WidgetProps } from "@mosaic/shared"; - -interface Event { - id: string; - title: string; - startTime: string; - endTime?: string; - location?: string; - allDay: boolean; -} +import type { WidgetProps, Event } from "@mosaic/shared"; +import { fetchEvents } from "@/lib/api/events"; export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element { const [events, setEvents] = useState([]); const [isLoading, setIsLoading] = useState(true); - // Mock data for now - will fetch from API later useEffect(() => { - setIsLoading(true); - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); + let isMounted = true; - setTimeout(() => { - setEvents([ - { - id: "1", - title: "Team Standup", - startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(), - endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(), - location: "Zoom", - allDay: false, - }, - { - id: "2", - title: "Project Review", - startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(), - endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(), - location: "Conference Room A", - allDay: false, - }, - { - id: "3", - title: "Sprint Planning", - startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(), - endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(), - allDay: false, - }, - ]); - setIsLoading(false); - }, 500); + const loadEvents = async (): Promise => { + setIsLoading(true); + try { + const data = await fetchEvents(); + if (isMounted) { + setEvents(data); + } + } catch { + if (isMounted) { + setEvents([]); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + void loadEvents(); + + return (): void => { + isMounted = false; + }; }, []); - const formatTime = (dateString: string): string => { - const date = new Date(dateString); + const formatTime = (dateValue: Date | string): string => { + const date = new Date(dateValue); return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", @@ -66,8 +48,8 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React }); }; - const formatDay = (dateString: string): string => { - const date = new Date(dateString); + const formatDay = (dateValue: Date | string): string => { + const date = new Date(dateValue); const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); diff --git a/apps/web/src/components/widgets/TasksWidget.tsx b/apps/web/src/components/widgets/TasksWidget.tsx index cbd04d3..a502551 100644 --- a/apps/web/src/components/widgets/TasksWidget.tsx +++ b/apps/web/src/components/widgets/TasksWidget.tsx @@ -4,68 +4,56 @@ import { useState, useEffect } from "react"; import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react"; -import type { WidgetProps } from "@mosaic/shared"; +import { TaskPriority, TaskStatus, type WidgetProps, type Task } from "@mosaic/shared"; +import { fetchTasks } from "@/lib/api/tasks"; -interface Task { - id: string; - title: string; - status: string; - priority: string; - dueDate?: string; -} - -// eslint-disable-next-line no-empty-pattern -export function TasksWidget({}: WidgetProps): React.JSX.Element { +export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element { const [tasks, setTasks] = useState([]); const [isLoading, setIsLoading] = useState(true); - // Mock data for now - will fetch from API later useEffect(() => { - setIsLoading(true); - // Simulate API call - setTimeout(() => { - setTasks([ - { - id: "1", - title: "Complete project documentation", - status: "IN_PROGRESS", - priority: "HIGH", - dueDate: "2024-02-01", - }, - { - id: "2", - title: "Review pull requests", - status: "NOT_STARTED", - priority: "MEDIUM", - dueDate: "2024-02-02", - }, - { - id: "3", - title: "Update dependencies", - status: "COMPLETED", - priority: "LOW", - dueDate: "2024-01-30", - }, - ]); - setIsLoading(false); - }, 500); + let isMounted = true; + + const loadTasks = async (): Promise => { + setIsLoading(true); + try { + const data = await fetchTasks(); + if (isMounted) { + setTasks(data); + } + } catch { + if (isMounted) { + setTasks([]); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + void loadTasks(); + + return (): void => { + isMounted = false; + }; }, []); - const getPriorityIcon = (priority: string): React.JSX.Element => { + const getPriorityIcon = (priority: TaskPriority): React.JSX.Element => { switch (priority) { - case "HIGH": + case TaskPriority.HIGH: return ; - case "MEDIUM": + case TaskPriority.MEDIUM: return ; - case "LOW": + case TaskPriority.LOW: return ; default: return ; } }; - const getStatusIcon = (status: string): React.JSX.Element => { - return status === "COMPLETED" ? ( + const getStatusIcon = (status: TaskStatus): React.JSX.Element => { + return status === TaskStatus.COMPLETED ? ( ) : ( @@ -74,8 +62,8 @@ export function TasksWidget({}: WidgetProps): React.JSX.Element { const stats = { total: tasks.length, - inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length, - completed: tasks.filter((t) => t.status === "COMPLETED").length, + inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length, + completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length, }; if (isLoading) { diff --git a/apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx b/apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx index daad555..c8a7906 100644 --- a/apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx +++ b/apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx @@ -1,16 +1,58 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { act, render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import type { Event } from "@mosaic/shared"; import { CalendarWidget } from "../CalendarWidget"; +import { fetchEvents } from "@/lib/api/events"; + +vi.mock("@/lib/api/events", () => ({ + fetchEvents: vi.fn(), +})); + +const mockEvents: Event[] = [ + { + id: "event-1", + title: "API Planning", + description: null, + startTime: new Date("2026-02-01T09:00:00Z"), + endTime: new Date("2026-02-01T09:30:00Z"), + allDay: false, + location: "Zoom", + recurrence: null, + creatorId: "user-1", + workspaceId: "workspace-1", + projectId: null, + metadata: {}, + createdAt: new Date("2026-01-30T09:00:00Z"), + updatedAt: new Date("2026-01-30T09:00:00Z"), + }, + { + id: "event-2", + title: "API Review", + description: null, + startTime: new Date("2026-02-02T10:00:00Z"), + endTime: new Date("2026-02-02T11:00:00Z"), + allDay: false, + location: "Room 1", + recurrence: null, + creatorId: "user-1", + workspaceId: "workspace-1", + projectId: null, + metadata: {}, + createdAt: new Date("2026-01-30T09:00:00Z"), + updatedAt: new Date("2026-01-30T09:00:00Z"), + }, +]; async function finishWidgetLoad(): Promise { - await act(async () => { - await vi.advanceTimersByTimeAsync(500); + await waitFor(() => { + expect(screen.queryByText("Loading events...")).not.toBeInTheDocument(); }); } describe("CalendarWidget", (): void => { beforeEach((): void => { - vi.useFakeTimers(); + vi.clearAllMocks(); + vi.mocked(fetchEvents).mockResolvedValue(mockEvents); vi.setSystemTime(new Date("2026-02-01T08:00:00Z")); }); @@ -24,15 +66,15 @@ describe("CalendarWidget", (): void => { expect(screen.getByText("Loading events...")).toBeInTheDocument(); }); - it("renders upcoming events after loading", async (): Promise => { + it("fetches and renders upcoming events after loading", async (): Promise => { render(); await finishWidgetLoad(); + expect(fetchEvents).toHaveBeenCalledTimes(1); expect(screen.getByText("Upcoming Events")).toBeInTheDocument(); - expect(screen.getByText("Team Standup")).toBeInTheDocument(); - expect(screen.getByText("Project Review")).toBeInTheDocument(); - expect(screen.getByText("Sprint Planning")).toBeInTheDocument(); + expect(screen.getByText("API Planning")).toBeInTheDocument(); + expect(screen.getByText("API Review")).toBeInTheDocument(); }); it("shows relative day labels", async (): Promise => { @@ -50,6 +92,15 @@ describe("CalendarWidget", (): void => { await finishWidgetLoad(); expect(screen.getByText("Zoom")).toBeInTheDocument(); - expect(screen.getByText("Conference Room A")).toBeInTheDocument(); + expect(screen.getByText("Room 1")).toBeInTheDocument(); + }); + + it("shows empty state when no events are returned", async (): Promise => { + vi.mocked(fetchEvents).mockResolvedValueOnce([]); + + render(); + await finishWidgetLoad(); + + expect(screen.getByText("No upcoming events")).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx b/apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx index 50091e4..354d62e 100644 --- a/apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx +++ b/apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx @@ -1,20 +1,80 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { act, render, screen } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared"; import { TasksWidget } from "../TasksWidget"; +import { fetchTasks } from "@/lib/api/tasks"; + +vi.mock("@/lib/api/tasks", () => ({ + fetchTasks: vi.fn(), +})); + +const mockTasks: Task[] = [ + { + id: "task-1", + title: "API task one", + description: null, + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-02-03T09:00:00Z"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "workspace-1", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: {}, + completedAt: null, + createdAt: new Date("2026-02-01T09:00:00Z"), + updatedAt: new Date("2026-02-01T09:00:00Z"), + }, + { + id: "task-2", + title: "API task two", + description: null, + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: new Date("2026-02-04T09:00:00Z"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "workspace-1", + projectId: null, + parentId: null, + sortOrder: 1, + metadata: {}, + completedAt: null, + createdAt: new Date("2026-02-01T09:00:00Z"), + updatedAt: new Date("2026-02-01T09:00:00Z"), + }, + { + id: "task-3", + title: "API task three", + description: null, + status: TaskStatus.COMPLETED, + priority: TaskPriority.LOW, + dueDate: new Date("2026-02-05T09:00:00Z"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "workspace-1", + projectId: null, + parentId: null, + sortOrder: 2, + metadata: {}, + completedAt: new Date("2026-02-02T09:00:00Z"), + createdAt: new Date("2026-02-01T09:00:00Z"), + updatedAt: new Date("2026-02-02T09:00:00Z"), + }, +]; async function finishWidgetLoad(): Promise { - await act(async () => { - await vi.advanceTimersByTimeAsync(500); + await waitFor(() => { + expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument(); }); } describe("TasksWidget", (): void => { beforeEach((): void => { - vi.useFakeTimers(); - }); - - afterEach((): void => { - vi.useRealTimers(); + vi.clearAllMocks(); + vi.mocked(fetchTasks).mockResolvedValue(mockTasks); }); it("renders loading state initially", (): void => { @@ -23,25 +83,26 @@ describe("TasksWidget", (): void => { expect(screen.getByText("Loading tasks...")).toBeInTheDocument(); }); - it("renders default summary stats", async (): Promise => { + it("fetches tasks and renders summary stats", async (): Promise => { render(); await finishWidgetLoad(); + expect(fetchTasks).toHaveBeenCalledTimes(1); expect(screen.getByText("Total")).toBeInTheDocument(); expect(screen.getByText("In Progress")).toBeInTheDocument(); expect(screen.getByText("Done")).toBeInTheDocument(); expect(screen.getByText("3")).toBeInTheDocument(); }); - it("renders default task rows", async (): Promise => { + it("renders task rows from API response", async (): Promise => { render(); await finishWidgetLoad(); - expect(screen.getByText("Complete project documentation")).toBeInTheDocument(); - expect(screen.getByText("Review pull requests")).toBeInTheDocument(); - expect(screen.getByText("Update dependencies")).toBeInTheDocument(); + expect(screen.getByText("API task one")).toBeInTheDocument(); + expect(screen.getByText("API task two")).toBeInTheDocument(); + expect(screen.getByText("API task three")).toBeInTheDocument(); }); it("shows due date labels for each task", async (): Promise => { @@ -51,4 +112,13 @@ describe("TasksWidget", (): void => { expect(screen.getAllByText(/Due:/).length).toBe(3); }); + + it("shows empty state when API returns no tasks", async (): Promise => { + vi.mocked(fetchTasks).mockResolvedValueOnce([]); + + render(); + await finishWidgetLoad(); + + expect(screen.getByText("No tasks yet")).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/lib/api/telemetry.test.ts b/apps/web/src/lib/api/telemetry.test.ts new file mode 100644 index 0000000..abd0610 --- /dev/null +++ b/apps/web/src/lib/api/telemetry.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { fetchUsageSummary } from "./telemetry"; + +vi.mock("./client", () => ({ + apiGet: vi.fn(), +})); + +const { apiGet } = await import("./client"); + +describe("Telemetry API Client", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-02T12:00:00Z")); + }); + + afterEach((): void => { + vi.useRealTimers(); + }); + + it("fetches usage summary from llm usage analytics endpoint", async (): Promise => { + vi.mocked(apiGet).mockResolvedValueOnce({ + data: { + totalCalls: 47, + totalPromptTokens: 120000, + totalCompletionTokens: 125800, + totalTokens: 245800, + totalCostCents: 342, + averageDurationMs: 3200, + byProvider: [], + byModel: [], + byTaskType: [], + }, + }); + + const result = await fetchUsageSummary("30d"); + + const calledEndpoint = vi.mocked(apiGet).mock.calls[0]?.[0]; + expect(calledEndpoint).toMatch(/^\/api\/llm-usage\/analytics\?/); + + const queryString = calledEndpoint?.split("?")[1] ?? ""; + const params = new URLSearchParams(queryString); + expect(params.get("startDate")).toBeTruthy(); + expect(params.get("endDate")).toBeTruthy(); + + expect(result).toEqual({ + totalTokens: 245800, + totalCost: 3.42, + taskCount: 47, + avgQualityGatePassRate: 0, + }); + }); +}); diff --git a/apps/web/src/lib/api/telemetry.ts b/apps/web/src/lib/api/telemetry.ts index 551c9d5..adee4c1 100644 --- a/apps/web/src/lib/api/telemetry.ts +++ b/apps/web/src/lib/api/telemetry.ts @@ -1,10 +1,6 @@ /** * Telemetry API Client * Handles telemetry data fetching for the usage dashboard. - * - * NOTE: Currently returns mock/placeholder data since the telemetry API - * aggregation endpoints don't exist yet. The important thing is the UI structure. - * When the backend endpoints are ready, replace mock calls with real apiGet() calls. */ import { apiGet, type ApiResponse } from "./client"; @@ -60,65 +56,84 @@ export interface EstimateResponse { }; } -// ─── Mock Data Generators ──────────────────────────────────────────── +interface ProviderUsageAnalyticsItem { + provider: string; + calls: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costCents: number; + averageDurationMs: number; +} -function generateDateRange(range: TimeRange): string[] { - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const dates: string[] = []; - const now = new Date(); +interface ModelUsageAnalyticsItem { + model: string; + calls: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costCents: number; + averageDurationMs: number; +} - for (let i = days - 1; i >= 0; i--) { - const d = new Date(now); - d.setDate(d.getDate() - i); - dates.push(d.toISOString().split("T")[0] ?? ""); +interface TaskTypeUsageAnalyticsItem { + taskType: string; + calls: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costCents: number; + averageDurationMs: number; +} + +interface UsageAnalyticsResponse { + totalCalls: number; + totalPromptTokens: number; + totalCompletionTokens: number; + totalTokens: number; + totalCostCents: number; + averageDurationMs: number; + byProvider: ProviderUsageAnalyticsItem[]; + byModel: ModelUsageAnalyticsItem[]; + byTaskType: TaskTypeUsageAnalyticsItem[]; +} + +const TASK_OUTCOME_COLORS = ["#6EBF8B", "#F5C862", "#94A3B8", "#C4A5DE", "#7AA2F7"]; +const DAYS_BY_RANGE: Record = { + "7d": 7, + "30d": 30, + "90d": 90, +}; +const analyticsRequestCache = new Map>(); + +function buildAnalyticsEndpoint(timeRange: TimeRange): string { + const endDate = new Date(); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - (DAYS_BY_RANGE[timeRange] - 1)); + startDate.setHours(0, 0, 0, 0); + + const query = new URLSearchParams({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }).toString(); + + return `/api/llm-usage/analytics?${query}`; +} + +async function fetchUsageAnalytics(timeRange: TimeRange): Promise { + const cachedRequest = analyticsRequestCache.get(timeRange); + if (cachedRequest) { + return cachedRequest; } - return dates; -} + const request = apiGet>(buildAnalyticsEndpoint(timeRange)) + .then((response) => response.data) + .finally(() => { + analyticsRequestCache.delete(timeRange); + }); -function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] { - const dates = generateDateRange(range); - - return dates.map((date) => { - const baseInput = 8000 + Math.floor(Math.random() * 12000); - const baseOutput = 3000 + Math.floor(Math.random() * 7000); - return { - date, - inputTokens: baseInput, - outputTokens: baseOutput, - totalTokens: baseInput + baseOutput, - }; - }); -} - -function generateMockSummary(range: TimeRange): UsageSummary { - const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12; - return { - totalTokens: 245_800 * multiplier, - totalCost: 3.42 * multiplier, - taskCount: 47 * multiplier, - avgQualityGatePassRate: 0.87, - }; -} - -function generateMockCostBreakdown(): CostBreakdownItem[] { - return [ - { model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 }, - { model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 }, - { model: "claude-haiku-3.5", provider: "anthropic", cost: 4.2, taskCount: 156 }, - { model: "llama-3.3-70b", provider: "ollama", cost: 0, taskCount: 67 }, - { model: "gemini-2.0-flash", provider: "google", cost: 2.8, taskCount: 42 }, - ]; -} - -// PDA-friendly colors: calm, no aggressive reds -function generateMockTaskOutcomes(): TaskOutcomeItem[] { - return [ - { outcome: "Success", count: 312, color: "#6EBF8B" }, - { outcome: "Partial", count: 48, color: "#F5C862" }, - { outcome: "Timeout", count: 18, color: "#94A3B8" }, - { outcome: "Incomplete", count: 22, color: "#C4A5DE" }, - ]; + analyticsRequestCache.set(timeRange, request); + return request; } // ─── API Functions ─────────────────────────────────────────────────── @@ -127,47 +142,54 @@ function generateMockTaskOutcomes(): TaskOutcomeItem[] { * Fetch usage summary data (total tokens, cost, task count, quality rate) */ export async function fetchUsageSummary(timeRange: TimeRange): Promise { - // TODO: Replace with real API call when backend aggregation endpoints are ready - // const response = await apiGet>(`/api/telemetry/summary?range=${timeRange}`); - // return response.data; - void apiGet; // suppress unused import warning in the meantime - await new Promise((resolve) => setTimeout(resolve, 200)); - return generateMockSummary(timeRange); + const analytics = await fetchUsageAnalytics(timeRange); + + return { + totalTokens: analytics.totalTokens, + totalCost: analytics.totalCostCents / 100, + taskCount: analytics.totalCalls, + avgQualityGatePassRate: 0, + }; } /** * Fetch token usage time series for charts */ -export async function fetchTokenUsage(timeRange: TimeRange): Promise { - // TODO: Replace with real API call - // const response = await apiGet>(`/api/telemetry/tokens?range=${timeRange}`); - // return response.data; - await new Promise((resolve) => setTimeout(resolve, 250)); - return generateMockTokenUsage(timeRange); +export function fetchTokenUsage(timeRange: TimeRange): Promise { + void timeRange; + return Promise.resolve([]); } /** * Fetch cost breakdown by model */ export async function fetchCostBreakdown(timeRange: TimeRange): Promise { - // TODO: Replace with real API call - // const response = await apiGet>(`/api/telemetry/costs?range=${timeRange}`); - // return response.data; - await new Promise((resolve) => setTimeout(resolve, 200)); - void timeRange; - return generateMockCostBreakdown(); + const analytics = await fetchUsageAnalytics(timeRange); + + return analytics.byModel + .filter((item) => item.calls > 0) + .sort((a, b) => b.costCents - a.costCents) + .map((item) => ({ + model: item.model, + provider: "unknown", + cost: item.costCents / 100, + taskCount: item.calls, + })); } /** * Fetch task outcome distribution */ export async function fetchTaskOutcomes(timeRange: TimeRange): Promise { - // TODO: Replace with real API call - // const response = await apiGet>(`/api/telemetry/outcomes?range=${timeRange}`); - // return response.data; - await new Promise((resolve) => setTimeout(resolve, 150)); - void timeRange; - return generateMockTaskOutcomes(); + const analytics = await fetchUsageAnalytics(timeRange); + + return analytics.byTaskType + .filter((item) => item.calls > 0) + .map((item, index) => ({ + outcome: item.taskType, + count: item.calls, + color: TASK_OUTCOME_COLORS[index % TASK_OUTCOME_COLORS.length] ?? "#94A3B8", + })); } /**