diff --git a/apps/web/.env.example b/apps/web/.env.example index a01e79f5..ad25e90e 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -2,6 +2,10 @@ # Update this if DEALBOT_PORT is changed in backend VITE_API_BASE_URL=http://localhost:8080 +# Optional: enable MSW (Mock Service Worker) for API mocking in development +# Set to "true" to intercept API requests with mock handlers +VITE_ENABLE_MOCKING= + # Optional: enable Plausible analytics by setting the site domain # Example values: dealbot.filoz.org or staging.dealbot.filoz.org VITE_PLAUSIBLE_DATA_DOMAIN= diff --git a/apps/web/README.md b/apps/web/README.md index a347aec8..39688af3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -72,6 +72,7 @@ pnpm preview | Variable | Description | Default | | ---------------------------- | ------------------------------- | ----------------------- | | `VITE_API_BASE_URL` | Backend API base URL | `http://localhost:8080` | +| `VITE_ENABLE_MOCKING` | Enable MSW API mocking | Empty (disabled) | | `VITE_PLAUSIBLE_DATA_DOMAIN` | Enable Plausible site analytics | Empty (disabled) | All environment variables must be prefixed with `VITE_` to be accessible in the application. diff --git a/apps/web/package.json b/apps/web/package.json index fc0f7f53..2b13a6ea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,8 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.1.12", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -44,7 +46,8 @@ "react-router-dom": "^7.13.0", "recharts": "^3.7.0", "tailwind-merge": "^2.6.0", - "tailwindcss": "^4.1.12" + "tailwindcss": "^4.1.12", + "zod": "^4.3.6" }, "devDependencies": { "@biomejs/biome": "catalog:", diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js new file mode 100644 index 00000000..f9bb7dea --- /dev/null +++ b/apps/web/public/mockServiceWorker.js @@ -0,0 +1,336 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = "2.12.10"; +const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; +const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); +const activeClientIds = new Set(); + +addEventListener("install", () => { + self.skipWaiting(); +}); + +addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +addEventListener("message", async (event) => { + const clientId = Reflect.get(event.source || {}, "id"); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + switch (event.data) { + case "KEEPALIVE_REQUEST": { + sendToClient(client, { + type: "KEEPALIVE_RESPONSE", + }); + break; + } + + case "INTEGRITY_CHECK_REQUEST": { + sendToClient(client, { + type: "INTEGRITY_CHECK_RESPONSE", + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); + + sendToClient(client, { + type: "MOCKING_ENABLED", + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener("fetch", (event) => { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === "navigate") { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse(event, client, requestId, requestInterceptedAt); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: "RESPONSE", + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === "top-level") { + return client; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === "visible"; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get("accept"); + if (acceptHeader) { + const values = acceptHeader.split(",").map((value) => value.trim()); + const filteredValues = values.filter((value) => value !== "msw/passthrough"); + + if (filteredValues.length > 0) { + headers.set("accept", filteredValues.join(", ")); + } else { + headers.delete("accept"); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: "REQUEST", + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); + } + + case "PASSTHROUGH": { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data?.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 99fe39ce..d003d5ec 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -22,7 +22,7 @@ const JSON_HEADERS = { "Content-Type": "application/json" } as const; * 2. Build-time value from import.meta.env.VITE_API_BASE_URL (set via Docker ARG / Vite env) * 3. Empty string (uses relative URLs) */ -const getBaseUrl = (): string => { +export const getBaseUrl = (): string => { const runtimeBaseUrl = typeof window === "undefined" ? undefined : window.__DEALBOT_CONFIG__?.API_BASE_URL; return runtimeBaseUrl ?? import.meta.env.VITE_API_BASE_URL ?? ""; }; diff --git a/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.spec.tsx b/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.spec.tsx new file mode 100644 index 00000000..75c86c36 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.spec.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ApprovalBadge } from "./ApprovalBadge"; + +describe("ApprovalBadge", () => { + it("should render 'Approved' badge when approved is true", () => { + render(); + expect(screen.getByText("Approved")).toBeInTheDocument(); + }); + + it("should render nothing when approved is false", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.tsx b/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.tsx new file mode 100644 index 00000000..d00462e6 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@/components/ui/badge"; + +interface ApprovalBadgeProps { + approved: boolean; +} + +export function ApprovalBadge({ approved }: ApprovalBadgeProps) { + if (!approved) return null; + + return ( + + Approved + + ); +} diff --git a/apps/web/src/components/NewLanding/ProvidersTable/components/TableCells.spec.tsx b/apps/web/src/components/NewLanding/ProvidersTable/components/TableCells.spec.tsx new file mode 100644 index 00000000..6ff3d57e --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/TableCells.spec.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FaultRateCell, SamplesCell, SuccessRateCell } from "./TableCells"; + +describe("SuccessRateCell", () => { + it("should render the rate with one decimal and percent sign", () => { + render(); + expect(screen.getByText("99.5%")).toBeInTheDocument(); + }); + + it("should render integer rates with one decimal place", () => { + render(); + expect(screen.getByText("100.0%")).toBeInTheDocument(); + }); + + it("should apply success styling", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("text-right"); + }); + + it("should render with warning status", () => { + render(); + expect(screen.getByText("90.0%")).toBeInTheDocument(); + }); + + it("should render with insufficient status", () => { + render(); + expect(screen.getByText("50.0%")).toBeInTheDocument(); + }); +}); + +describe("FaultRateCell", () => { + it("should render the rate with two decimals and percent sign", () => { + render(); + expect(screen.getByText("0.15%")).toBeInTheDocument(); + }); + + it("should render zero fault rate", () => { + render(); + expect(screen.getByText("0.00%")).toBeInTheDocument(); + }); + + it("should render with warning status", () => { + render(); + expect(screen.getByText("1.50%")).toBeInTheDocument(); + }); +}); + +describe("SamplesCell", () => { + it("should render the sample count with locale formatting", () => { + render(); + expect(screen.getByText("1,000")).toBeInTheDocument(); + }); + + it("should render zero samples", () => { + render(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("should render large numbers with locale formatting", () => { + render(); + expect(screen.getByText((1234567).toLocaleString())).toBeInTheDocument(); + }); + + it("should render small numbers without commas", () => { + render(); + expect(screen.getByText("672")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/NewLanding/ProvidersTable/components/TableCells.tsx b/apps/web/src/components/NewLanding/ProvidersTable/components/TableCells.tsx new file mode 100644 index 00000000..a06d18a1 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/TableCells.tsx @@ -0,0 +1,41 @@ +import { cn } from "@/lib/utils"; +import type { CriteriaStatus } from "../utils/acceptance-criteria"; + +const rateStatusStyles: Record = { + success: "text-green-600 font-medium", + warning: "text-red-600 font-medium", + insufficient: "text-gray-400", +}; + +const samplesStatusStyles: Record = { + success: "text-foreground", + warning: "text-orange-500", + insufficient: "text-gray-400", +}; + +interface SuccessRateCellProps { + rate: number; + status: CriteriaStatus; +} + +export function SuccessRateCell({ rate, status }: SuccessRateCellProps) { + return
{rate.toFixed(1)}%
; +} + +interface FaultRateCellProps { + rate: number; + status: CriteriaStatus; +} + +export function FaultRateCell({ rate, status }: FaultRateCellProps) { + return
{rate.toFixed(2)}%
; +} + +interface SamplesCellProps { + samples: number; + status: CriteriaStatus; +} + +export function SamplesCell({ samples, status }: SamplesCellProps) { + return {samples.toLocaleString()}; +} diff --git a/apps/web/src/components/NewLanding/ProvidersTable/components/TableHeaders.tsx b/apps/web/src/components/NewLanding/ProvidersTable/components/TableHeaders.tsx new file mode 100644 index 00000000..f319a85c --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/TableHeaders.tsx @@ -0,0 +1,70 @@ +import { ACCEPTANCE_CRITERIA } from "../utils/acceptance-criteria"; + +export function StorageSuccessRateHeader() { + return ( +
+ Data Storage +
+ Success Rate +
+ ); +} + +export function StorageSamplesHeader() { + return ( +
+ Storage +
+ Samples +
+ (Min {ACCEPTANCE_CRITERIA.MIN_STORAGE_SAMPLES}) +
+
+ ); +} + +export function DataRetentionFaultRateHeader() { + return ( +
+ Data Retention +
+ Fault Rate +
+ ); +} + +export function DataRetentionSamplesHeader() { + return ( +
+ Data Retention +
+ Samples +
+ (Min {ACCEPTANCE_CRITERIA.MIN_RETENTION_SAMPLES}) +
+
+ ); +} + +export function RetrievalSuccessRateHeader() { + return ( +
+ Retrieval +
+ Success Rate +
+ ); +} + +export function RetrievalSamplesHeader() { + return ( +
+ Retrieval +
+ Samples +
+ (Min {ACCEPTANCE_CRITERIA.MIN_RETRIEVAL_SAMPLES}) +
+
+ ); +} diff --git a/apps/web/src/components/NewLanding/ProvidersTable/components/TableRow.tsx b/apps/web/src/components/NewLanding/ProvidersTable/components/TableRow.tsx new file mode 100644 index 00000000..b4786eb8 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/TableRow.tsx @@ -0,0 +1,22 @@ +import { flexRender, type Row } from "@tanstack/react-table"; +import { memo } from "react"; +import { TableCell, TableRow as UITableRow } from "@/components/ui/table"; +import type { ProviderWindowMetrics } from "@/schamas/providersWindowMetrics"; + +interface ProviderTableRowProps { + row: Row; +} + +function ProviderTableRowComponent({ row }: ProviderTableRowProps) { + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); +} + +export const ProviderTableRow = memo(ProviderTableRowComponent); diff --git a/apps/web/src/components/NewLanding/ProvidersTable/components/index.ts b/apps/web/src/components/NewLanding/ProvidersTable/components/index.ts new file mode 100644 index 00000000..099d0424 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/index.ts @@ -0,0 +1,11 @@ +export { ApprovalBadge } from "./ApprovalBadge"; +export { FaultRateCell, SamplesCell, SuccessRateCell } from "./TableCells"; +export { + DataRetentionFaultRateHeader, + DataRetentionSamplesHeader, + RetrievalSamplesHeader, + RetrievalSuccessRateHeader, + StorageSamplesHeader, + StorageSuccessRateHeader, +} from "./TableHeaders"; +export { ProviderTableRow } from "./TableRow"; diff --git a/apps/web/src/components/NewLanding/ProvidersTable/data/column-definitions.tsx b/apps/web/src/components/NewLanding/ProvidersTable/data/column-definitions.tsx new file mode 100644 index 00000000..dde82eb9 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/data/column-definitions.tsx @@ -0,0 +1,104 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import type { ProviderWindowMetrics } from "@/schamas/providersWindowMetrics"; +import { + ApprovalBadge, + DataRetentionFaultRateHeader, + DataRetentionSamplesHeader, + FaultRateCell, + RetrievalSamplesHeader, + RetrievalSuccessRateHeader, + SamplesCell, + StorageSamplesHeader, + StorageSuccessRateHeader, + SuccessRateCell, +} from "../components"; +import { + ACCEPTANCE_CRITERIA, + getFaultRateStatus, + getSamplesStatus, + getSuccessRateStatus, +} from "../utils/acceptance-criteria"; + +const columnHelper = createColumnHelper(); + +export const columns = [ + columnHelper.accessor("providerId", { + header: "Provider", + cell: (info) => ( +
+ {info.getValue()} + +
+ ), + }), + columnHelper.accessor("storageSuccessRate", { + header: StorageSuccessRateHeader, + size: 110, + cell: (info) => { + const rate = info.getValue(); + const samples = info.row.original.storageSamples; + const status = getSuccessRateStatus(rate, samples); + return ; + }, + }), + + columnHelper.accessor("storageSamples", { + header: StorageSamplesHeader, + size: 100, + cell: (info) => { + const samples = info.getValue(); + const status = getSamplesStatus(samples, ACCEPTANCE_CRITERIA.MIN_STORAGE_SAMPLES); + return ( +
+ +
+ ); + }, + }), + columnHelper.accessor("dataRetentionFaultRate", { + header: DataRetentionFaultRateHeader, + size: 110, + cell: (info) => { + const rate = info.getValue(); + const samples = info.row.original.dataRetentionSamples; + const status = getFaultRateStatus(rate, samples); + return ; + }, + }), + columnHelper.accessor("dataRetentionSamples", { + header: DataRetentionSamplesHeader, + size: 110, + cell: (info) => { + const samples = info.getValue(); + const status = getSamplesStatus(samples, ACCEPTANCE_CRITERIA.MIN_RETENTION_SAMPLES); + return ( +
+ +
+ ); + }, + }), + columnHelper.accessor("retrievalSuccessRate", { + header: RetrievalSuccessRateHeader, + size: 110, + cell: (info) => { + const rate = info.getValue(); + const samples = info.row.original.retrievalSamples; + const status = getSuccessRateStatus(rate, samples); + return ; + }, + }), + columnHelper.accessor("retrievalSamples", { + header: RetrievalSamplesHeader, + size: 100, + cell: (info) => { + const samples = info.getValue(); + const status = getSamplesStatus(samples, ACCEPTANCE_CRITERIA.MIN_RETRIEVAL_SAMPLES); + return ( +
+ +
+ ); + }, + }), +]; diff --git a/apps/web/src/components/NewLanding/ProvidersTable/index.spec.tsx b/apps/web/src/components/NewLanding/ProvidersTable/index.spec.tsx new file mode 100644 index 00000000..037d0d94 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/index.spec.tsx @@ -0,0 +1,110 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { ProviderWindowMetrics } from "@/schamas/providersWindowMetrics"; +import ProvidersTable from "./index"; + +const mockData: ProviderWindowMetrics[] = [ + { + providerId: "f01234", + manuallyApproved: true, + storageSuccessRate: 99.5, + storageSamples: 672, + dataRetentionFaultRate: 0.0, + dataRetentionSamples: 672, + retrievalSuccessRate: 98.5, + retrievalSamples: 672, + }, + { + providerId: "f05678", + manuallyApproved: false, + storageSuccessRate: 94.5, + storageSamples: 100, + dataRetentionFaultRate: 0.6, + dataRetentionSamples: 300, + retrievalSuccessRate: 85.5, + retrievalSamples: 100, + }, +]; + +describe("ProvidersTable", () => { + describe("Rendering", () => { + it("should render without crashing with default props", () => { + render(); + expect(screen.getByText(/no results/i)).toBeInTheDocument(); + }); + + it("should render column headers", () => { + render(); + expect(screen.getByText("Provider")).toBeInTheDocument(); + }); + + it("should render provider IDs", () => { + render(); + expect(screen.getByText("f01234")).toBeInTheDocument(); + expect(screen.getByText("f05678")).toBeInTheDocument(); + }); + + it("should render approval badge for manually approved provider", () => { + render(); + expect(screen.getByText("Approved")).toBeInTheDocument(); + }); + }); + + describe("Loading State", () => { + it("should show loading message when isLoading is true", () => { + render(); + expect(screen.getByText(/loading providers/i)).toBeInTheDocument(); + }); + + it("should not show data rows when loading", () => { + render(); + expect(screen.queryByText("f01234")).not.toBeInTheDocument(); + }); + }); + + describe("Error State", () => { + it("should show error message when error is provided", () => { + const error = new Error("Network failure"); + render(); + expect(screen.getByText(/error: network failure/i)).toBeInTheDocument(); + }); + + it("should not show data rows when error", () => { + const error = new Error("fail"); + render(); + expect(screen.queryByText("f01234")).not.toBeInTheDocument(); + }); + }); + + describe("Empty State", () => { + it("should show 'No results.' when data is empty", () => { + render(); + expect(screen.getByText(/no results/i)).toBeInTheDocument(); + }); + }); + + describe("Data Display", () => { + it("should render success rates with correct formatting", () => { + render(); + expect(screen.getByText("99.5%")).toBeInTheDocument(); + expect(screen.getByText("98.5%")).toBeInTheDocument(); + }); + + it("should render fault rates with two decimal places", () => { + render(); + expect(screen.getByText("0.00%")).toBeInTheDocument(); + expect(screen.getByText("0.60%")).toBeInTheDocument(); + }); + + it("should render sample counts", () => { + render(); + expect(screen.getAllByText("672").length).toBeGreaterThan(0); + }); + + it("should render min sample thresholds in headers", () => { + render(); + expect(screen.getAllByText(/min 200/i)).toHaveLength(2); + expect(screen.getByText(/min 500/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/NewLanding/ProvidersTable/index.tsx b/apps/web/src/components/NewLanding/ProvidersTable/index.tsx new file mode 100644 index 00000000..662f4459 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/index.tsx @@ -0,0 +1,87 @@ +import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useMemo } from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { ProviderWindowMetrics } from "@/schamas/providersWindowMetrics"; +import { ProviderTableRow } from "./components"; +import { columns } from "./data/column-definitions"; + +const EmptyState = ( + + + No results. + + +); + +const LoadingState = ( + + + Loading providers... + + +); + +const ErrorState = ({ message }: { message: string }) => ( + + + Error: {message} + + +); + +interface ProvidersTableProps { + data?: ProviderWindowMetrics[]; + isLoading?: boolean; + error?: Error | null; +} + +const EMPTY_DATA: ProviderWindowMetrics[] = []; + +export default function ProvidersTable({ data = EMPTY_DATA, isLoading = false, error = null }: ProvidersTableProps) { + const table = useReactTable( + useMemo( + () => ({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }), + [data], + ), + ); + + const rows = table.getRowModel().rows; + const hasRows = rows.length > 0; + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {isLoading ? ( + LoadingState + ) : error ? ( + + ) : hasRows ? ( + rows.map((row) => ) + ) : ( + EmptyState + )} + +
+
+ ); +} diff --git a/apps/web/src/components/NewLanding/ProvidersTable/utils/acceptance-criteria.spec.ts b/apps/web/src/components/NewLanding/ProvidersTable/utils/acceptance-criteria.spec.ts new file mode 100644 index 00000000..13effe19 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/utils/acceptance-criteria.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { ACCEPTANCE_CRITERIA, getFaultRateStatus, getSamplesStatus, getSuccessRateStatus } from "./acceptance-criteria"; + +describe("getSuccessRateStatus", () => { + it("should return 'insufficient' when samples are below MIN_STORAGE_SAMPLES", () => { + expect(getSuccessRateStatus(99, ACCEPTANCE_CRITERIA.MIN_STORAGE_SAMPLES - 1)).toBe("insufficient"); + expect(getSuccessRateStatus(99, 0)).toBe("insufficient"); + }); + + it("should return 'success' when rate >= MIN_SUCCESS_RATE and samples sufficient", () => { + expect(getSuccessRateStatus(97.0, 200)).toBe("success"); + expect(getSuccessRateStatus(100, 500)).toBe("success"); + }); + + it("should return 'warning' when rate < MIN_SUCCESS_RATE and samples sufficient", () => { + expect(getSuccessRateStatus(96.9, 200)).toBe("warning"); + expect(getSuccessRateStatus(0, 200)).toBe("warning"); + }); + + it("should handle boundary at exactly MIN_STORAGE_SAMPLES", () => { + expect(getSuccessRateStatus(97, 200)).toBe("success"); + }); + + it("should handle boundary at exactly MIN_SUCCESS_RATE", () => { + expect(getSuccessRateStatus(97.0, 200)).toBe("success"); + expect(getSuccessRateStatus(96.99, 200)).toBe("warning"); + }); +}); + +describe("getFaultRateStatus", () => { + it("should return 'insufficient' when samples are below MIN_RETENTION_SAMPLES", () => { + expect(getFaultRateStatus(0, ACCEPTANCE_CRITERIA.MIN_RETENTION_SAMPLES - 1)).toBe("insufficient"); + expect(getFaultRateStatus(0, 0)).toBe("insufficient"); + }); + + it("should return 'success' when rate <= MAX_FAULT_RATE and samples sufficient", () => { + expect(getFaultRateStatus(0.2, 500)).toBe("success"); + expect(getFaultRateStatus(0, 500)).toBe("success"); + }); + + it("should return 'warning' when rate > MAX_FAULT_RATE and samples sufficient", () => { + expect(getFaultRateStatus(0.21, 500)).toBe("warning"); + expect(getFaultRateStatus(5, 500)).toBe("warning"); + }); + + it("should handle boundary at exactly MIN_RETENTION_SAMPLES", () => { + expect(getFaultRateStatus(0.1, 500)).toBe("success"); + }); + + it("should handle boundary at exactly MAX_FAULT_RATE", () => { + expect(getFaultRateStatus(0.2, 500)).toBe("success"); + expect(getFaultRateStatus(0.200001, 500)).toBe("warning"); + }); +}); + +describe("getSamplesStatus", () => { + it("should return 'success' when samples >= minSamples", () => { + expect(getSamplesStatus(200, 200)).toBe("success"); + expect(getSamplesStatus(1000, 200)).toBe("success"); + }); + + it("should return 'insufficient' when samples < minSamples", () => { + expect(getSamplesStatus(199, 200)).toBe("insufficient"); + expect(getSamplesStatus(0, 200)).toBe("insufficient"); + }); + + it("should work with different threshold values", () => { + expect(getSamplesStatus(500, 500)).toBe("success"); + expect(getSamplesStatus(499, 500)).toBe("insufficient"); + }); +}); + +describe("ACCEPTANCE_CRITERIA", () => { + it("should have expected constant values", () => { + expect(ACCEPTANCE_CRITERIA.MIN_STORAGE_SAMPLES).toBe(200); + expect(ACCEPTANCE_CRITERIA.MIN_RETRIEVAL_SAMPLES).toBe(200); + expect(ACCEPTANCE_CRITERIA.MIN_RETENTION_SAMPLES).toBe(500); + expect(ACCEPTANCE_CRITERIA.MIN_SUCCESS_RATE).toBe(97.0); + expect(ACCEPTANCE_CRITERIA.MAX_FAULT_RATE).toBe(0.2); + }); +}); diff --git a/apps/web/src/components/NewLanding/ProvidersTable/utils/acceptance-criteria.ts b/apps/web/src/components/NewLanding/ProvidersTable/utils/acceptance-criteria.ts new file mode 100644 index 00000000..31bbed5d --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/utils/acceptance-criteria.ts @@ -0,0 +1,27 @@ +export const ACCEPTANCE_CRITERIA = { + MIN_STORAGE_SAMPLES: 200, + MIN_RETRIEVAL_SAMPLES: 200, + MIN_RETENTION_SAMPLES: 500, + MIN_SUCCESS_RATE: 97.0, + MAX_FAULT_RATE: 0.2, +} as const; + +export type CriteriaStatus = "success" | "warning" | "insufficient"; + +export function getSuccessRateStatus(successRate: number, samples: number): CriteriaStatus { + if (samples < ACCEPTANCE_CRITERIA.MIN_STORAGE_SAMPLES) { + return "insufficient"; + } + return successRate >= ACCEPTANCE_CRITERIA.MIN_SUCCESS_RATE ? "success" : "warning"; +} + +export function getFaultRateStatus(faultRate: number, samples: number): CriteriaStatus { + if (samples < ACCEPTANCE_CRITERIA.MIN_RETENTION_SAMPLES) { + return "insufficient"; + } + return faultRate <= ACCEPTANCE_CRITERIA.MAX_FAULT_RATE ? "success" : "warning"; +} + +export function getSamplesStatus(samples: number, minSamples: number): CriteriaStatus { + return samples >= minSamples ? "success" : "insufficient"; +} diff --git a/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx b/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx new file mode 100644 index 00000000..3877471b --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx @@ -0,0 +1,50 @@ +import { subDays } from "date-fns"; +import type { DateRange, OnSelectHandler } from "react-day-picker"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import type { PresetOption, PresetValue, TimeWindow } from "@/lib/time-window"; + +const today = new Date(); +const ninetyDaysAgo = subDays(today, 90); + +type WindowContentProps = { + timeWindow: TimeWindow; + onDateRangeSelect: OnSelectHandler; + presetOptions: readonly PresetOption[]; + onPresetSelect: (preset: PresetValue) => void; +}; + +const WindowContent = ({ timeWindow, onDateRangeSelect, presetOptions, onPresetSelect }: WindowContentProps) => { + return ( + + + + + + {presetOptions.map((presetOption) => ( + + ))} + + + ); +}; + +export default WindowContent; diff --git a/apps/web/src/components/shared/TimeWindowSelector/WindowTrigger.tsx b/apps/web/src/components/shared/TimeWindowSelector/WindowTrigger.tsx new file mode 100644 index 00000000..afc25204 --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/WindowTrigger.tsx @@ -0,0 +1,26 @@ +import { Calendar, ChevronDown } from "lucide-react"; +import React from "react"; +import { Button } from "@/components/ui/button"; + +type WindowTriggerProps = React.ComponentPropsWithoutRef & { + label: string; +}; + +const WindowTrigger = React.forwardRef(({ label, ...props }, ref) => ( + +)); + +WindowTrigger.displayName = "WindowTrigger"; + +export default WindowTrigger; diff --git a/apps/web/src/components/shared/TimeWindowSelector/index.spec.tsx b/apps/web/src/components/shared/TimeWindowSelector/index.spec.tsx new file mode 100644 index 00000000..dd20270b --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/index.spec.tsx @@ -0,0 +1,106 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PRESET_OPTIONS, type PresetValue, type TimeWindow } from "@/lib/time-window"; +import TimeWindowSelector from "./index"; + +const defaultTimeWindow: TimeWindow = { + range: { from: new Date("2025-01-15"), to: undefined }, + preset: "7d", +}; + +describe("TimeWindowSelector", () => { + const onDateRangeSelect = vi.fn(); + const onPresetSelect = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + function renderSelector(timeWindow: TimeWindow = defaultTimeWindow) { + return render( + , + ); + } + + describe("Rendering", () => { + it("should render the trigger button with preset label", () => { + renderSelector(); + expect(screen.getByText("Last 7 Days")).toBeInTheDocument(); + }); + + it("should render the trigger button with date range label", () => { + const tw: TimeWindow = { + range: { from: new Date("2025-01-01T00:00:00Z"), to: new Date("2025-01-31T00:00:00Z") }, + preset: undefined, + }; + renderSelector(tw); + expect(screen.getByText(/jan/i)).toBeInTheDocument(); + }); + + it("should not show popover content initially", () => { + renderSelector(); + expect(screen.queryByText("Last Hour")).not.toBeInTheDocument(); + }); + }); + + describe("Popover Interaction", () => { + it("should open popover when trigger is clicked", async () => { + const user = userEvent.setup(); + renderSelector(); + + await user.click(screen.getByRole("button", { name: /last 7 days/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Last Hour")).toBeInTheDocument(); + expect(screen.getByText("All Time")).toBeInTheDocument(); + }); + + it("should render all preset buttons when open", async () => { + const user = userEvent.setup(); + renderSelector(); + + await user.click(screen.getByRole("button", { name: /last 7 days/i })); + + const dialog = screen.getByRole("dialog"); + const presetLabels = PRESET_OPTIONS.map((p) => p.label); + + for (const label of presetLabels) { + expect(within(dialog).getByRole("button", { name: label })).toBeInTheDocument(); + } + }); + + it("should call onPresetSelect and close popover when a preset is clicked", async () => { + const user = userEvent.setup(); + renderSelector(); + + await user.click(screen.getByRole("button", { name: /last 7 days/i })); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Last 30 Days" })); + + expect(onPresetSelect).toHaveBeenCalledWith("30d" as PresetValue); + expect(onPresetSelect).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("Active Preset Highlighting", () => { + it("should highlight the currently active preset", async () => { + const user = userEvent.setup(); + renderSelector({ ...defaultTimeWindow, preset: "30d" }); + + await user.click(screen.getByRole("button", { name: /last 30 days/i })); + + const dialog = screen.getByRole("dialog"); + const activeButton = within(dialog).getByRole("button", { name: "Last 30 Days" }); + const inactiveButton = within(dialog).getByRole("button", { name: "Last 7 Days" }); + + expect(activeButton).toBeInTheDocument(); + expect(inactiveButton).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/shared/TimeWindowSelector/index.tsx b/apps/web/src/components/shared/TimeWindowSelector/index.tsx new file mode 100644 index 00000000..cf276ea0 --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/index.tsx @@ -0,0 +1,53 @@ +import { useCallback, useState } from "react"; +import type { DateRange, OnSelectHandler } from "react-day-picker"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import type { PresetValue, TimeWindow } from "@/lib/time-window"; +import { getTimeWindowLabel, PRESET_OPTIONS } from "@/lib/time-window"; +import WindowContent from "./WindowContent"; +import WindowTrigger from "./WindowTrigger"; + +interface TimeWindowSelectorProps { + timeWindow: TimeWindow; + onDateRangeSelect: OnSelectHandler; + onPresetSelect: (preset: PresetValue) => void; +} + +function TimeWindowSelector({ timeWindow, onDateRangeSelect, onPresetSelect }: TimeWindowSelectorProps) { + const [open, setOpen] = useState(false); + + const handleDateRangeSelect: OnSelectHandler = useCallback( + (range, triggerDate, modifiers, e) => { + onDateRangeSelect(range, triggerDate, modifiers, e); + if (range.from && range.to) { + setOpen(false); + } + }, + [onDateRangeSelect], + ); + + const handlePresetSelect = useCallback( + (preset: PresetValue) => { + onPresetSelect(preset); + setOpen(false); + }, + [onPresetSelect], + ); + + return ( + + + + + + + + + ); +} + +export default TimeWindowSelector; diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index 1ceacd35..872b4001 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -1,6 +1,6 @@ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react"; -import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; +import { type DayButton, DayPicker, getDefaultClassNames, type Locale } from "react-day-picker"; import { Button, buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -10,6 +10,7 @@ function Calendar({ showOutsideDays = true, captionLayout = "label", buttonVariant = "ghost", + locale, formatters, components, ...props @@ -22,14 +23,15 @@ function Calendar({ svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, className, )} captionLayout={captionLayout} + locale={locale} formatters={{ - formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }), + formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: "short" }), ...formatters, }} classNames={{ @@ -56,7 +58,7 @@ function Calendar({ defaultClassNames.dropdowns, ), dropdown_root: cn( - "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + "relative cn-calendar-dropdown-root rounded-(--cell-radius)", defaultClassNames.dropdown_root, ), dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown), @@ -64,30 +66,36 @@ function Calendar({ "select-none font-medium", captionLayout === "label" ? "text-sm" - : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + : "cn-calendar-caption-label rounded-(--cell-radius) flex items-center gap-1 text-sm [&>svg]:text-muted-foreground [&>svg]:size-3.5", defaultClassNames.caption_label, ), table: "w-full border-collapse", weekdays: cn("flex", defaultClassNames.weekdays), weekday: cn( - "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + "text-muted-foreground rounded-(--cell-radius) flex-1 font-normal text-[0.8rem] select-none", defaultClassNames.weekday, ), week: cn("flex w-full mt-2", defaultClassNames.week), week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header), week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number), day: cn( - "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + "relative w-full rounded-(--cell-radius) h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius) group/day aspect-square select-none", props.showWeekNumber - ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" - : "[&:first-child[data-selected=true]_button]:rounded-l-md", + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", defaultClassNames.day, ), - range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start), + range_start: cn( + "rounded-l-(--cell-radius) bg-muted relative after:bg-muted after:absolute after:inset-y-0 after:w-4 after:right-0 z-0 isolate", + defaultClassNames.range_start, + ), range_middle: cn("rounded-none", defaultClassNames.range_middle), - range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + range_end: cn( + "rounded-r-(--cell-radius) bg-muted relative after:bg-muted after:absolute after:inset-y-0 after:w-4 after:left-0 z-0 isolate", + defaultClassNames.range_end, + ), today: cn( - "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + "bg-muted text-foreground rounded-(--cell-radius) data-[selected=true]:rounded-none", defaultClassNames.today, ), outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside), @@ -101,16 +109,16 @@ function Calendar({ }, Chevron: ({ className, orientation, ...props }) => { if (orientation === "left") { - return ; + return ; } if (orientation === "right") { - return ; + return ; } return ; }, - DayButton: CalendarDayButton, + DayButton: ({ ...props }) => , WeekNumber: ({ children, ...props }) => { return ( @@ -125,7 +133,13 @@ function Calendar({ ); } -function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps) { +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { const defaultClassNames = getDefaultClassNames(); const ref = React.useRef(null); @@ -138,7 +152,7 @@ function CalendarDayButton({ className, day, modifiers, ...props }: React.Compon ref={ref} variant="ghost" size="icon" - data-day={day.date.toLocaleDateString()} + data-day={day.date.toLocaleDateString(locale?.code)} data-selected-single={ modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle } @@ -146,7 +160,7 @@ function CalendarDayButton({ className, day, modifiers, ...props }: React.Compon data-range-end={modifiers.range_end} data-range-middle={modifiers.range_middle} className={cn( - "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", + "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70", defaultClassNames.day, className, )} diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx new file mode 100644 index 00000000..89ec34b3 --- /dev/null +++ b/apps/web/src/components/ui/table.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ; +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ; +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> + ); +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> + ); +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ); +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/apps/web/src/hooks/useProvidersQuery.spec.tsx b/apps/web/src/hooks/useProvidersQuery.spec.tsx new file mode 100644 index 00000000..356b867f --- /dev/null +++ b/apps/web/src/hooks/useProvidersQuery.spec.tsx @@ -0,0 +1,115 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { mockProviderData } from "@test/mocks/data/providers"; +import { server } from "@test/mocks/server"; +import { renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useProvidersQuery } from "./useProvidersQuery"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +const fastProvidersHandler = http.get("*/api/providers", ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json({ + data: mockProviderData, + meta: { + startDate: url.searchParams.get("startDate"), + endDate: url.searchParams.get("endDate"), + count: mockProviderData.length, + }, + }); +}); + +describe("useProvidersQuery", () => { + beforeEach(() => { + server.use(fastProvidersHandler); + }); + + it("should fetch providers with default options", async () => { + const { result } = renderHook(() => useProvidersQuery(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.data).toHaveLength(mockProviderData.length); + expect(result.current.data?.meta.count).toBe(mockProviderData.length); + }); + + it("should fetch with preset option", async () => { + const { result } = renderHook(() => useProvidersQuery({ preset: "7d" }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.data).toHaveLength(mockProviderData.length); + }); + + it("should fetch with date range options", async () => { + const { result } = renderHook( + () => + useProvidersQuery({ + startDate: "2025-01-01T00:00:00Z", + endDate: "2025-01-31T00:00:00Z", + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.data).toHaveLength(mockProviderData.length); + }); + + it("should be in loading state initially", () => { + const { result } = renderHook(() => useProvidersQuery(), { wrapper: createWrapper() }); + expect(result.current.isLoading).toBe(true); + }); + + it("should handle API errors", async () => { + server.use( + http.get("*/api/providers", () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + const { result } = renderHook(() => useProvidersQuery(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toBe("Failed to fetch providers"); + }); + + it("should handle Zod validation errors for malformed data", async () => { + server.use( + http.get("*/api/providers", () => { + return HttpResponse.json({ + data: [{ providerId: 123 }], + meta: { startDate: null, endDate: null, count: 1 }, + }); + }), + ); + + const { result } = renderHook(() => useProvidersQuery(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it("should validate provider data structure", async () => { + const { result } = renderHook(() => useProvidersQuery({ preset: "30d" }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const provider = result.current.data?.data[0]; + expect(provider).toBeDefined(); + expect(typeof provider?.providerId).toBe("string"); + expect(typeof provider?.manuallyApproved).toBe("boolean"); + expect(typeof provider?.storageSuccessRate).toBe("number"); + expect(typeof provider?.storageSamples).toBe("number"); + }); +}); diff --git a/apps/web/src/hooks/useProvidersQuery.ts b/apps/web/src/hooks/useProvidersQuery.ts new file mode 100644 index 00000000..48998d4c --- /dev/null +++ b/apps/web/src/hooks/useProvidersQuery.ts @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { getBaseUrl } from "@/api/client"; +import { + type ProviderWindowMetricsResponse, + providerWindowMetricsResponseSchema, +} from "@/schamas/providersWindowMetrics"; + +interface UseProvidersQueryOptions { + startDate?: string; + endDate?: string; + preset?: string; +} + +async function fetchProviders(options: UseProvidersQueryOptions): Promise { + const params = new URLSearchParams(); + + if (options.preset) { + params.append("preset", options.preset); + } + + if (options.startDate) { + params.append("startDate", options.startDate); + } + + if (options.endDate) { + params.append("endDate", options.endDate); + } + + const url = `${getBaseUrl()}/api/providers?${params.toString()}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error("Failed to fetch providers"); + } + + const json: unknown = await response.json(); + return providerWindowMetricsResponseSchema.parse(json); +} + +export function useProvidersQuery(options: UseProvidersQueryOptions = {}) { + return useQuery({ + queryKey: ["providers", options], + queryFn: () => fetchProviders(options), + }); +} diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts new file mode 100644 index 00000000..dc7d7879 --- /dev/null +++ b/apps/web/src/lib/query-client.ts @@ -0,0 +1,12 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/apps/web/src/lib/time-window/constants.ts b/apps/web/src/lib/time-window/constants.ts new file mode 100644 index 00000000..1746bafc --- /dev/null +++ b/apps/web/src/lib/time-window/constants.ts @@ -0,0 +1,13 @@ +export const PRESET_OPTIONS = [ + { value: "1h", label: "Last Hour" }, + { value: "6h", label: "Last 6 Hours" }, + { value: "12h", label: "Last 12 Hours" }, + { value: "24h", label: "Last 24 Hours" }, + { value: "2d", label: "Last 2 Days" }, + { value: "7d", label: "Last 7 Days" }, + { value: "14d", label: "Last 14 Days" }, + { value: "30d", label: "Last 30 Days" }, + { value: "60d", label: "Last 60 Days" }, + { value: "90d", label: "Last 90 Days" }, + { value: "all", label: "All Time" }, +] as const; diff --git a/apps/web/src/lib/time-window/index.ts b/apps/web/src/lib/time-window/index.ts new file mode 100644 index 00000000..ecac7baa --- /dev/null +++ b/apps/web/src/lib/time-window/index.ts @@ -0,0 +1,9 @@ +export { PRESET_OPTIONS } from "./constants"; +export type { PresetOption, PresetValue, TimeWindow } from "./types"; +export { + formatCustomDateRange, + getPresetLabel, + getTimeWindowLabel, + parseTimeWindowFromURL, + serializeTimeWindowToURL, +} from "./url"; diff --git a/apps/web/src/lib/time-window/types.ts b/apps/web/src/lib/time-window/types.ts new file mode 100644 index 00000000..3bed3d6d --- /dev/null +++ b/apps/web/src/lib/time-window/types.ts @@ -0,0 +1,11 @@ +import type { DateRange } from "react-day-picker"; +import { PRESET_OPTIONS } from "./constants"; + +export type PresetOption = (typeof PRESET_OPTIONS)[number]; + +export type PresetValue = PresetOption["value"]; + +export type TimeWindow = { + range: DateRange; + preset?: PresetValue; +}; diff --git a/apps/web/src/lib/time-window/url.spec.ts b/apps/web/src/lib/time-window/url.spec.ts new file mode 100644 index 00000000..1a280687 --- /dev/null +++ b/apps/web/src/lib/time-window/url.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + formatCustomDateRange, + getPresetLabel, + getTimeWindowLabel, + parseTimeWindowFromURL, + serializeTimeWindowToURL, +} from "./url"; +import { PRESET_OPTIONS } from "./constants"; + +describe("parseTimeWindowFromURL", () => { + it("should parse a valid preset param", () => { + const params = new URLSearchParams({ preset: "7d" }); + const result = parseTimeWindowFromURL(params); + expect(result.preset).toBe("7d"); + expect(result.range.to).toBeUndefined(); + }); + + it("should parse all valid preset values", () => { + const presets = PRESET_OPTIONS.map((p) => p.value); + for (const preset of presets) { + const result = parseTimeWindowFromURL(new URLSearchParams({ preset })); + expect(result.preset).toBe(preset); + } + }); + + it("should reject invalid preset and fall back to default", () => { + const params = new URLSearchParams({ preset: "invalid" }); + const result = parseTimeWindowFromURL(params); + expect(result.preset).toBe("7d"); + }); + + it("should parse a valid date range", () => { + const from = "2025-01-01T00:00:00.000Z"; + const to = "2025-01-31T00:00:00.000Z"; + const params = new URLSearchParams({ from, to }); + const result = parseTimeWindowFromURL(params); + expect(result.preset).toBeUndefined(); + expect(result.range.from?.toISOString()).toBe(from); + expect(result.range.to?.toISOString()).toBe(to); + }); + + it("should reject date range where from > to", () => { + const params = new URLSearchParams({ + from: "2025-02-01T00:00:00.000Z", + to: "2025-01-01T00:00:00.000Z", + }); + const result = parseTimeWindowFromURL(params); + expect(result.preset).toBe("7d"); + }); + + it("should reject invalid date strings", () => { + const params = new URLSearchParams({ from: "not-a-date", to: "also-not-a-date" }); + const result = parseTimeWindowFromURL(params); + expect(result.preset).toBe("7d"); + }); + + it("should fall back to default when no params provided", () => { + const result = parseTimeWindowFromURL(new URLSearchParams()); + expect(result.preset).toBe("7d"); + expect(result.range.to).toBeUndefined(); + }); + + it("should prefer preset over date range when both present", () => { + const params = new URLSearchParams({ + preset: "30d", + from: "2025-01-01T00:00:00.000Z", + to: "2025-01-31T00:00:00.000Z", + }); + const result = parseTimeWindowFromURL(params); + expect(result.preset).toBe("30d"); + }); +}); + +describe("serializeTimeWindowToURL", () => { + it("should serialize a preset time window", () => { + const params = serializeTimeWindowToURL({ + range: { from: new Date(), to: undefined }, + preset: "7d", + }); + expect(params.get("preset")).toBe("7d"); + expect(params.has("from")).toBe(false); + expect(params.has("to")).toBe(false); + }); + + it("should serialize a date range time window", () => { + const from = new Date("2025-01-01T00:00:00.000Z"); + const to = new Date("2025-01-31T00:00:00.000Z"); + const params = serializeTimeWindowToURL({ range: { from, to }, preset: undefined }); + expect(params.get("from")).toBe(from.toISOString()); + expect(params.get("to")).toBe(to.toISOString()); + expect(params.has("preset")).toBe(false); + }); + + it("should return empty params when no preset and incomplete range", () => { + const params = serializeTimeWindowToURL({ + range: { from: new Date(), to: undefined }, + preset: undefined, + }); + expect(params.toString()).toBe(""); + }); + + it("should prefer preset over date range", () => { + const params = serializeTimeWindowToURL({ + range: { from: new Date("2025-01-01"), to: new Date("2025-01-31") }, + preset: "7d", + }); + expect(params.get("preset")).toBe("7d"); + expect(params.has("from")).toBe(false); + }); +}); + +describe("getPresetLabel", () => { + it("should return correct labels for all presets", () => { + expect(getPresetLabel("1h")).toBe("Last Hour"); + expect(getPresetLabel("7d")).toBe("Last 7 Days"); + expect(getPresetLabel("30d")).toBe("Last 30 Days"); + expect(getPresetLabel("all")).toBe("All Time"); + }); +}); + +describe("formatCustomDateRange", () => { + it("should format a date range", () => { + const from = new Date("2025-01-01T00:00:00.000Z"); + const to = new Date("2025-01-31T00:00:00.000Z"); + const result = formatCustomDateRange(from, to); + expect(result).toMatch(/Jan\s+1,\s+2025/); + expect(result).toMatch(/Jan\s+31,\s+2025/); + expect(result).toContain(" - "); + }); + + it("should return empty string when from is undefined", () => { + expect(formatCustomDateRange(undefined, new Date())).toBe(""); + }); + + it("should return empty string when to is undefined", () => { + expect(formatCustomDateRange(new Date(), undefined)).toBe(""); + }); + + it("should return empty string when both are undefined", () => { + expect(formatCustomDateRange(undefined, undefined)).toBe(""); + }); +}); + +describe("getTimeWindowLabel", () => { + it("should return preset label when preset is set", () => { + const result = getTimeWindowLabel({ + range: { from: new Date(), to: undefined }, + preset: "7d", + }); + expect(result).toBe("Last 7 Days"); + }); + + it("should return formatted date range when no preset", () => { + const from = new Date("2025-01-01T00:00:00.000Z"); + const to = new Date("2025-01-31T00:00:00.000Z"); + const result = getTimeWindowLabel({ range: { from, to }, preset: undefined }); + expect(result).toContain(" - "); + }); + + it("should return empty string when no preset and incomplete range", () => { + const result = getTimeWindowLabel({ + range: { from: new Date(), to: undefined }, + preset: undefined, + }); + expect(result).toBe(""); + }); +}); diff --git a/apps/web/src/lib/time-window/url.ts b/apps/web/src/lib/time-window/url.ts new file mode 100644 index 00000000..47cb27a3 --- /dev/null +++ b/apps/web/src/lib/time-window/url.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { PRESET_OPTIONS } from "./constants"; +import type { PresetValue, TimeWindow } from "./types"; + +const DEFAULT_PRESET: PresetValue = "7d"; + +const VALID_PRESETS = PRESET_OPTIONS.map((o) => o.value) as [PresetValue, ...PresetValue[]]; + +const dateSchema = z + .string() + .refine((val) => !Number.isNaN(new Date(val).getTime()), "Invalid date string") + .transform((val) => new Date(val)); + +const presetParamSchema = z.object({ + preset: z.enum(VALID_PRESETS), +}); + +const dateRangeParamSchema = z + .object({ from: dateSchema, to: dateSchema }) + .refine(({ from, to }) => from <= to, "Start date must be before end date"); + +/** + * Parses a URL search parameter object into a TimeWindow object. + * Validates presets against PRESET_OPTIONS and date ranges using Zod schemas. + * Falls back to the default preset ("7d") if the URL params are invalid or absent. + * + * @param searchParams - URL search parameter object to parse + * @returns Parsed TimeWindow object + */ +export function parseTimeWindowFromURL(searchParams: URLSearchParams): TimeWindow { + const now = new Date(); + const params = Object.fromEntries(searchParams); + + const presetResult = presetParamSchema.safeParse(params); + if (presetResult.success) { + return { range: { from: now, to: undefined }, preset: presetResult.data.preset }; + } + + const rangeResult = dateRangeParamSchema.safeParse(params); + if (rangeResult.success) { + return { range: { from: rangeResult.data.from, to: rangeResult.data.to }, preset: undefined }; + } + + return { range: { from: now, to: undefined }, preset: DEFAULT_PRESET }; +} + +/** + * Serializes a TimeWindow to URL search parameters. + * Only includes necessary parameters based on the type. + * + * @param timeWindow - TimeWindow to serialize + * @returns URLSearchParams with minimal required parameters + */ +export function serializeTimeWindowToURL({ range, preset }: TimeWindow): URLSearchParams { + const params = new URLSearchParams(); + + if (preset) { + params.set("preset", preset); + } else if (range.from && range.to) { + params.set("from", range.from.toISOString()); + params.set("to", range.to.toISOString()); + } + + return params; +} + +/** + * Gets a human-readable label for a preset value. + * Looks up the label from PRESET_OPTIONS to ensure consistency. + * + * @param preset - Preset string (e.g., "7d", "30d", "all") + * @returns Human-readable label + */ +export function getPresetLabel(preset: PresetValue): string { + const option = PRESET_OPTIONS.find((o) => o.value === preset); + return option?.label ?? preset; +} + +/** + * Formats a custom date range into a readable label. + * + * @param from - Start date + * @param to - End date + * @returns Formatted date range string + */ +export function formatCustomDateRange(from: Date | undefined, to: Date | undefined): string { + if (!from || !to) return ""; + + const formatDate = (date: Date): string => { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + return `${formatDate(from)} - ${formatDate(to)}`; +} + +export function getTimeWindowLabel(timeWindow: TimeWindow): string { + if (timeWindow.preset) return getPresetLabel(timeWindow.preset); + return formatCustomDateRange(timeWindow.range.from, timeWindow.range.to); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 34881289..f8d5f7d3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,19 +1,37 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import App from "./App"; import "./index.css"; -import { BrowserRouter } from "react-router-dom"; import { ThemeProvider } from "@/components/shared"; +import { queryClient } from "@/lib/query-client"; + +async function enableMocking() { + if (import.meta.env.VITE_ENABLE_MOCKING !== "true") { + return; + } -const container = document.getElementById("root"); -if (container) { - createRoot(container).render( - - - - - - - , - ); + const { worker } = await import("../test/mocks/browser"); + + return worker.start({ + onUnhandledRequest: "bypass", + }); } + +enableMocking().then(() => { + const container = document.getElementById("root"); + if (container) { + createRoot(container).render( + + + + + + + + + , + ); + } +}); diff --git a/apps/web/src/pages/NewLanding.tsx b/apps/web/src/pages/NewLanding.tsx index a0d47c82..54b09cd7 100644 --- a/apps/web/src/pages/NewLanding.tsx +++ b/apps/web/src/pages/NewLanding.tsx @@ -1,6 +1,75 @@ -import { LayoutDashboard } from "lucide-react"; -import { ComingSoon } from "@/components/shared"; +import { useEffect, useMemo, useState } from "react"; +import type { DateRange, OnSelectHandler } from "react-day-picker"; +import { useSearchParams } from "react-router-dom"; +import ProvidersTable from "@/components/NewLanding/ProvidersTable"; +import TimeWindowSelector from "@/components/shared/TimeWindowSelector"; +import { useProvidersQuery } from "@/hooks/useProvidersQuery"; +import type { PresetValue, TimeWindow } from "@/lib/time-window"; +import { parseTimeWindowFromURL, serializeTimeWindowToURL } from "@/lib/time-window"; export default function NewLanding() { - return ; + const [searchParams, setSearchParams] = useSearchParams(); + const [timeWindow, setTimeWindow] = useState(() => parseTimeWindowFromURL(searchParams)); + + useEffect(() => { + const params = serializeTimeWindowToURL(timeWindow); + const currentParams = searchParams.toString(); + const newParams = params.toString(); + + if (currentParams !== newParams) { + setSearchParams(params, { replace: true }); + } + }, [timeWindow, searchParams, setSearchParams]); + + const queryOptions = useMemo(() => { + if (timeWindow.preset) { + return { preset: timeWindow.preset }; + } + if (timeWindow.range.from && timeWindow.range.to) { + return { + startDate: timeWindow.range.from.toISOString(), + endDate: timeWindow.range.to.toISOString(), + }; + } + return {}; + }, [timeWindow]); + + const { data, isLoading, error } = useProvidersQuery(queryOptions); + + const handleDateRange: OnSelectHandler = (range) => { + setTimeWindow((prev) => ({ + range, + preset: range.from && range.to ? undefined : prev.preset, + })); + }; + + const handlePresetSelect = (preset: PresetValue) => { + setTimeWindow((prev) => ({ + preset, + range: { + ...prev.range, + to: undefined, + }, + })); + }; + + return ( +
+
+
+ Assessment Window: + +
+
+ +
+

Storage Providers

+ +
+
+ ); } diff --git a/apps/web/src/schamas/providersWindowMetrics.spec.ts b/apps/web/src/schamas/providersWindowMetrics.spec.ts new file mode 100644 index 00000000..ee4a7c6e --- /dev/null +++ b/apps/web/src/schamas/providersWindowMetrics.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { providerWindowMetricsResponseSchema, providerWindowMetricsSchema } from "./providersWindowMetrics"; + +const validProvider = { + providerId: "f01234", + manuallyApproved: true, + storageSuccessRate: 99.5, + storageSamples: 672, + dataRetentionFaultRate: 0.0, + dataRetentionSamples: 672, + retrievalSuccessRate: 98.5, + retrievalSamples: 672, +}; + +describe("providerWindowMetricsSchema", () => { + it("should parse a valid provider object", () => { + const result = providerWindowMetricsSchema.parse(validProvider); + expect(result).toEqual(validProvider); + }); + + it("should reject missing required fields", () => { + const { providerId, ...missing } = validProvider; + expect(() => providerWindowMetricsSchema.parse(missing)).toThrow(); + }); + + it("should reject non-string providerId", () => { + expect(() => providerWindowMetricsSchema.parse({ ...validProvider, providerId: 123 })).toThrow(); + }); + + it("should reject non-boolean manuallyApproved", () => { + expect(() => providerWindowMetricsSchema.parse({ ...validProvider, manuallyApproved: "yes" })).toThrow(); + }); + + it("should reject non-number rates", () => { + expect(() => providerWindowMetricsSchema.parse({ ...validProvider, storageSuccessRate: "high" })).toThrow(); + }); + + it("should reject negative sample counts", () => { + expect(() => providerWindowMetricsSchema.parse({ ...validProvider, storageSamples: -1 })).toThrow(); + }); + + it("should reject non-integer sample counts", () => { + expect(() => providerWindowMetricsSchema.parse({ ...validProvider, storageSamples: 1.5 })).toThrow(); + }); + + it("should accept zero for sample counts", () => { + const result = providerWindowMetricsSchema.parse({ ...validProvider, storageSamples: 0 }); + expect(result.storageSamples).toBe(0); + }); + + it("should accept decimal rates", () => { + const result = providerWindowMetricsSchema.parse({ ...validProvider, storageSuccessRate: 99.999 }); + expect(result.storageSuccessRate).toBe(99.999); + }); +}); + +describe("providerWindowMetricsResponseSchema", () => { + const validResponse = { + data: [validProvider], + meta: { startDate: "2025-01-01T00:00:00Z", endDate: "2025-01-31T00:00:00Z", count: 1 }, + }; + + it("should parse a valid response", () => { + const result = providerWindowMetricsResponseSchema.parse(validResponse); + expect(result.data).toHaveLength(1); + expect(result.meta.count).toBe(1); + }); + + it("should parse a response with empty data array", () => { + const result = providerWindowMetricsResponseSchema.parse({ + data: [], + meta: { startDate: null, endDate: null, count: 0 }, + }); + expect(result.data).toHaveLength(0); + }); + + it("should accept null startDate and endDate", () => { + const result = providerWindowMetricsResponseSchema.parse({ + data: [], + meta: { startDate: null, endDate: null, count: 0 }, + }); + expect(result.meta.startDate).toBeNull(); + expect(result.meta.endDate).toBeNull(); + }); + + it("should reject missing meta", () => { + expect(() => providerWindowMetricsResponseSchema.parse({ data: [] })).toThrow(); + }); + + it("should reject missing data", () => { + expect(() => + providerWindowMetricsResponseSchema.parse({ + meta: { startDate: null, endDate: null, count: 0 }, + }), + ).toThrow(); + }); + + it("should reject negative count", () => { + expect(() => + providerWindowMetricsResponseSchema.parse({ + data: [], + meta: { startDate: null, endDate: null, count: -1 }, + }), + ).toThrow(); + }); + + it("should reject invalid provider in data array", () => { + expect(() => + providerWindowMetricsResponseSchema.parse({ + data: [{ providerId: 123 }], + meta: { startDate: null, endDate: null, count: 1 }, + }), + ).toThrow(); + }); +}); diff --git a/apps/web/src/schamas/providersWindowMetrics.ts b/apps/web/src/schamas/providersWindowMetrics.ts new file mode 100644 index 00000000..237cb276 --- /dev/null +++ b/apps/web/src/schamas/providersWindowMetrics.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const providerWindowMetricsSchema = z.object({ + providerId: z.string(), + manuallyApproved: z.boolean(), + storageSuccessRate: z.number(), + storageSamples: z.number().int().nonnegative(), + dataRetentionFaultRate: z.number(), + dataRetentionSamples: z.number().int().nonnegative(), + retrievalSuccessRate: z.number(), + retrievalSamples: z.number().int().nonnegative(), +}); + +export type ProviderWindowMetrics = z.infer; + +export const providerWindowMetricsResponseSchema = z.object({ + data: z.array(providerWindowMetricsSchema), + meta: z.object({ + startDate: z.string().nullable(), + endDate: z.string().nullable(), + count: z.number().int().nonnegative(), + }), +}); + +export type ProviderWindowMetricsResponse = z.infer; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 1e6e5086..e992a7c2 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -5,6 +5,7 @@ declare module "*.module.css"; interface ImportMeta { readonly env: { readonly VITE_API_BASE_URL: string; + readonly VITE_ENABLE_MOCKING?: string; readonly VITE_PLAUSIBLE_DATA_DOMAIN?: string; }; } diff --git a/apps/web/test/mocks/browser.ts b/apps/web/test/mocks/browser.ts new file mode 100644 index 00000000..bcd82e48 --- /dev/null +++ b/apps/web/test/mocks/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from "msw/browser"; +import { handlers } from "./handlers"; + +export const worker = setupWorker(...handlers); diff --git a/apps/web/test/mocks/data/providers.ts b/apps/web/test/mocks/data/providers.ts new file mode 100644 index 00000000..333e20a8 --- /dev/null +++ b/apps/web/test/mocks/data/providers.ts @@ -0,0 +1,62 @@ +export const mockProviderData = [ + { + providerId: "f01234", + manuallyApproved: true, + storageSuccessRate: 99.5, + storageSamples: 672, + dataRetentionFaultRate: 0.0, + dataRetentionSamples: 672, + retrievalSuccessRate: 98.5, + retrievalSamples: 672, + }, + { + providerId: "f05678", + manuallyApproved: false, + storageSuccessRate: 97.5, + storageSamples: 250, + dataRetentionFaultRate: 0.1, + dataRetentionSamples: 400, + retrievalSuccessRate: 97.0, + retrievalSamples: 250, + }, + { + providerId: "f09012", + manuallyApproved: false, + storageSuccessRate: 94.5, + storageSamples: 672, + dataRetentionFaultRate: 0.0, + dataRetentionSamples: 700, + retrievalSuccessRate: 98.0, + retrievalSamples: 672, + }, + { + providerId: "f03456", + manuallyApproved: false, + storageSuccessRate: 98.0, + storageSamples: 672, + dataRetentionFaultRate: 0.6, + dataRetentionSamples: 672, + retrievalSuccessRate: 97.5, + retrievalSamples: 672, + }, + { + providerId: "f07890", + manuallyApproved: false, + storageSuccessRate: 97.0, + storageSamples: 210, + dataRetentionFaultRate: 0.0, + dataRetentionSamples: 672, + retrievalSuccessRate: 99.0, + retrievalSamples: 210, + }, + { + providerId: "f02468", + manuallyApproved: false, + storageSuccessRate: 89.0, + storageSamples: 672, + dataRetentionFaultRate: 1.2, + dataRetentionSamples: 672, + retrievalSuccessRate: 85.5, + retrievalSamples: 672, + }, +]; diff --git a/apps/web/test/mocks/handlers/example.ts b/apps/web/test/mocks/handlers/example.ts deleted file mode 100644 index 2065c60f..00000000 --- a/apps/web/test/mocks/handlers/example.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { HttpResponse, http } from "msw"; - -export const exampleHandler = http.get("/api/test", () => { - return HttpResponse.json({ - message: "Hello from MSW!", - status: "success", - }); -}); diff --git a/apps/web/test/mocks/handlers/index.ts b/apps/web/test/mocks/handlers/index.ts index 75043b30..4cc094f7 100644 --- a/apps/web/test/mocks/handlers/index.ts +++ b/apps/web/test/mocks/handlers/index.ts @@ -1,6 +1,3 @@ -import { exampleHandler } from "./example"; +import { providersHandler } from "./providers"; -export const handlers = [ - // Mock Metrics handlers would come here - exampleHandler, -]; +export const handlers = [providersHandler]; diff --git a/apps/web/test/mocks/handlers/providers.ts b/apps/web/test/mocks/handlers/providers.ts new file mode 100644 index 00000000..ec80bf6c --- /dev/null +++ b/apps/web/test/mocks/handlers/providers.ts @@ -0,0 +1,23 @@ +import { HttpResponse, http } from "msw"; +import { mockProviderData } from "../data/providers"; +import { wait } from "../utils/wait"; + +export const providersHandler = http.get("/api/providers", async ({ request }) => { + const url = new URL(request.url); + const preset = url.searchParams.get("preset"); + const startDate = url.searchParams.get("startDate"); + const endDate = url.searchParams.get("endDate"); + + // wait for 1 seconds + await wait(1_000); + + return HttpResponse.json({ + data: mockProviderData, + meta: { + preset, + startDate, + endDate, + count: mockProviderData.length, + }, + }); +}); diff --git a/apps/web/test/mocks/utils/wait.ts b/apps/web/test/mocks/utils/wait.ts new file mode 100644 index 00000000..91010d91 --- /dev/null +++ b/apps/web/test/mocks/utils/wait.ts @@ -0,0 +1,7 @@ +/** + * Creates a promise that resolves after a specified amount of time (in milliseconds). + * Useful for testing asynchronous code or creating delays. + * @param ms - The amount of time to wait (in milliseconds). + * @returns A promise that resolves after the specified amount of time. + */ +export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 2f805071..ba22c775 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -30,6 +30,6 @@ "@test/*": ["./test/*"] } }, - "include": ["src"], + "include": ["src", "test/mocks"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"] } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9004a9a0..130323ad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,6 +1,6 @@ { "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.vitest.json"}], "compilerOptions": { "baseUrl": ".", "paths": { diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 0e2dc8d2..4f09ec55 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -16,7 +16,7 @@ This document provides a comprehensive guide to all environment variables used b | [Proxy](#proxy-configuration) | `PROXY_LIST`, `PROXY_LOCATIONS` | | [Timeouts](#timeout-configuration) | `CONNECT_TIMEOUT_MS`, `HTTP_REQUEST_TIMEOUT_MS`, `HTTP2_REQUEST_TIMEOUT_MS`, `RETRIEVAL_TIMEOUT_BUFFER_MS` | | [External Services](#external-services) | `FILBEAM_BOT_TOKEN` | -| [Web Frontend](#web-frontend) | `VITE_API_BASE_URL`, `VITE_PLAUSIBLE_DATA_DOMAIN`, `DEALBOT_API_BASE_URL` | +| [Web Frontend](#web-frontend) | `VITE_API_BASE_URL`, `VITE_ENABLE_MOCKING`, `VITE_PLAUSIBLE_DATA_DOMAIN`, `DEALBOT_API_BASE_URL` | --- @@ -868,6 +868,31 @@ VITE_API_BASE_URL=http://localhost:9000 --- +### `VITE_ENABLE_MOCKING` + +- **Type**: `string` +- **Required**: No +- **Default**: Empty (disabled) +- **Valid values**: `true` to enable, any other value or empty to disable +- **Location**: `apps/web/.env` (dev only) + +**Role**: Enables [Mock Service Worker (MSW)](https://mswjs.io/) to intercept API requests and return mock +responses. When set to `"true"`, the MSW service worker is registered at app startup and all requests +matching handlers in `test/mocks/handlers/` are intercepted; unmatched requests pass through normally. + +**When to update**: + +- Set to `true` when developing frontend features without a running backend +- Leave empty (or remove) when working against a real backend API + +**Example**: + +```bash +VITE_ENABLE_MOCKING=true +``` + +--- + ### `VITE_PLAUSIBLE_DATA_DOMAIN` - **Type**: `string` (domain) @@ -923,7 +948,6 @@ docker run \ --- - ## Environment Files Reference | File | Purpose | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1544a8b0..4d5e98b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,12 @@ importers: '@tailwindcss/vite': specifier: ^4.1.12 version: 4.1.18(vite@7.3.1(@types/node@22.19.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.4) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -252,6 +258,9 @@ importers: tailwindcss: specifier: ^4.1.12 version: 4.1.18 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@biomejs/biome': specifier: 'catalog:' @@ -615,24 +624,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.13': resolution: {integrity: sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.13': resolution: {integrity: sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.13': resolution: {integrity: sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.13': resolution: {integrity: sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==} @@ -2425,56 +2438,67 @@ packages: resolution: {integrity: sha512-UPMMNeC4LXW7ZSHxeP3Edv09aLsFUMaD1TSVW6n1CWMECnUIJMFFB7+XC2lZTdPtvB36tYC0cJWc86mzSsaviw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.4': resolution: {integrity: sha512-H8uwlV0otHs5Q7WAMSoyvjV9DJPiy5nJ/xnHolY0QptLPjaSsuX7tw+SPIfiYH6cnVx3fe4EWFafo6gH6ekZKA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.4': resolution: {integrity: sha512-BLRwSRwICXz0TXkbIbqJ1ibK+/dSBpTJqDClF61GWIrxTXZWQE78ROeIhgl5MjVs4B4gSLPCFeD4xML9vbzvCQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.4': resolution: {integrity: sha512-6bySEjOTbmVcPJAywjpGLckK793A0TJWSbIa0sVwtVGfe/Nz6gOWHOwkshUIAp9j7wg2WKcA4Snu7Y1nUZyQew==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.4': resolution: {integrity: sha512-U0ow3bXYJZ5MIbchVusxEycBw7bO6C2u5UvD31i5IMTrnt2p4Fh4ZbHSdc/31TScIJQYHwxbj05BpevB3201ug==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.4': resolution: {integrity: sha512-iujDk07ZNwGLVn0YIWM80SFN039bHZHCdCCuX9nyx3Jsa2d9V/0Y32F+YadzwbvDxhSeVo9zefkoPnXEImnM5w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.4': resolution: {integrity: sha512-MUtAktiOUSu+AXBpx1fkuG/Bi5rhlorGs3lw5QeJ2X3ziEGAq7vFNdWVde6XGaVqi0LGSvugwjoxSNJfHFTC0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.4': resolution: {integrity: sha512-btm35eAbDfPtcFEgaXCI5l3c2WXyzwiE8pArhd66SDtoLWmgK5/M7CUxmUglkwtniPzwvWioBKKl6IXLbPf2sQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.4': resolution: {integrity: sha512-uJlhKE9ccUTCUlK+HUz/80cVtx2RayadC5ldDrrDUFaJK0SNb8/cCmC9RhBhIWuZ71Nqj4Uoa9+xljKWRogdhA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.4': resolution: {integrity: sha512-jjEMkzvASQBbzzlzf4os7nzSBd/cvPrpqXCUOqoeCh1dQ4BP3RZCJk8XBeik4MUln3m+8LeTJcY54C/u8wb3DQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.4': resolution: {integrity: sha512-lu90KG06NNH19shC5rBPkrh6mrTpq5kviFylPBXQVpdEu0yzb0mDgyxLr6XdcGdBIQTH/UAhDJnL+APZTBu1aQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.4': resolution: {integrity: sha512-dFDcmLwsUzhAm/dn0+dMOQZoONVYBtgik0VuY/d5IJUUb787L3Ko/ibvTvddqhb3RaB7vFEozYevHN4ox22R/w==} @@ -2638,24 +2662,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -2728,24 +2756,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2780,6 +2812,25 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4768,24 +4819,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -9994,6 +10049,21 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@22.19.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0