diff --git a/apps/web/package.json b/apps/web/package.json index b30b869c..51b31eaa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,6 +61,7 @@ "@tiptap/pm": "2.0.4", "@tiptap/react": "2.0.4", "@tiptap/starter-kit": "2.0.4", + "@tremor/react": "^3.11.0", "@trpc/client": "^10.19.1", "@trpc/next": "^10.19.1", "@trpc/react-query": "^10.19.1", diff --git a/apps/web/prisma/migrations/20231112171928_add_goals_to_tests/migration.sql b/apps/web/prisma/migrations/20231112171928_add_goals_to_tests/migration.sql new file mode 100644 index 00000000..7cbc72ef --- /dev/null +++ b/apps/web/prisma/migrations/20231112171928_add_goals_to_tests/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE `Event` ADD COLUMN `testGoalId` VARCHAR(191) NULL, + ADD COLUMN `testVersion` INTEGER NOT NULL DEFAULT 1; + +-- AlterTable +ALTER TABLE `Test` ADD COLUMN `version` INTEGER NOT NULL DEFAULT 1; + +-- CreateTable +CREATE TABLE `TestGoal` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `testId` VARCHAR(191) NOT NULL, + + INDEX `TestGoal_testId_idx`(`testId`), + UNIQUE INDEX `TestGoal_testId_name_key`(`testId`, `name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `Event_testGoalId_idx` ON `Event`(`testGoalId`); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 46dd4d7d..6f1f1799 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -147,16 +147,31 @@ model Test { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - name String - options Option[] - events Event[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + options Option[] + conversions TestConversion[] + goals TestGoal[] + + version Int @default(1) @@unique([projectId, name]) @@index([projectId]) } +model TestGoal { + id String @id @default(cuid()) + name String + + testId String + test Test @relation(fields: [testId], references: [id], onDelete: Cascade) + TestConversion TestConversion[] + + @@unique([testId, name]) + @@index([testId]) +} + model Option { id String @id @default(cuid()) identifier String @@ -168,7 +183,7 @@ model Option { @@unique([testId, identifier]) } -model Event { +model TestConversion { id String @id @default(cuid()) test Test @relation(fields: [testId], references: [id], onDelete: Cascade) @@ -178,9 +193,16 @@ model Event { selectedVariant String createdAt DateTime @default(now()) + testVersion Int @default(1) + + testGoalId String? + testGoal TestGoal? @relation(fields: [testGoalId], references: [id], onDelete: Cascade) + @@index([testId]) @@index([type]) @@index([selectedVariant]) + @@index([testGoalId]) + @@map("Event") } model FeatureFlag { diff --git a/apps/web/prisma/seedEvents.ts b/apps/web/prisma/seedEvents.ts index dd6f1449..30dbeaa9 100644 --- a/apps/web/prisma/seedEvents.ts +++ b/apps/web/prisma/seedEvents.ts @@ -1,8 +1,23 @@ import { PrismaClient, Prisma } from "@prisma/client"; import { AbbyEventType } from "@tryabby/core"; +import crypto from "crypto"; const prisma = new PrismaClient(); +function trueRandomNumber(min: number, max: number) { + const cryptoArray = new Uint32Array(1); + crypto.getRandomValues(cryptoArray); + const randomValue = cryptoArray![0]! / (0xffffffff + 1); + return Math.floor(min + randomValue * (max - min + 1)); +} + +function randomDate(start: Date, end: Date) { + const startTime = start.getTime(); + const endTime = end.getTime(); + const randomTime = trueRandomNumber(startTime, endTime); + return new Date(randomTime); +} + async function main() { const user = await prisma.user.findFirst({ where: {}, @@ -42,6 +57,7 @@ async function main() { }); await prisma.option.createMany({ + skipDuplicates: true, data: [ { identifier: "oldFooter", @@ -71,9 +87,9 @@ async function main() { ], }); - await prisma.event.createMany({ + await prisma.testConversion.createMany({ data: [ - ...Array.from({ + ...Array.from({ length: Math.floor(Math.random() * 200), }).map( () => @@ -81,9 +97,10 @@ async function main() { selectedVariant: "oldFooter", testId: footerTest.id, type: AbbyEventType.PING, - } as Prisma.EventCreateManyInput) + createdAt: randomDate(new Date(2021, 0, 1), new Date()), + } as Prisma.TestConversionCreateManyInput) ), - ...Array.from({ + ...Array.from({ length: Math.floor(Math.random() * 200), }).map( () => @@ -91,9 +108,10 @@ async function main() { selectedVariant: "newFooter", testId: footerTest.id, type: AbbyEventType.PING, - } as Prisma.EventCreateManyInput) + createdAt: randomDate(new Date(2021, 0, 1), new Date()), + } as Prisma.TestConversionCreateManyInput) ), - ...Array.from({ + ...Array.from({ length: Math.floor(Math.random() * 200), }).map( () => @@ -101,9 +119,10 @@ async function main() { selectedVariant: "oldFooter", testId: footerTest.id, type: AbbyEventType.ACT, - } as Prisma.EventCreateManyInput) + createdAt: randomDate(new Date(2021, 0, 1), new Date()), + } as Prisma.TestConversionCreateManyInput) ), - ...Array.from({ + ...Array.from({ length: Math.floor(Math.random() * 200), }).map( () => @@ -111,7 +130,8 @@ async function main() { selectedVariant: "newFooter", testId: footerTest.id, type: AbbyEventType.ACT, - } as Prisma.EventCreateManyInput) + createdAt: randomDate(new Date(2021, 0, 1), new Date()), + } as Prisma.TestConversionCreateManyInput) ), ], }); diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index 8d2f46d9..ed8eef0b 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -23,10 +23,10 @@ export function Footer() {

Integrations

- React - Next.js - Svelte - Angular + React + Next.js + Svelte + Angular

Links

diff --git a/apps/web/src/components/Integrations.tsx b/apps/web/src/components/Integrations.tsx index 2b197a58..79ccb929 100644 --- a/apps/web/src/components/Integrations.tsx +++ b/apps/web/src/components/Integrations.tsx @@ -5,36 +5,55 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { SiAngular, SiNextdotjs, SiReact, SiSvelte } from "react-icons/si"; -const INTEGRATIONS = [ +export const INTEGRATIONS = [ { name: "Next.js", logo: , docsUrlSlug: "nextjs", logoFill: "#fff", + description: "Feature Flags, Remote Config, and A/B Testing for Next.js", + npmPackage: "next", + additionalFeatures: [ + "Server Side Rendering", + "Incremental Static Regeneration", + "Easy to use Hooks", + ], }, { name: "React", logo: , docsUrlSlug: "react", logoFill: "#61DAFB", + description: "Feature Flags, Remote Config, and A/B Testing for React", + npmPackage: "react", + additionalFeatures: ["Easy to use Hooks"], }, { name: "Svelte", logo: , docsUrlSlug: "svelte", logoFill: "#FF3E00", + description: + "Feature Flags, Remote Config, and A/B Testing for Svelte & Sveltekit", + npmPackage: "svelte", + additionalFeatures: ["Sveltekit Support"], }, { name: "Angular", logo: , docsUrlSlug: "angular", logoFill: "#DD0031", + description: "Feature Flags, Remote Config, and A/B Testing for Angular", + npmPackage: "angular", }, ] satisfies Array<{ name: string; logo: React.ReactNode; logoFill: string; docsUrlSlug: string; + description: string; + npmPackage: string; + additionalFeatures?: Array; }>; export const Integrations = () => { diff --git a/apps/web/src/components/Navbar/index.tsx b/apps/web/src/components/Navbar/index.tsx index 5b5562fb..0489a9b0 100644 --- a/apps/web/src/components/Navbar/index.tsx +++ b/apps/web/src/components/Navbar/index.tsx @@ -52,6 +52,11 @@ const NAV_ITEMS: Array = [ subTitle: "Painless Debugging", href: "/devtools", }, + { + title: "Integrations", + subTitle: "SDKs for your favorite frameworks", + href: "/integrations", + }, { title: "Documentation", subTitle: "Developers API Reference", diff --git a/apps/web/src/components/Test/Metrics.tsx b/apps/web/src/components/Test/Metrics.tsx index e2421cb2..1ba1844c 100644 --- a/apps/web/src/components/Test/Metrics.tsx +++ b/apps/web/src/components/Test/Metrics.tsx @@ -1,4 +1,5 @@ -import { Prisma, Event } from "@prisma/client"; +import { TestConversion } from "@prisma/client"; +import { DonutChart, Text } from "@tremor/react"; import { Chart as ChartJS, BarElement, @@ -45,46 +46,51 @@ const Metrics = ({ pingEvents, options, }: { - pingEvents: Event[]; + pingEvents: TestConversion[]; options: ClientOption[]; }) => { - const labels = options.map((option) => option.identifier); const actualData = useMemo(() => { return options.map((option) => { return { - pings: pingEvents.filter( + name: option.identifier, + count: pingEvents.filter( (event) => event.selectedVariant === option.identifier ).length, - weight: option.chance, }; }); }, [options, pingEvents]); const absPings = actualData.reduce((accumulator, value) => { - return accumulator + value.pings; + return accumulator + value.count; }, 0); + const expectedData = useMemo(() => { + return options.map((option) => ({ + name: option.identifier, + count: absPings * option.chance, + })); + }, [absPings, options]); + return ( -
- d.pings), - backgroundColor: "#A9E4EF", - }, - { - label: "Expected", - data: actualData.map((data) => absPings * data.weight), - backgroundColor: "#f472b6", - }, - ], - }} - /> +
+
+ Conversions + +
+
+ Expected + +
); }; diff --git a/apps/web/src/components/Test/Section.tsx b/apps/web/src/components/Test/Section.tsx index 5b435185..085657b4 100644 --- a/apps/web/src/components/Test/Section.tsx +++ b/apps/web/src/components/Test/Section.tsx @@ -1,4 +1,4 @@ -import { Event, Test } from "@prisma/client"; +import { TestConversion, Test } from "@prisma/client"; import { ReactNode, useId, useState } from "react"; import { AbbyEventType } from "@tryabby/core"; import { Serves } from "./Serves"; @@ -7,7 +7,7 @@ import Weights from "./Weights"; import type { ClientOption } from "server/trpc/router/project"; import { BiInfoCircle } from "react-icons/bi"; import * as Popover from "@radix-ui/react-popover"; -import { AiOutlineDelete } from "react-icons/ai"; + import { trpc } from "utils/trpc"; import { toast } from "react-hot-toast"; import { Button } from "components/ui/button"; @@ -17,6 +17,14 @@ import { useFeatureFlag } from "lib/abby"; import { TitleEdit } from "components/TitleEdit"; import { Modal } from "components/Modal"; import { cn } from "lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu"; +import { BsThreeDotsVertical } from "react-icons/bs"; +import { EditIcon, TrashIcon } from "lucide-react"; function getBestVariant({ absPings, @@ -132,7 +140,7 @@ const Section = ({ id, }: Test & { options: ClientOption[]; - events: Event[]; + events: TestConversion[]; }) => { const router = useRouter(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -161,16 +169,24 @@ const Section = ({ title={name} onSave={(newName) => updateTestName({ name: newName, testId: id })} /> - + + + + + + {}}> + + Edit Name + + {}} + > + + Delete Test + + + setIsDeleteModalOpen(false)} @@ -209,10 +225,10 @@ const Section = ({ /> - An interaction is triggered when the + A conversion is triggered when the onAct diff --git a/apps/web/src/components/Test/Serves.tsx b/apps/web/src/components/Test/Serves.tsx index 8934cf37..a1d87e0d 100644 --- a/apps/web/src/components/Test/Serves.tsx +++ b/apps/web/src/components/Test/Serves.tsx @@ -1,107 +1,59 @@ -import { Event } from "@prisma/client"; -import { - Chart as ChartJS, - BarElement, - CategoryScale, - Legend, - LinearScale, - Title, - Tooltip, - ChartOptions, -} from "chart.js"; +import { TestConversion } from "@prisma/client"; +import { Card, DeltaBar, DonutChart, Text, Title } from "@tremor/react"; + import { useMemo } from "react"; import { Bar } from "react-chartjs-2"; import type { ClientOption } from "server/trpc/router/project"; -ChartJS.defaults.font.family = "Mona Sans"; -ChartJS.defaults.color = "white"; - -ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend -); - -export const OPTIONS: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - min: 0, - max: 100, - }, - }, - plugins: { - legend: { - position: "top" as const, - }, - tooltip: { - callbacks: { - label: function (context) { - let label = context.dataset.label || ""; - - if (label) { - label += ": "; - } - if (context.parsed.y !== null) { - label += context.parsed.y; - } - return `${label}%`; - }, - }, - }, - }, -}; - const Serves = ({ pingEvents, options, }: { - pingEvents: Event[]; + pingEvents: TestConversion[]; options: ClientOption[]; }) => { - const labels = options.map((option) => option.identifier); - const actualData = useMemo(() => { return options.map((option) => { - return pingEvents.filter( - (event) => event.selectedVariant === option.identifier - ).length; + return { + name: option.identifier, + count: pingEvents.filter( + (event) => event.selectedVariant === option.identifier + ).length, + }; }); }, [options, pingEvents]); const absPings = actualData.reduce((accumulator, value) => { - return accumulator + value; + return accumulator + value.count; }, 0); + const expectedData = useMemo(() => { + return options.map((option) => ({ + name: option.identifier, + count: absPings * option.chance, + })); + }, [absPings, options]); + return ( -
- parseFloat(option.chance.toString()) * 100 - ), - backgroundColor: "#A9E4EF", - }, - { - label: "Actual", - data: actualData.map((data) => - Math.round((data / absPings) * 100) - ), - backgroundColor: "#f472b6", - }, - ], - }} - /> +
+
+ Views + +
+
+ Expected + +
); }; diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 48e7b4a2..685e96e9 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -14,7 +14,6 @@ import { useRouter } from "next/router"; import { TooltipProvider } from "components/Tooltip"; import "@fontsource/martian-mono/600.css"; -import "../styles/globals.css"; import "../styles/shadcn.css"; import "@code-hike/mdx/dist/index.css"; import PlausibleProvider from "next-plausible"; diff --git a/apps/web/src/pages/integrations/[integration]/index.tsx b/apps/web/src/pages/integrations/[integration]/index.tsx new file mode 100644 index 00000000..5f2050ce --- /dev/null +++ b/apps/web/src/pages/integrations/[integration]/index.tsx @@ -0,0 +1,127 @@ +import { DOCS_URL } from "@tryabby/core"; +import { INTEGRATIONS } from "components/Integrations"; +import { MarketingLayout } from "components/MarketingLayout"; +import { SignupButton } from "components/SignupButton"; +import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next"; +import { NextSeo } from "next-seo"; +import Link from "next/link"; +import { NextPageWithLayout } from "pages/_app"; + +const IntegrationPage: NextPageWithLayout< + InferGetStaticPropsType +> = (props) => { + const integration = INTEGRATIONS.find( + (i) => i.docsUrlSlug === props.integrationSlug + ); + + if (!integration) { + return null; + } + + return ( + <> + +
+
+
+

+ Abby {integration.name} SDK +

+

+ Statically typed Feature Flags, Remote Config & A/B testing for{" "} + + {integration.name} + +

+
+
+
+
+ {integration.logo} +
+

SDK Features

+
    +
  • Feature Flags
  • +
  • Remote Config
  • +
  • A/B Testing
  • +
  • Full Typescript Support
  • +
  • + Fully typed without a build step +
  • +
  • Devtools for managing flags, configs, and experiments
  • + {integration.additionalFeatures?.map((feature) => ( +
  • {feature}
  • + ))} +
+
+
+

Installation

+ + npm install{" "} + + @tryabby/{integration.npmPackage} + + +
+ + SDK Docs + +
+
+
+
+

About Abby

+

+ Abby is an open-source Feature Flagging, Remote Config, and A/B + Testing service. Abby' {integration.name} SDK is fully typed + and open source. +
+ Abby is the easiest way to manage your features and experiments + for Developers and Product Managers. Start now with our forever + free plan. +

+
+ +
+
+ + ); +}; + +IntegrationPage.getLayout = (page) => { + return {page}; +}; + +export default IntegrationPage; + +export const getStaticProps = ((ctx) => { + const integrationSlug = ctx.params?.integration; + + if (typeof integrationSlug !== "string") { + return { + notFound: true, + }; + } + return { + props: { + integrationSlug, + }, + }; +}) satisfies GetStaticProps; + +export const getStaticPaths: GetStaticPaths = () => { + return { + paths: INTEGRATIONS.map((i) => ({ + params: { + integration: i.docsUrlSlug, + }, + })), + fallback: false, + }; +}; diff --git a/apps/web/src/pages/integrations/index.tsx b/apps/web/src/pages/integrations/index.tsx new file mode 100644 index 00000000..cfae0565 --- /dev/null +++ b/apps/web/src/pages/integrations/index.tsx @@ -0,0 +1,47 @@ +import { MarketingLayout } from "components/MarketingLayout"; +import { NextPageWithLayout } from "../_app"; +import { INTEGRATIONS } from "components/Integrations"; +import { Button } from "components/ui/button"; +import Link from "next/link"; + +const IntegrationsMainPage: NextPageWithLayout = () => { + return ( +
+
+

+ Our Integrations and SDKs +

+

+ We provide a wide range of integrations and SDKs to help you get the + most out of our service. +

+
+
+ {INTEGRATIONS.map((integration, index) => ( +
+
+ {integration.logo} +
+

{integration.name}

+

+ {integration.description} +

+ + + +
+ ))} +
+
+ ); +}; + +IntegrationsMainPage.getLayout = (page) => { + return ( + + {page} + + ); +}; + +export default IntegrationsMainPage; diff --git a/apps/web/src/pages/projects/[projectId]/index.tsx b/apps/web/src/pages/projects/[projectId]/index.tsx index fe5c2819..b7dc0f0a 100644 --- a/apps/web/src/pages/projects/[projectId]/index.tsx +++ b/apps/web/src/pages/projects/[projectId]/index.tsx @@ -59,7 +59,7 @@ const Projects: NextPageWithLayout = () => {
{data?.project?.tests.map((test) => ( -
+
))}
diff --git a/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx b/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx index 03b8fcca..356afdac 100644 --- a/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx +++ b/apps/web/src/pages/projects/[projectId]/tests/[testId].tsx @@ -1,7 +1,6 @@ import { DashboardHeader } from "components/DashboardHeader"; import { Layout } from "components/Layout"; import { LoadingSpinner } from "components/LoadingSpinner"; -import { Select, SelectItem } from "components/Select"; import { getFormattingByInterval, getLabelsByInterval, @@ -18,55 +17,31 @@ import { BiArrowBack } from "react-icons/bi"; import { trpc } from "utils/trpc"; import { groupBy, minBy } from "lodash-es"; import dayjs from "dayjs"; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend, - Filler, - ChartOptions, -} from "chart.js"; + import colors from "tailwindcss/colors"; import { useMemo } from "react"; import { getColorByIndex } from "lib/graphs"; import { AbbyEventType } from "@tryabby/core"; import { Button } from "components/ui/button"; +import { + AreaChart, + Card, + Divider, + Metric, + Text, + ValueFormatter, + Title, +} from "@tremor/react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/ui/select"; const INTERVAL_PARAM_NAME = "interval"; -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend, - Filler -); - -ChartJS.defaults.color = "white"; -ChartJS.defaults.font.family = "'Fragment Mono', monospace"; -ChartJS.defaults.borderColor = `${colors.gray[800]}33`; - -const CHART_OPTIONS: ChartOptions<"line"> = { - responsive: true, - scales: { - y: { - min: 0, - }, - }, - plugins: { - legend: { - position: "top" as const, - }, - }, -}; - const getChartOptions = (index: number, variant: string) => { const color = getColorByIndex(index); return { @@ -77,6 +52,28 @@ const getChartOptions = (index: number, variant: string) => { }; }; +const data = [ + { + Month: "Jan 21", + "Gross Volume": 2890, + "Successful Payments": 2400, + Customers: 4938, + }, + { + Month: "Feb 21", + "Gross Volume": 1890, + "Successful Payments": 1398, + Customers: 2938, + }, + // ... + { + Month: "Jan 22", + "Gross Volume": 3890, + "Successful Payments": 2980, + Customers: 2645, + }, +]; + const TestDetailPage: NextPageWithLayout = () => { const router = useRouter(); const projectId = useProjectId(); @@ -88,20 +85,7 @@ const TestDetailPage: NextPageWithLayout = () => { ? intervalParam : INTERVALS[1].value; - const { - data: test, - isLoading: isTestLoading, - isError: isTestError, - } = trpc.tests.getById.useQuery( - { - testId, - }, - { - enabled: !!testId, - } - ); - - const { data: events } = trpc.events.getEventsByTestId.useQuery( + const { data, isLoading, isError } = trpc.events.getEventsByTestId.useQuery( { testId, interval, @@ -111,71 +95,65 @@ const TestDetailPage: NextPageWithLayout = () => { } ); - const eventsByVariant = useMemo(() => { - const eventsByVariant = groupBy(events, (e) => e.selectedVariant); - // make sure all variants are present - test?.options.map((option) => { - eventsByVariant[option.identifier] ??= []; - }); - return eventsByVariant; - }, [events, test?.options]); - - const labels = getLabelsByInterval( - interval, - minBy(events, "createdAt")?.createdAt! - ); - - const formattedEvents = useMemo(() => { - return Object.entries(eventsByVariant).map(([variant, events], i) => { - const eventsByDate = groupBy(events, (e) => { - const date = dayjs(e.createdAt); - // round by 3 hours - const hour = Math.floor(date.hour() / 3) * 3; - - return date - .set("hour", hour) - .set("minute", 0) - .format(getFormattingByInterval(interval)); - }); - return { eventsByDate, variant }; - }); - }, [eventsByVariant, interval]); - const viewEvents = useMemo( - () => ({ - labels, - datasets: formattedEvents.map(({ eventsByDate, variant }, i) => { - return { - data: labels.map( - (label) => - eventsByDate[label]?.filter((e) => e.type === AbbyEventType.PING) - ?.length ?? 0 - ), - ...getChartOptions(i, variant), - }; - }), - }), - [formattedEvents, labels] + () => data?.events?.filter((e) => e.type === AbbyEventType.PING), + [data?.events] ); const actEvents = useMemo( - () => ({ - labels, - datasets: formattedEvents.map(({ eventsByDate, variant }, i) => { - return { - data: labels.map( - (label) => - eventsByDate[label]?.filter((e) => e.type === AbbyEventType.ACT) - ?.length ?? 0 - ), - ...getChartOptions(i, variant), - }; - }), - }), - [formattedEvents, labels] + () => data?.events?.filter((e) => e.type === AbbyEventType.ACT), + [data?.events] ); - if (isTestLoading || isTestError) { + const viewEventsByDay = useMemo(() => { + const eventsByDay = groupBy(viewEvents, (e) => { + const date = dayjs(e.createdAt); + // round by 3 hours + const hour = Math.floor(date.hour() / 3) * 3; + + return date + .set("hour", hour) + .set("minute", 0) + .format(getFormattingByInterval(interval)); + }); + return Object.entries(eventsByDay).map(([day, events]) => { + return { + day, + ...data?.variants.reduce>((acc, variant) => { + acc[variant] = events.filter( + (e) => e.selectedVariant === variant + ).length; + return acc; + }, {}), + }; + }); + }, [data?.variants, interval, viewEvents]); + + const actEventsByDay = useMemo(() => { + const eventsByDay = groupBy(actEvents, (e) => { + const date = dayjs(e.createdAt); + // round by 3 hours + const hour = Math.floor(date.hour() / 3) * 3; + + return date + .set("hour", hour) + .set("minute", 0) + .format(getFormattingByInterval(interval)); + }); + return Object.entries(eventsByDay).map(([day, events]) => { + return { + day, + ...data?.variants.reduce>((acc, variant) => { + acc[variant] = events.filter( + (e) => e.selectedVariant === variant + ).length; + return acc; + }, {}), + }; + }); + }, [data?.variants, interval, actEvents]); + + if (isLoading || isError) { return ; } @@ -188,13 +166,12 @@ const TestDetailPage: NextPageWithLayout = () => {

- {test.name} + {data.testName}

- {events?.length === 0 ? ( + {data.events == null || data.events.length === 0 ? (

No events yet :(

) : ( -
-
-

Views

- -
-
-
-

Interactions

- -
-
+ + Views + Amount of times your A/B Test has been viewed + {}} + connectNulls + /> + + + + Conversions + Conversions are your predefined goals + {}} + connectNulls + /> + + + )}
); diff --git a/apps/web/src/server/services/EventService.ts b/apps/web/src/server/services/EventService.ts index e3e4c34d..6151dc73 100644 --- a/apps/web/src/server/services/EventService.ts +++ b/apps/web/src/server/services/EventService.ts @@ -17,7 +17,18 @@ export abstract class EventService { testName, type, }: AbbyEvent) { - return prisma.event.create({ + const currentTestVersion = await prisma.test.findUnique({ + where: { + projectId_name: { + projectId, + name: testName, + }, + }, + select: { + version: true, + }, + }); + return prisma.testConversion.create({ data: { selectedVariant, type, @@ -29,28 +40,28 @@ export abstract class EventService { }, }, }, + testVersion: currentTestVersion?.version, }, }); } - static async getEventsByProjectId(projectId: string) { - return prisma.event.findMany({ - where: { - test: { - projectId, - }, - }, - }); - } - - static async getEventsByTestId(testId: string, timeInterval: string) { + static async getEventsByTestId({ + testId, + timeInterval, + testVersion = 1, + }: { + testId: string; + timeInterval: string; + testVersion?: number; + }) { const now = new Date().getTime(); if (isSpecialTimeInterval(timeInterval)) { const specialIntervalInMs = getMSFromSpecialTimeInterval(timeInterval); - return prisma.event.findMany({ + return prisma.testConversion.findMany({ where: { testId, + testVersion, ...(specialIntervalInMs !== Infinity && timeInterval !== SpecialTimeInterval.DAY && { createdAt: { @@ -64,6 +75,9 @@ export abstract class EventService { }, }), }, + orderBy: { + createdAt: "asc", + }, }); } @@ -73,13 +87,16 @@ export abstract class EventService { throw new Error("Invalid time interval"); } - return prisma.event.findMany({ + return prisma.testConversion.findMany({ where: { testId, createdAt: { gte: new Date(now - ms(timeInterval)), }, }, + orderBy: { + createdAt: "asc", + }, }); } diff --git a/apps/web/src/server/services/InviteService.ts b/apps/web/src/server/services/InviteService.ts index e2c3575b..f10cef84 100644 --- a/apps/web/src/server/services/InviteService.ts +++ b/apps/web/src/server/services/InviteService.ts @@ -1,5 +1,4 @@ import { prisma } from "server/db/client"; -import { AbbyEvent } from "@tryabby/core"; export abstract class InviteService { static async acceptInvite(inviteId: string, userId: string) { @@ -40,14 +39,4 @@ export abstract class InviteService { }, }); } - - static async getEventsByProjectId(projectId: string) { - return prisma.event.findMany({ - where: { - test: { - projectId, - }, - }, - }); - } } diff --git a/apps/web/src/server/trpc/router/events.ts b/apps/web/src/server/trpc/router/events.ts index 991c601d..72b04307 100644 --- a/apps/web/src/server/trpc/router/events.ts +++ b/apps/web/src/server/trpc/router/events.ts @@ -5,20 +5,6 @@ import { z } from "zod"; import { protectedProcedure, router } from "../trpc"; export const eventRouter = router({ - getEvents: protectedProcedure - .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - const hasAccess = await ProjectService.hasProjectAccess( - input.projectId, - ctx.session.user.id - ); - - if (!hasAccess) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return EventService.getEventsByProjectId(input.projectId); - }), getEventsByTestId: protectedProcedure .input( z.object({ @@ -27,7 +13,7 @@ export const eventRouter = router({ }) ) .query(async ({ ctx, input }) => { - const currentTest = await ctx.prisma.test.count({ + const currentTest = await ctx.prisma.test.findFirst({ where: { id: input.testId, project: { @@ -38,17 +24,23 @@ export const eventRouter = router({ }, }, }, + include: { options: true }, }); if (!currentTest) { throw new TRPCError({ code: "UNAUTHORIZED" }); } - const tests = await EventService.getEventsByTestId( - input.testId, - input.interval - ); + const events = await EventService.getEventsByTestId({ + testId: input.testId, + timeInterval: input.interval, + testVersion: currentTest.version, + }); - return tests; + return { + events, + testName: currentTest.name, + variants: currentTest.options.map((option) => option.identifier), + }; }), }); diff --git a/apps/web/src/server/trpc/router/project.ts b/apps/web/src/server/trpc/router/project.ts index 022f42ad..1999cee3 100644 --- a/apps/web/src/server/trpc/router/project.ts +++ b/apps/web/src/server/trpc/router/project.ts @@ -31,7 +31,7 @@ export const projectRouter = router({ }, include: { tests: { - include: { options: true, events: true }, + include: { options: true, conversions: true }, }, environments: true, featureFlags: true, diff --git a/apps/web/src/server/trpc/router/tests.ts b/apps/web/src/server/trpc/router/tests.ts index a7814d17..89963e75 100644 --- a/apps/web/src/server/trpc/router/tests.ts +++ b/apps/web/src/server/trpc/router/tests.ts @@ -1,12 +1,8 @@ import { TRPCError } from "@trpc/server"; -import { ProjectService } from "server/services/ProjectService"; -import { z } from "zod"; -import { protectedProcedure, router } from "../trpc"; import { prisma } from "server/db/client"; -import { getLimitByPlan } from "server/common/plans"; -import { getProjectPaidPlan } from "lib/stripe"; -import { EventService } from "server/services/EventService"; import { TestService } from "server/services/TestService"; +import { z } from "zod"; +import { protectedProcedure, router } from "../trpc"; export const testRouter = router({ createTest: protectedProcedure @@ -94,7 +90,15 @@ export const testRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - await Promise.all( + await Promise.all([ + prisma.test.update({ + where: { + id: input.testId, + }, + data: { + version: { increment: 1 }, + }, + }), input.weights.map((w) => prisma.option.update({ where: { @@ -104,8 +108,8 @@ export const testRouter = router({ chance: w.weight, }, }) - ) - ); + ), + ]); }), getById: protectedProcedure .input( diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css deleted file mode 100644 index 7b5bd806..00000000 --- a/apps/web/src/styles/globals.css +++ /dev/null @@ -1,90 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --color-primary-background: 255, 255, 255; - --color-primary-foreground: 18, 25, 41; - --color-accent-background: 249, 168, 212; - --color-accent-foreground: 0, 0, 0; -} - -.dark { - --color-primary-background: 18, 25, 41; - --color-primary-foreground: 255, 255, 255; - --color-accent-background: 249, 168, 212; - --color-accent-foreground: 0, 0, 0; -} - -@font-face { - font-family: "Mona Sans"; - src: url("/Mona-Sans.woff2") format("woff2 supports variations"), - url("/Mona-Sans.woff2") format("woff2-variations"); - font-weight: 200 900; - font-stretch: 75% 125%; - font-display: swap; -} - -@font-face { - font-family: "Fragment Mono"; - src: url("/FragmentMono-Regular.ttf") format("ttf"); -} - -html { - @apply scroll-smooth; -} - -.shiki { - @apply mr-auto overflow-x-auto rounded-lg p-4 pt-24 !font-mono; -} - -.shiki * { - @apply !font-mono; -} - -@layer components { - input[type="range"] { - @apply appearance-none bg-transparent; - } - - input[type="range"]::-webkit-slider-runnable-track { - @apply rounded-full bg-background; - } - - input[type="range"]::-moz-range-track { - @apply rounded-full bg-background; - } - - input[type="range"]::-moz-range-thumb { - @apply bg-primary; - } - - input[type="range"]::-webkit-slider-thumb { - @apply h-4 w-4 appearance-none rounded-full bg-primary; - } - - input[type="range"]::-ms-track { - @apply rounded-full bg-background; - } -} - -.mark { - position: relative; - display: inline-block; - z-index: 0; - @apply text-ab_accent-foreground; -} - -.mark::before { - /* Highlight color */ - @apply bg-ab_accent-background; - - content: ""; - position: absolute; - width: calc(100% + 12px); - height: 100%; - left: -6px; - bottom: 0px; - z-index: -1; - transform: rotate(-1deg); -} diff --git a/apps/web/src/styles/shadcn.css b/apps/web/src/styles/shadcn.css index a836f37e..f63836ef 100644 --- a/apps/web/src/styles/shadcn.css +++ b/apps/web/src/styles/shadcn.css @@ -54,3 +54,90 @@ @apply border-border; } } + +:root { + --color-primary-background: 255, 255, 255; + --color-primary-foreground: 18, 25, 41; + --color-accent-background: 249, 168, 212; + --color-accent-foreground: 0, 0, 0; +} + +.dark { + --color-primary-background: 18, 25, 41; + --color-primary-foreground: 255, 255, 255; + --color-accent-background: 249, 168, 212; + --color-accent-foreground: 0, 0, 0; +} + +@font-face { + font-family: "Mona Sans"; + src: url("/Mona-Sans.woff2") format("woff2 supports variations"), + url("/Mona-Sans.woff2") format("woff2-variations"); + font-weight: 200 900; + font-stretch: 75% 125%; + font-display: swap; +} + +@font-face { + font-family: "Fragment Mono"; + src: url("/FragmentMono-Regular.ttf") format("ttf"); +} + +html { + @apply scroll-smooth; +} + +.shiki { + @apply mr-auto overflow-x-auto rounded-lg p-4 pt-24 !font-mono; +} + +.shiki * { + @apply !font-mono; +} + +@layer components { + input[type="range"] { + @apply appearance-none bg-transparent; + } + + input[type="range"]::-webkit-slider-runnable-track { + @apply rounded-full bg-background; + } + + input[type="range"]::-moz-range-track { + @apply rounded-full bg-background; + } + + input[type="range"]::-moz-range-thumb { + @apply bg-primary; + } + + input[type="range"]::-webkit-slider-thumb { + @apply h-4 w-4 appearance-none rounded-full bg-primary; + } + + input[type="range"]::-ms-track { + @apply rounded-full bg-background; + } +} + +.mark { + position: relative; + display: inline-block; + z-index: 0; + @apply text-ab_accent-foreground; +} + +.mark::before { + /* Highlight color */ + @apply bg-ab_accent-background; + + content: ""; + position: absolute; + width: calc(100% + 12px); + height: 100%; + left: -6px; + bottom: 0px; + z-index: -1; + transform: rotate(-1deg); +} diff --git a/apps/web/tailwind.config.cjs b/apps/web/tailwind.config.cjs index 70d5d29f..1c2d6bb9 100644 --- a/apps/web/tailwind.config.cjs +++ b/apps/web/tailwind.config.cjs @@ -3,8 +3,13 @@ const { fontFamily } = require("tailwindcss/defaultTheme"); /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], - content: ["./src/**/*.{js,ts,jsx,tsx}"], + content: [ + "./src/**/*.{js,ts,jsx,tsx}", + "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}", // Tremor module + ], theme: { + transparent: "transparent", + current: "currentColor", container: { center: true, padding: "2rem", @@ -71,16 +76,99 @@ module.exports = { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, + // light mode + tremor: { + brand: { + faint: "#eff6ff", // blue-50 + muted: "#bfdbfe", // blue-200 + subtle: "#60a5fa", // blue-400 + DEFAULT: "#3b82f6", // blue-500 + emphasis: "#1d4ed8", // blue-700 + inverted: "#ffffff", // white + }, + background: { + muted: "#f9fafb", // gray-50 + subtle: "#f3f4f6", // gray-100 + DEFAULT: "#ffffff", // white + emphasis: "#374151", // gray-700 + }, + border: { + DEFAULT: "#e5e7eb", // gray-200 + }, + ring: { + DEFAULT: "#e5e7eb", // gray-200 + }, + content: { + subtle: "#9ca3af", // gray-400 + DEFAULT: "#6b7280", // gray-500 + emphasis: "#374151", // gray-700 + strong: "#111827", // gray-900 + inverted: "#ffffff", // white + }, + }, + // dark mode + "dark-tremor": { + brand: { + faint: "#0B1229", // custom + muted: "#172554", // blue-950 + subtle: "#1e40af", // blue-800 + DEFAULT: "#3b82f6", // blue-500 + emphasis: "#60a5fa", // blue-400 + inverted: "#030712", // gray-950 + }, + background: { + muted: "#131A2B", // custom + subtle: "#1f2937", // gray-800 + DEFAULT: "#111827", // gray-900 + emphasis: "#d1d5db", // gray-300 + }, + border: { + DEFAULT: "#1f2937", // gray-800 + }, + ring: { + DEFAULT: "#1f2937", // gray-800 + }, + content: { + subtle: "#4b5563", // gray-600 + DEFAULT: "#6b7280", // gray-500 + emphasis: "#e5e7eb", // gray-200 + strong: "#f9fafb", // gray-50 + inverted: "#000000", // black + }, + }, }, fontFamily: { sans: ["Mona Sans", ...fontFamily.sans], mono: ["Fragment Mono", ...fontFamily.mono], logo: ["Martian Mono", ...fontFamily.mono], }, + boxShadow: { + // light + "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "tremor-card": + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "tremor-dropdown": + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + // dark + "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "dark-tremor-card": + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "dark-tremor-dropdown": + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + }, + fontSize: { + "tremor-label": ["0.75rem"], + "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }], + "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }], + "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }], + }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", + "tremor-small": "0.375rem", + "tremor-default": "0.5rem", + "tremor-full": "9999px", }, keyframes: { slideUpAndFade: { @@ -121,7 +209,37 @@ module.exports = { }, }, }, + safelist: [ + { + pattern: + /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ["hover", "ui-selected"], + }, + { + pattern: + /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ["hover", "ui-selected"], + }, + { + pattern: + /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ["hover", "ui-selected"], + }, + { + pattern: + /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + { + pattern: + /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + { + pattern: + /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + ], plugins: [ + // require("@headlessui/tailwindcss"), require("@tailwindcss/typography"), require("@tailwindcss/forms"), require("tailwindcss-animate"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9184431b..92fad41c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,9 @@ importers: '@tiptap/starter-kit': specifier: 2.0.4 version: 2.0.4(@tiptap/pm@2.0.4) + '@tremor/react': + specifier: ^3.11.0 + version: 3.11.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2) '@trpc/client': specifier: ^10.19.1 version: 10.30.0(@trpc/server@10.30.0) @@ -5921,6 +5924,17 @@ packages: '@floating-ui/core': 1.3.1 dev: false + /@floating-ui/react-dom@1.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.4.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@floating-ui/react-dom@2.0.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-rZtAmSht4Lry6gdhAJDrCp/6rKN7++JnL1/Anbr/DdeyYXQPxvg/ivrbYvJulbRf4vL8b212suwMM2lxbv+RQA==} peerDependencies: @@ -5932,6 +5946,19 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@floating-ui/react@0.19.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 1.3.0(react-dom@18.2.0)(react@18.2.0) + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tabbable: 6.2.0 + dev: false + /@fontsource/martian-mono@5.0.8: resolution: {integrity: sha512-9XXGJww+3rrztm6d8aauiHUW1LMWmOlKmAQkpZKN1j5PQ3MrGWk+E2C+vN/9DxcCBSz55tZAtMCcOSFSqJXT7g==} dev: false @@ -5952,6 +5979,15 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@headlessui/tailwindcss@0.1.3(tailwindcss@3.3.2): + resolution: {integrity: sha512-3aMdDyYZx9A15euRehpppSyQnb2gIw2s/Uccn2ELIoLQ9oDy0+9oRygNWNjXCD5Dt+w1pxo7C+XoiYvGcqA4Kg==} + engines: {node: '>=10'} + peerDependencies: + tailwindcss: ^3.0 + dependencies: + tailwindcss: 3.3.2(ts-node@10.9.1) + dev: false + /@humanwhocodes/config-array@0.11.10: resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} engines: {node: '>=10.10.0'} @@ -9879,6 +9915,27 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + /@tremor/react@3.11.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2): + resolution: {integrity: sha512-ItGx8Q3uzG3JOqTLb92Ximx6p/opIc7AIY5ALialtOAA7kGA3DSQMHeChRRhxLIRD7SYOmiwUhOgLFjjoffwnw==} + peerDependencies: + react: ^18.0.0 + react-dom: '>=16.6.0' + dependencies: + '@floating-ui/react': 0.19.2(react-dom@18.2.0)(react@18.2.0) + '@headlessui/react': 1.7.15(react-dom@18.2.0)(react@18.2.0) + '@headlessui/tailwindcss': 0.1.3(tailwindcss@3.3.2) + date-fns: 2.30.0 + react: 18.2.0 + react-day-picker: 8.9.1(date-fns@2.30.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + recharts: 2.9.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + tailwind-merge: 1.13.1 + transitivePeerDependencies: + - prop-types + - tailwindcss + dev: false + /@trpc/client@10.30.0(@trpc/server@10.30.0): resolution: {integrity: sha512-utz0qRI4eU3QcHvBwcSONEnt5pWR3Dyk4VFJnySHysBT6GQRRpJifWX5+RxDhFK93LxcAmiirFbYXjZ40gbobw==} peerDependencies: @@ -10061,6 +10118,48 @@ packages: dependencies: '@types/node': 18.16.17 + /@types/d3-array@3.2.1: + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.0.2: + resolution: {integrity: sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==} + dev: false + + /@types/d3-scale@4.0.8: + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + dependencies: + '@types/d3-time': 3.0.3 + dev: false + + /@types/d3-shape@3.1.5: + resolution: {integrity: sha512-dfEWpZJ1Pdg8meLlICX1M3WBIpxnaH2eQV2eY43Y5ysRJOTAV9f3/R++lgJKFstfrEOE2zdJ0sv5qwr2Bkic6Q==} + dependencies: + '@types/d3-path': 3.0.2 + dev: false + + /@types/d3-time@3.0.3: + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + /@types/debug@4.1.8: resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} dependencies: @@ -12203,6 +12302,10 @@ packages: typescript: 4.9.5 dev: false + /classnames@2.3.2: + resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} + dev: false + /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -12689,6 +12792,77 @@ packages: /custom-event@1.0.1: resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==} + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -12717,6 +12891,13 @@ packages: whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.22.5 + dev: false + /date-format@4.0.14: resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} engines: {node: '>=4.0'} @@ -12781,6 +12962,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -13030,6 +13215,19 @@ packages: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dev: true + /dom-helpers@3.4.0: + resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} + dependencies: + '@babel/runtime': 7.22.5 + dev: false + + /dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.22.5 + csstype: 3.1.2 + dev: false + /dom-serialize@2.2.1: resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==} dependencies: @@ -14588,6 +14786,11 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-glob@3.2.12: resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} engines: {node: '>=8.6.0'} @@ -15841,6 +16044,11 @@ packages: has: 1.0.3 side-channel: 1.0.4 + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} @@ -20329,6 +20537,16 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true + /react-day-picker@8.9.1(date-fns@2.30.0)(react@18.2.0): + resolution: {integrity: sha512-W0SPApKIsYq+XCtfGeMYDoU0KbsG3wfkYtlw8l+vZp6KoBXGOlhzBUp4tNx1XiwiOZwhfdGOlj7NGSCKGSlg5Q==} + peerDependencies: + date-fns: ^2.28.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + date-fns: 2.30.0 + react: 18.2.0 + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -20398,6 +20616,10 @@ packages: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + dev: false + /react-refresh@0.14.0: resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} engines: {node: '>=0.10.0'} @@ -20457,6 +20679,31 @@ packages: use-sidecar: 1.1.2(@types/react@18.0.14)(react@18.2.0) dev: false + /react-resize-detector@8.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-S7szxlaIuiy5UqLhLL1KY3aoyGHbZzsTpYal9eYMwCyKqoqoVLCmIgAgNyIM1FhnP2KyBygASJxdhejrzjMb+w==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-smooth@2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==} + peerDependencies: + prop-types: ^15.6.0 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 2.9.0(react-dom@18.2.0)(react@18.2.0) + dev: false + /react-ssr-prepass@1.5.0(react@18.2.0): resolution: {integrity: sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==} peerDependencies: @@ -20482,6 +20729,34 @@ packages: tslib: 2.5.3 dev: false + /react-transition-group@2.9.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} + peerDependencies: + react: '>=15.0.0' + react-dom: '>=15.0.0' + dependencies: + dom-helpers: 3.4.0 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + dev: false + + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + dependencies: + '@babel/runtime': 7.22.5 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-use-wizard@2.2.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-Oh3EfpmWwF7vW1YZ2EgSLoU9VKJ13+TyLNmT56rBhLoXr3FXZoDEmtyxZ46GVkcicl/8qiHX0C42M6n2oKDkEQ==} engines: {node: '>=10'} @@ -20601,6 +20876,34 @@ packages: tslib: 2.5.3 dev: true + /recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + dependencies: + decimal.js-light: 2.5.1 + dev: false + + /recharts@2.9.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-B61sKrDlTxHvYwOCw8eYrD6rTA2a2hJg0avaY8qFI1ZYdHKvU18+J5u7sBMFg//wfJ/C5RL5+HsXt5e8tcJNLg==} + engines: {node: '>=12'} + peerDependencies: + prop-types: ^15.6.0 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + classnames: 2.3.2 + eventemitter3: 4.0.7 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 16.13.1 + react-resize-detector: 8.1.0(react-dom@18.2.0)(react@18.2.0) + react-smooth: 2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.1 + victory-vendor: 36.6.12 + dev: false + /rechoir@0.6.2: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} @@ -22328,6 +22631,10 @@ packages: globrex: 0.1.2 dev: true + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + /tinybench@2.5.0: resolution: {integrity: sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==} dev: true @@ -23309,6 +23616,25 @@ packages: vfile-message: 3.1.4 dev: false + /victory-vendor@36.6.12: + resolution: {integrity: sha512-pJrTkNHln+D83vDCCSUf0ZfxBvIaVrFHmrBOsnnLAbdqfudRACAj51He2zU94/IWq9464oTADcPVkmWAfNMwgA==} + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.5 + '@types/d3-time': 3.0.3 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + dev: false + /vite-node@0.33.0(@types/node@18.16.17): resolution: {integrity: sha512-19FpHYbwWWxDr73ruNahC+vtEdza52kA90Qb3La98yZ0xULqV8A5JLNPUff0f5zID4984tW7l3DH2przTJUZSw==} engines: {node: '>=v14.18.0'}