From 45279250b59c694b0ffb84deb560d20f269e764f Mon Sep 17 00:00:00 2001 From: silent-cipher Date: Mon, 9 Feb 2026 22:21:03 +0530 Subject: [PATCH 1/8] feat(web): add shared date picker component --- .../TimeWindowSelector/WindowContent.tsx | 51 +++++++++++++ .../TimeWindowSelector/WindowTrigger.tsx | 26 +++++++ .../shared/TimeWindowSelector/constants.ts | 15 ++++ .../shared/TimeWindowSelector/index.tsx | 56 ++++++++++++++ .../shared/TimeWindowSelector/types.ts | 12 +++ apps/web/src/components/ui/table.tsx | 73 +++++++++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx create mode 100644 apps/web/src/components/shared/TimeWindowSelector/WindowTrigger.tsx create mode 100644 apps/web/src/components/shared/TimeWindowSelector/constants.ts create mode 100644 apps/web/src/components/shared/TimeWindowSelector/index.tsx create mode 100644 apps/web/src/components/shared/TimeWindowSelector/types.ts create mode 100644 apps/web/src/components/ui/table.tsx 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..af39e922 --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx @@ -0,0 +1,51 @@ +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 { PresetOptions, TimeWindow } from "./types"; + +type WindowContentProps = { + value: TimeWindow; + onDateRangeSelect: OnSelectHandler; + presetOptions: PresetOptions[]; + onPresetSelect: (preset: string, label: string) => void; +}; + +const WindowContent = ({ value, onDateRangeSelect, presetOptions, onPresetSelect }: WindowContentProps) => { + const selectedRange = { + from: value.from, + to: value.to, + }; + + return ( + + + + + + {presetOptions.map((preset) => ( + + ))} + + + ); +}; + +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/constants.ts b/apps/web/src/components/shared/TimeWindowSelector/constants.ts new file mode 100644 index 00000000..3e4295c7 --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/constants.ts @@ -0,0 +1,15 @@ +import type { PresetOptions } from "./types"; + +export const PRESET_OPTIONS: PresetOptions[] = [ + { 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" }, +]; 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..df2ea2b1 --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/index.tsx @@ -0,0 +1,56 @@ +import { format } from "date-fns"; +import { useState } from "react"; +import type { DateRange, OnSelectHandler } from "react-day-picker"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { PRESET_OPTIONS } from "./constants"; +import type { TimeWindow } from "./types"; +import WindowContent from "./WindowContent"; +import WindowTrigger from "./WindowTrigger"; + +interface TimeWindowSelectorProps { + value: TimeWindow; + onChange: (window: TimeWindow) => void; +} + +function TimeWindowSelector({ value, onChange }: TimeWindowSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + + const handlePresetSelect = (preset: string, label: string) => { + onChange({ + type: "preset", + preset, + label, + }); + setIsOpen(false); + }; + + const handleDateRangeSelect: OnSelectHandler = (selected) => { + onChange({ + type: "custom", + from: selected.from, + to: selected.to, + label: selected.from && selected.to ? `${format(selected.from, "MMM d")} - ${format(selected.to, "MMM d")}` : "", + }); + if (selected.from && selected.to) { + setIsOpen(false); + } + }; + + return ( + + + + + + + + + ); +} + +export default TimeWindowSelector; diff --git a/apps/web/src/components/shared/TimeWindowSelector/types.ts b/apps/web/src/components/shared/TimeWindowSelector/types.ts new file mode 100644 index 00000000..a4521598 --- /dev/null +++ b/apps/web/src/components/shared/TimeWindowSelector/types.ts @@ -0,0 +1,12 @@ +export interface TimeWindow { + type: "preset" | "custom"; + preset?: string; + from?: Date; + to?: Date; + label: string; +} + +export interface PresetOptions { + value: string; + label: string; +} 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 }; From b00077b30614b3e74b69e5dc6fff6ef466f34244 Mon Sep 17 00:00:00 2001 From: silent-cipher Date: Fri, 13 Feb 2026 15:32:39 +0530 Subject: [PATCH 2/8] reactor(web): date and preset picker --- apps/web/package.json | 3 +- .../TimeWindowSelector/WindowContent.tsx | 33 +++--- .../shared/TimeWindowSelector/index.tsx | 46 ++------ .../shared/TimeWindowSelector/types.ts | 12 -- .../time-window}/constants.ts | 6 +- apps/web/src/lib/time-window/index.ts | 9 ++ apps/web/src/lib/time-window/types.ts | 11 ++ apps/web/src/lib/time-window/url.ts | 103 ++++++++++++++++++ pnpm-lock.yaml | 3 + 9 files changed, 154 insertions(+), 72 deletions(-) delete mode 100644 apps/web/src/components/shared/TimeWindowSelector/types.ts rename apps/web/src/{components/shared/TimeWindowSelector => lib/time-window}/constants.ts (82%) create mode 100644 apps/web/src/lib/time-window/index.ts create mode 100644 apps/web/src/lib/time-window/types.ts create mode 100644 apps/web/src/lib/time-window/url.ts diff --git a/apps/web/package.json b/apps/web/package.json index fc0f7f53..fd61f87b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,7 +44,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/src/components/shared/TimeWindowSelector/WindowContent.tsx b/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx index af39e922..898b2c29 100644 --- a/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx +++ b/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx @@ -2,45 +2,38 @@ 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 { PresetOptions, TimeWindow } from "./types"; +import type { PresetOption, PresetValue, TimeWindow } from "@/lib/time-window"; type WindowContentProps = { - value: TimeWindow; + timeWindow: TimeWindow; onDateRangeSelect: OnSelectHandler; - presetOptions: PresetOptions[]; - onPresetSelect: (preset: string, label: string) => void; + presetOptions: readonly PresetOption[]; + onPresetSelect: (preset: PresetValue) => void; }; -const WindowContent = ({ value, onDateRangeSelect, presetOptions, onPresetSelect }: WindowContentProps) => { - const selectedRange = { - from: value.from, - to: value.to, - }; - +const WindowContent = ({ timeWindow, onDateRangeSelect, presetOptions, onPresetSelect }: WindowContentProps) => { return ( - + - {presetOptions.map((preset) => ( + {presetOptions.map((presetOption) => ( ))} diff --git a/apps/web/src/components/shared/TimeWindowSelector/index.tsx b/apps/web/src/components/shared/TimeWindowSelector/index.tsx index df2ea2b1..f9f9fba7 100644 --- a/apps/web/src/components/shared/TimeWindowSelector/index.tsx +++ b/apps/web/src/components/shared/TimeWindowSelector/index.tsx @@ -1,52 +1,28 @@ -import { format } from "date-fns"; -import { useState } from "react"; import type { DateRange, OnSelectHandler } from "react-day-picker"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { PRESET_OPTIONS } from "./constants"; -import type { TimeWindow } from "./types"; +import { PRESET_OPTIONS, getTimeWindowLabel } from "@/lib/time-window"; +import type { PresetValue, TimeWindow } from "@/lib/time-window"; import WindowContent from "./WindowContent"; import WindowTrigger from "./WindowTrigger"; interface TimeWindowSelectorProps { - value: TimeWindow; - onChange: (window: TimeWindow) => void; + timeWindow: TimeWindow; + onDateRangeSelect: OnSelectHandler; + onPresetSelect: (preset: PresetValue) => void; } -function TimeWindowSelector({ value, onChange }: TimeWindowSelectorProps) { - const [isOpen, setIsOpen] = useState(false); - - const handlePresetSelect = (preset: string, label: string) => { - onChange({ - type: "preset", - preset, - label, - }); - setIsOpen(false); - }; - - const handleDateRangeSelect: OnSelectHandler = (selected) => { - onChange({ - type: "custom", - from: selected.from, - to: selected.to, - label: selected.from && selected.to ? `${format(selected.from, "MMM d")} - ${format(selected.to, "MMM d")}` : "", - }); - if (selected.from && selected.to) { - setIsOpen(false); - } - }; - +function TimeWindowSelector({ timeWindow, onDateRangeSelect, onPresetSelect }: TimeWindowSelectorProps) { return ( - + - + diff --git a/apps/web/src/components/shared/TimeWindowSelector/types.ts b/apps/web/src/components/shared/TimeWindowSelector/types.ts deleted file mode 100644 index a4521598..00000000 --- a/apps/web/src/components/shared/TimeWindowSelector/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface TimeWindow { - type: "preset" | "custom"; - preset?: string; - from?: Date; - to?: Date; - label: string; -} - -export interface PresetOptions { - value: string; - label: string; -} diff --git a/apps/web/src/components/shared/TimeWindowSelector/constants.ts b/apps/web/src/lib/time-window/constants.ts similarity index 82% rename from apps/web/src/components/shared/TimeWindowSelector/constants.ts rename to apps/web/src/lib/time-window/constants.ts index 3e4295c7..1746bafc 100644 --- a/apps/web/src/components/shared/TimeWindowSelector/constants.ts +++ b/apps/web/src/lib/time-window/constants.ts @@ -1,6 +1,4 @@ -import type { PresetOptions } from "./types"; - -export const PRESET_OPTIONS: PresetOptions[] = [ +export const PRESET_OPTIONS = [ { value: "1h", label: "Last Hour" }, { value: "6h", label: "Last 6 Hours" }, { value: "12h", label: "Last 12 Hours" }, @@ -12,4 +10,4 @@ export const PRESET_OPTIONS: PresetOptions[] = [ { 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..c4794a1e --- /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 { + parseTimeWindowFromURL, + serializeTimeWindowToURL, + getPresetLabel, + formatCustomDateRange, + getTimeWindowLabel, +} 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.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/pnpm-lock.yaml b/pnpm-lock.yaml index 1544a8b0..88f1997f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,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:' From 6b6c718dc1fcfe187f93aecca0e0a9aa9c4a9e2f Mon Sep 17 00:00:00 2001 From: silent-cipher Date: Fri, 13 Feb 2026 15:56:22 +0530 Subject: [PATCH 3/8] feat(web): enable MSW mocking in development mode --- apps/web/.env.example | 4 + apps/web/README.md | 1 + apps/web/public/mockServiceWorker.js | 336 ++++++++++++++++++++++ apps/web/src/main.tsx | 38 ++- apps/web/test/mocks/browser.ts | 4 + apps/web/test/mocks/data/providers.ts | 62 ++++ apps/web/test/mocks/handlers/example.ts | 8 - apps/web/test/mocks/handlers/index.ts | 7 +- apps/web/test/mocks/handlers/providers.ts | 23 ++ apps/web/test/mocks/utils/wait.ts | 7 + apps/web/tsconfig.app.json | 2 +- docs/environment-variables.md | 28 +- 12 files changed, 492 insertions(+), 28 deletions(-) create mode 100644 apps/web/public/mockServiceWorker.js create mode 100644 apps/web/test/mocks/browser.ts create mode 100644 apps/web/test/mocks/data/providers.ts delete mode 100644 apps/web/test/mocks/handlers/example.ts create mode 100644 apps/web/test/mocks/handlers/providers.ts create mode 100644 apps/web/test/mocks/utils/wait.ts 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/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/main.tsx b/apps/web/src/main.tsx index 34881289..3efe2f93 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,19 +1,33 @@ 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"; -const container = document.getElementById("root"); -if (container) { - createRoot(container).render( - - - - - - - , - ); +async function enableMocking() { + if (import.meta.env.VITE_ENABLE_MOCKING !== "true") { + return; + } + + 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/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/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 | From 159fa0553f732381a0a313ad63a2717890d147e0 Mon Sep 17 00:00:00 2001 From: silent-cipher Date: Fri, 13 Feb 2026 15:59:32 +0530 Subject: [PATCH 4/8] chore: lint --- .../shared/TimeWindowSelector/index.tsx | 2 +- apps/web/src/components/ui/calendar.tsx | 50 ++++++++++++------- apps/web/src/lib/time-window/index.ts | 6 +-- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/shared/TimeWindowSelector/index.tsx b/apps/web/src/components/shared/TimeWindowSelector/index.tsx index f9f9fba7..53bb8fb7 100644 --- a/apps/web/src/components/shared/TimeWindowSelector/index.tsx +++ b/apps/web/src/components/shared/TimeWindowSelector/index.tsx @@ -1,7 +1,7 @@ import type { DateRange, OnSelectHandler } from "react-day-picker"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { PRESET_OPTIONS, getTimeWindowLabel } from "@/lib/time-window"; import type { PresetValue, TimeWindow } from "@/lib/time-window"; +import { getTimeWindowLabel, PRESET_OPTIONS } from "@/lib/time-window"; import WindowContent from "./WindowContent"; import WindowTrigger from "./WindowTrigger"; 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/lib/time-window/index.ts b/apps/web/src/lib/time-window/index.ts index c4794a1e..ecac7baa 100644 --- a/apps/web/src/lib/time-window/index.ts +++ b/apps/web/src/lib/time-window/index.ts @@ -1,9 +1,9 @@ export { PRESET_OPTIONS } from "./constants"; export type { PresetOption, PresetValue, TimeWindow } from "./types"; export { - parseTimeWindowFromURL, - serializeTimeWindowToURL, - getPresetLabel, formatCustomDateRange, + getPresetLabel, getTimeWindowLabel, + parseTimeWindowFromURL, + serializeTimeWindowToURL, } from "./url"; From 8c0a825df870efba236d4ec20dbfed2cca3b4a0a Mon Sep 17 00:00:00 2001 From: silent-cipher Date: Fri, 13 Feb 2026 16:42:40 +0530 Subject: [PATCH 5/8] feat(web): add providers table --- apps/web/package.json | 2 + apps/web/src/api/client.ts | 2 +- .../components/ApprovalBadge.tsx | 11 +++ .../ProvidersTable/components/TableCells.tsx | 41 ++++++++ .../components/TableHeaders.tsx | 70 +++++++++++++ .../ProvidersTable/components/TableRow.tsx | 22 +++++ .../ProvidersTable/components/index.ts | 11 +++ .../data/column-definitions.tsx | 99 +++++++++++++++++++ .../NewLanding/ProvidersTable/index.tsx | 87 ++++++++++++++++ .../NewLanding/ProvidersTable/types.ts | 10 ++ .../utils/acceptance-criteria.ts | 27 +++++ .../TimeWindowSelector/WindowContent.tsx | 10 +- apps/web/src/hooks/useProvidersQuery.ts | 49 +++++++++ apps/web/src/lib/query-client.ts | 12 +++ apps/web/src/main.tsx | 14 ++- apps/web/src/pages/NewLanding.tsx | 75 +++++++++++++- apps/web/src/vite-env.d.ts | 1 + pnpm-lock.yaml | 67 +++++++++++++ 18 files changed, 599 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.tsx create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/components/TableCells.tsx create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/components/TableHeaders.tsx create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/components/TableRow.tsx create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/components/index.ts create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/data/column-definitions.tsx create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/index.tsx create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/types.ts create mode 100644 apps/web/src/components/NewLanding/ProvidersTable/utils/acceptance-criteria.ts create mode 100644 apps/web/src/hooks/useProvidersQuery.ts create mode 100644 apps/web/src/lib/query-client.ts diff --git a/apps/web/package.json b/apps/web/package.json index fd61f87b..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", 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.tsx b/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.tsx new file mode 100644 index 00000000..b3a6ebfc --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/components/ApprovalBadge.tsx @@ -0,0 +1,11 @@ +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.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..21f288c4 --- /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 { ProviderData } from "../types"; + +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..6878580d --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/data/column-definitions.tsx @@ -0,0 +1,99 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { + ApprovalBadge, + DataRetentionFaultRateHeader, + DataRetentionSamplesHeader, + FaultRateCell, + RetrievalSamplesHeader, + RetrievalSuccessRateHeader, + SamplesCell, + StorageSamplesHeader, + StorageSuccessRateHeader, + SuccessRateCell, +} from "../components"; +import type { ProviderData } from "../types"; +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.tsx b/apps/web/src/components/NewLanding/ProvidersTable/index.tsx new file mode 100644 index 00000000..4a952d3b --- /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 { ProviderTableRow } from "./components"; +import { columns } from "./data/column-definitions"; +import type { ProviderData } from "./types"; + +const EmptyState = ( + + + No results. + + +); + +const LoadingState = ( + + + Loading providers... + + +); + +const ErrorState = ({ message }: { message: string }) => ( + + + Error: {message} + + +); + +interface ProvidersTableProps { + data?: ProviderData[]; + isLoading?: boolean; + error?: Error | null; +} + +const EMPTY_DATA: ProviderData[] = []; + +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/types.ts b/apps/web/src/components/NewLanding/ProvidersTable/types.ts new file mode 100644 index 00000000..305c8b74 --- /dev/null +++ b/apps/web/src/components/NewLanding/ProvidersTable/types.ts @@ -0,0 +1,10 @@ +export interface ProviderData { + providerId: string; + manuallyApproved: boolean; + storageSuccessRate: number; + storageSamples: number; + dataRetentionFaultRate: number; + dataRetentionSamples: number; + retrievalSuccessRate: number; + retrievalSamples: number; +} 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 index 898b2c29..3877471b 100644 --- a/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx +++ b/apps/web/src/components/shared/TimeWindowSelector/WindowContent.tsx @@ -1,9 +1,13 @@ +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; @@ -13,7 +17,7 @@ type WindowContentProps = { const WindowContent = ({ timeWindow, onDateRangeSelect, presetOptions, onPresetSelect }: WindowContentProps) => { return ( - + - + {presetOptions.map((presetOption) => (