diff --git a/apps/web/package.json b/apps/web/package.json index ee1f18a8..1079ec65 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,7 +49,7 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.1.0", - "@radix-ui/react-tooltip": "^1.0.5", + "@radix-ui/react-tooltip": "^1.0.6", "@react-email/button": "0.0.5", "@react-email/container": "0.0.5", "@react-email/head": "0.0.3", diff --git a/apps/web/src/components/AddABTestModal.tsx b/apps/web/src/components/AddABTestModal.tsx index 454dc009..0709be1b 100644 --- a/apps/web/src/components/AddABTestModal.tsx +++ b/apps/web/src/components/AddABTestModal.tsx @@ -1,27 +1,31 @@ import { TRPCClientError } from "@trpc/client"; import { TRPC_ERROR_CODES_BY_KEY } from "@trpc/server/rpc"; - +import { Modal } from "components/Modal"; +import { Card, CardContent } from "components/ui/card"; import { useTracking } from "lib/tracking"; import { useState } from "react"; import { toast } from "react-hot-toast"; import { trpc } from "utils/trpc"; -import { Modal } from "./Modal"; import { CreateTestSection, DEFAULT_NEW_VARIANT_PREFIX, } from "./Test/CreateTestSection"; -type UIVariant = { name: string; weight: number }; +type UIVariant = { name: string; weight: number; id: string }; const INITIAL_VARIANTS: Array = [ { name: `${DEFAULT_NEW_VARIANT_PREFIX}1`, + id: crypto.randomUUID(), }, { name: `${DEFAULT_NEW_VARIANT_PREFIX}2`, + id: crypto.randomUUID(), }, - // give each variant a weight of 100 / number of variants -].map((v, _, array) => ({ ...v, weight: 100 / array.length })); +].map((v, _, array) => { + const weight = Number((100 / array.length).toFixed(2)); + return { ...v, weight }; +}); const INITIAL_TEST_NAME = "New Test"; @@ -33,33 +37,31 @@ type Props = { export const AddABTestModal = ({ onClose, isOpen, projectId }: Props) => { const [testName, setTestName] = useState(INITIAL_TEST_NAME); - const [variants, setVariants] = - useState>(INITIAL_VARIANTS); + const [variants, setVariants] = useState>(INITIAL_VARIANTS); const variantsIncludeDuplicates = new Set(variants.map((variant) => variant.name)).size !== variants.length; - const variantsWeightSum = variants - .map(({ weight }) => weight) - // biome-ignore lint/suspicious/noAssignInExpressions: - // biome-ignore lint/style/noParameterAssign: - .reduce((sum, weight) => (sum += weight), 0); + const variantsWeightSum = Number( + variants + .map(({ weight }) => weight) + .reduce((sum, weight) => sum + weight, 0) + .toFixed(2) + ); const isConfirmButtonDisabled = - variantsIncludeDuplicates || variantsWeightSum !== 100; + variantsIncludeDuplicates || Math.abs(variantsWeightSum - 100) > 0.3; const createTestMutation = trpc.tests.createTest.useMutation(); - const trpcContext = trpc.useContext(); - const trackEvent = useTracking(); const onCreateClick = async () => { try { if (!variants.length || !variants[0]) throw new Error(); - if (variants.reduce((acc, curr) => acc + curr.weight, 0) !== 100) { - toast.error("Weights must add up to 100"); + if (Math.abs(variantsWeightSum - 100) > 0.3) { + toast.error("Weights must add up to 100%"); return; } @@ -67,7 +69,7 @@ export const AddABTestModal = ({ onClose, isOpen, projectId }: Props) => { name: testName, variants: variants.map((v) => ({ name: v.name, - weight: v.weight / 100, + weight: Number.parseFloat((v.weight / 100).toFixed(3)), // Convert to decimal with proper precision })), projectId: projectId, }); @@ -105,12 +107,16 @@ export const AddABTestModal = ({ onClose, isOpen, projectId }: Props) => { isConfirming={createTestMutation.isLoading} isConfirmButtonDisabled={isConfirmButtonDisabled} > - + + + + + ); }; diff --git a/apps/web/src/components/Test/CreateTestSection.tsx b/apps/web/src/components/Test/CreateTestSection.tsx index 0305311f..0ffd119d 100644 --- a/apps/web/src/components/Test/CreateTestSection.tsx +++ b/apps/web/src/components/Test/CreateTestSection.tsx @@ -1,24 +1,41 @@ import { Button } from "components/ui/button"; +import { Card, CardContent, CardHeader } from "components/ui/card"; +import { Input } from "components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/ui/tooltip"; import produce from "immer"; -import { getUpdatedWeights } from "lib/helper"; +import { cn } from "lib/utils"; +import { RotateCcw } from "lucide-react"; +import { PieChart, Plus, Trash2 } from "lucide-react"; import { - type ChangeEvent, type Dispatch, - Fragment, type SetStateAction, + useEffect, + useRef, + useState, } from "react"; -import { BiTrash } from "react-icons/bi"; -import { Card } from "./Section"; type Props = { testName: string; setTestName: (name: string) => void; - variants: Array<{ name: string; weight: number }>; + variants: Array<{ name: string; weight: number; id: string }>; setVariants: Dispatch< SetStateAction< { name: string; weight: number; + id: string; }[] > >; @@ -53,177 +70,414 @@ function getMaxDefaultVariantNameIndex(variants: Props["variants"]): number { return Math.max(...variantsIndexes); } +const WeightPresets = { + "Equal Split": (count: number) => { + if (count === 3) { + return [33.3, 33.3, 33.3]; + } + const weight = Number((100 / count).toFixed(2)); + const weights = Array(count).fill(0); + + // Distribute weights evenly and handle remainder + let remaining = 100; + for (let i = 0; i < count - 1; i++) { + weights[i] = weight; + remaining -= weight; + } + weights[count - 1] = Number(remaining.toFixed(2)); + + return weights; + }, + "Champion/Challenger": (count: number) => { + if (count < 2) return [100]; + const challenger = 10; + const remaining = Number((90 / (count - 1)).toFixed(2)); + + // Handle remainder to ensure 100% total + const weights = Array(count).fill(remaining); + weights[count - 1] = challenger; + + // Adjust last non-challenger weight to make sum exactly 100 + const sum = weights.reduce((a, b) => a + b, 0); + if (sum !== 100) { + weights[count - 2] = Number( + (weights[count - 2] + (100 - sum)).toFixed(2) + ); + } + + return weights; + }, + "Progressive Split": (count: number) => { + if (count < 2) return [100]; + const total = (count * (count + 1)) / 2; + const weights = Array(count) + .fill(0) + .map((_, i) => Number((((i + 1) * 100) / total).toFixed(2))); + + // Handle rounding by adjusting last weight + const sum = weights.reduce((a, b) => a + b, 0); + if (sum !== 100) { + weights[count - 1] = Number( + ((weights[count - 1] ?? 0) + (100 - sum)).toFixed(2) + ); + } + + return weights; + }, +}; + +// Distribution color palette using tailwind colors that work well in both modes +const WEIGHT_COLORS = [ + "bg-blue-500/90 dark:bg-blue-400/90", + "bg-emerald-500/90 dark:bg-emerald-400/90", + "bg-indigo-500/90 dark:bg-indigo-400/90", + "bg-amber-500/90 dark:bg-amber-400/90", + "bg-rose-500/90 dark:bg-rose-400/90", +]; + +const PRESET_DESCRIPTIONS = { + "Equal Split": { + description: "Distributes traffic evenly between all variants", + example: "e.g., 33.3% / 33.3% / 33.3%", + }, + "Champion/Challenger": { + description: "90% to existing variant, 10% to the new variant", + example: "e.g., 45% / 45% / 10%", + }, + "Progressive Split": { + description: "Gradually increases weight for each variant", + example: "e.g., 17% / 33% / 50%", + }, +}; + export function CreateTestSection({ setTestName, testName, setVariants, variants, }: Props) { - const updateWeights = (draft: { name: string; weight: number }[]) => { - if (draft.length <= 2) { - draft.forEach((draftItem) => { - draftItem.weight = 100 / draft.length; - }); - } + const [selectedPreset, setSelectedPreset] = useState< + keyof typeof WeightPresets | undefined + >(undefined); + const [isDirty, setIsDirty] = useState(false); + const [showError, setShowError] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(null); + const containerRef = useRef(null); + + const updateWeights = (weights: number[]) => { + setVariants((current) => + current.map((variant, i) => ({ + ...variant, + weight: Number(weights[i]?.toFixed(2)), + })) + ); + setIsDirty(true); }; const addVariant = () => { const maxVariantIndex = getMaxDefaultVariantNameIndex(variants); - setVariants( produce(variants, (draft) => { draft.push({ + id: crypto.randomUUID(), name: `${DEFAULT_NEW_VARIANT_PREFIX}${maxVariantIndex + 1}`, - weight: 0, + weight: 1, }); - updateWeights(draft); }) ); + setIsDirty(true); }; const removeVariant = (index: number) => { - // delete and rebalance weights setVariants( produce(variants, (draft) => { draft.splice(index, 1); - updateWeights(draft); + // Rebalance weights after removing + const equalWeight = Number((100 / draft.length).toFixed(2)); + const remainder = Number( + (100 - equalWeight * (draft.length - 1)).toFixed(2) + ); + draft.forEach((v, i) => { + v.weight = i === draft.length - 1 ? remainder : equalWeight; + }); + }) + ); + setIsDirty(true); + }; + + const handleWeightChange = (index: number, value: string) => { + setVariants( + produce(variants, (draft) => { + if (!draft[index]) return; + draft[index].weight = Number.parseFloat( + Number.parseFloat(value).toFixed(2) + ); }) ); + setIsDirty(true); }; - const weightSum = Math.round( - variants - .map(({ weight }) => weight) - // biome-ignore lint/suspicious/noAssignInExpressions: - // biome-ignore lint/style/noParameterAssign: - .reduce((sum, weight) => (sum += weight), 0) + const weightSum = Number( + variants.reduce((sum, { weight }) => sum + weight, 0).toFixed(2) ); + const isValidWeightSum = Math.abs(weightSum - 100) <= 0.1; - const handleWeightChange = ( - index: number, - event: ChangeEvent - ) => { - const rawEventValue = event.target.value !== "" ? event.target.value : 0; + useEffect(() => { + const timer = setTimeout(() => { + setShowError(!isValidWeightSum); + }, 500); - const eventValue = - typeof rawEventValue === "number" - ? rawEventValue - : Number.parseInt(rawEventValue); + return () => clearTimeout(timer); + }, [isValidWeightSum]); - if (Number.isNaN(eventValue)) { - return; - } + const applyPreset = (preset: keyof typeof WeightPresets) => { + const weights = WeightPresets[preset](variants.length); + updateWeights(weights); + setSelectedPreset(preset); + }; - setVariants((currentVariants) => { - const updatedWeights = getUpdatedWeights({ - indexToUpdate: index, - newWeight: eventValue, - weights: variants.map((v) => v.weight), - }); - - return currentVariants.map((currentVariant, i) => ({ - name: currentVariant.name, - weight: updatedWeights[i] ?? 0, - })); - }); + const onRevert = () => { + setVariants(variants); + setTestName(testName); + setSelectedPreset(undefined); + setIsDirty(false); + }; + + const handleNameChange = (e: React.ChangeEvent) => { + setTestName(e.target.value); + setIsDirty(true); + }; + + const handleVariantNameChange = ( + e: React.ChangeEvent, + index: number + ) => { + setVariants( + produce(variants, (draft) => { + if (!draft[index]) return; + draft[index].name = e.target.value; + }) + ); + setIsDirty(true); }; return ( -
-

{testName}

-
- -
- - setTestName(e.target.value)} - className="flex items-center space-x-4 rounded-md bg-gray-700 p-2 pr-4 focus:outline focus:outline-blue-400" - /> -
-
- -
- {variants.map(({ name, weight }, i) => { - return ( - -
+ + + + + + Remove variant + + +
+ ))} + +
+
+ {variants.map((option, i) => { + return ( +
+ ); + })} +
+
+ +
+
+ {showError && ( +

+ Weights must add up to 100% (currently{" "} + {weightSum.toFixed(2)}%) +

+ )} +
+
+ {isDirty && ( + + )} + + +
- + + +
+ {isDirty && + `Changes pending. ${variants.length} variants with total weight: ${weightSum.toFixed(2)}%`}
); diff --git a/apps/web/src/components/Test/Metrics.tsx b/apps/web/src/components/Test/Metrics.tsx index 02b60c69..9767d7f3 100644 --- a/apps/web/src/components/Test/Metrics.tsx +++ b/apps/web/src/components/Test/Metrics.tsx @@ -12,12 +12,12 @@ const Metrics = ({ const labels = options.map((option) => option.identifier); return ( -
+
acc + e._count._all, 0)} variants={labels} events={actEvents} - totalText="Interactions" + totalText="Conversions" />
); diff --git a/apps/web/src/components/Test/Section.tsx b/apps/web/src/components/Test/Section.tsx index 8670ff51..18e9427b 100644 --- a/apps/web/src/components/Test/Section.tsx +++ b/apps/web/src/components/Test/Section.tsx @@ -3,14 +3,14 @@ import * as Popover from "@radix-ui/react-popover"; import { Modal } from "components/Modal"; import { TitleEdit } from "components/TitleEdit"; import { Button } from "components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "components/ui/card"; import { useFeatureFlag } from "lib/abby"; import { cn } from "lib/utils"; -import Link from "next/link"; +import { ChevronRight, TrashIcon } from "lucide-react"; import { useRouter } from "next/router"; import type { ProjectClientEvents } from "pages/projects/[projectId]"; import { type ReactNode, useState } from "react"; import { toast } from "react-hot-toast"; -import { AiOutlineDelete } from "react-icons/ai"; import { BiInfoCircle } from "react-icons/bi"; import type { ClientOption } from "server/trpc/router/project"; import { trpc } from "utils/trpc"; @@ -81,7 +81,7 @@ const DeleteTestModal = ({ ); }; -export const Card = ({ +export const MetricCard = ({ title, children, tooltip, @@ -93,35 +93,32 @@ export const Card = ({ className?: string; }) => { return ( -
-
-

{title}

- {tooltip && ( - - - - - - - {tooltip} - - - - - )} -
- {children} -
+ + +
+ {title} + {tooltip && ( + + + + + + + {tooltip} + + + + + )} +
+
+ {children} +
); }; @@ -157,85 +154,82 @@ const Section = ({ }); return ( -
-
- updateTestName({ name: newName, testId: id })} - /> - - setIsDeleteModalOpen(false)} - testId={id} - testName={name} - /> -
-
- - The weights define the chances for your defined variants to be - served.
- This means that if you have 2 variants with a weight of 50%, each - variant will be served 50% of the time. -

- } - > - -
- - A visit means that a user has visited a page where the A/B test - takes place. Think of it like a page visit on a website. -

- } - > - -
- - An interaction is triggered when the - - onAct - - is called in your code. -

- } - > - -
-
-
- {bestVariant && ( -

- The variant {bestVariant} is currently performing best -

- )} - {showAdvancedTestStats && ( - +
+ +
+ updateTestName({ name: newName, testId: id })} + /> + +
+
+ +
+ - - - )} -
-
+ + + + + + + A conversion is triggered when the{" "} + onAct{" "} + is called in your code. +

+ } + > + +
+
+ +
+ {bestVariant && ( +

+ The variant{" "} + {bestVariant}{" "} + is currently performing best +

+ )} + {showAdvancedTestStats && ( + + )} +
+ + setIsDeleteModalOpen(false)} + testId={id} + testName={name} + /> + ); }; diff --git a/apps/web/src/components/Test/Serves.tsx b/apps/web/src/components/Test/Serves.tsx index 10e258b3..8c8b568a 100644 --- a/apps/web/src/components/Test/Serves.tsx +++ b/apps/web/src/components/Test/Serves.tsx @@ -12,7 +12,7 @@ const Serves = ({ const labels = options.map((option) => option.identifier); return ( -
+
acc + e._count._all, 0)} variants={labels} diff --git a/apps/web/src/components/Test/Weights.tsx b/apps/web/src/components/Test/Weights.tsx index 1cd822b1..4bf637ff 100644 --- a/apps/web/src/components/Test/Weights.tsx +++ b/apps/web/src/components/Test/Weights.tsx @@ -1,67 +1,205 @@ import { Button } from "components/ui/button"; -import { getUpdatedWeights } from "lib/helper"; +import { Input } from "components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/ui/tooltip"; +import { cn } from "lib/utils"; +import { PieChart, RotateCcw } from "lucide-react"; import { useRouter } from "next/router"; -import { type ChangeEvent, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import type { ClientOption } from "server/trpc/router/project"; import { trpc } from "utils/trpc"; -const Weight = ({ - option, - value, - onChange, +// Distribution color palette using tailwind colors that work well in both modes +const WEIGHT_COLORS = [ + "bg-blue-500/90 dark:bg-blue-400/90", + "bg-emerald-500/90 dark:bg-emerald-400/90", + "bg-indigo-500/90 dark:bg-indigo-400/90", + "bg-amber-500/90 dark:bg-amber-400/90", + "bg-rose-500/90 dark:bg-rose-400/90", +]; + +const WeightPresets = { + "Equal Split": (count: number) => { + const weight = Number((100 / count).toFixed(2)); + const weights = Array(count).fill(0); + + // Distribute weights evenly and handle remainder + let remaining = 100; + for (let i = 0; i < count - 1; i++) { + weights[i] = weight; + remaining -= weight; + } + weights[count - 1] = Number(remaining.toFixed(2)); + + return weights; + }, + "Champion/Challenger": (count: number) => { + if (count < 2) return [100]; + const challenger = 10; + const remaining = Number((90 / (count - 1)).toFixed(2)); + + // Handle remainder to ensure 100% total + const weights = Array(count).fill(remaining); + weights[count - 1] = challenger; + + // Adjust last non-challenger weight to make sum exactly 100 + const sum = weights.reduce((a, b) => a + b, 0); + if (sum !== 100) { + weights[count - 2] = Number( + (weights[count - 2] + (100 - sum)).toFixed(2) + ); + } + + return weights; + }, + "Progressive Split": (count: number) => { + if (count < 2) return [100]; + const total = (count * (count + 1)) / 2; + const weights = Array(count) + .fill(0) + .map((_, i) => Number((((i + 1) * 100) / total).toFixed(2))); + + // Handle rounding by adjusting last weight + const sum = weights.reduce((a, b) => a + b, 0); + if (sum !== 100) { + weights[count - 1] = Number( + ((weights[count - 1] ?? 0) + (100 - sum)).toFixed(2) + ); + } + + return weights; + }, +}; + +const PRESET_DESCRIPTIONS = { + "Equal Split": { + description: "Distributes traffic evenly between all variants", + example: "e.g., 33.3% / 33.3% / 33.3%", + }, + "Champion/Challenger": { + description: "90% to existing variant, 10% to the new variant", + example: "e.g., 45% / 45% / 10%", + }, + "Progressive Split": { + description: "Gradually increases weight for each variant", + example: "e.g., 17% / 33% / 50%", + }, +}; + +const PresetItem = ({ + preset, + isSelected, + onSelect, }: { - option: ClientOption; - value: number; - index: number; - onChange: (e: ChangeEvent) => void; -}) => { - return ( - <> - - void; +}) => ( + { + if (e.key === "Enter") { + onSelect(); + } + }} + > +
+ - - ); -}; +
+ {preset} + + { + PRESET_DESCRIPTIONS[preset as keyof typeof PRESET_DESCRIPTIONS] + .example + } + +
+
+
+); const Weights = ({ options }: { options: ClientOption[] }) => { const router = useRouter(); const trpcContext = trpc.useContext(); - const [weights, setWeights] = useState( - options.map((option) => Number.parseFloat(option.chance.toString()) * 100) + const containerRef = useRef(null); + + const initialWeights = useMemo( + () => + options.map((option) => + Number((Number.parseFloat(option.chance.toString()) * 100).toFixed(2)) + ), + [options] ); + const [weights, setWeights] = useState(initialWeights); + const [selectedPreset, setSelectedPreset] = useState< + keyof typeof WeightPresets | undefined + >(undefined); + const [isDirty, setIsDirty] = useState(false); + const [showError, setShowError] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(null); + const { mutateAsync } = trpc.tests.updateWeights.useMutation(); - const updateWeight = (indexToUpdate: number, newWeight: number) => { - setWeights((currentWeights) => - getUpdatedWeights({ - indexToUpdate, - newWeight, - weights: currentWeights, - }) - ); + const updateWeight = (indexToUpdate: number, value: string) => { + setWeights((currentWeights) => { + const updatedWeights = [...currentWeights]; + updatedWeights[indexToUpdate] = Number.parseFloat( + Number.parseFloat(value).toFixed(2) + ); + return updatedWeights; + }); + setIsDirty(true); }; - const weightsSum = Math.round( - // biome-ignore lint/suspicious/noAssignInExpressions: - // biome-ignore lint/style/noParameterAssign: - weights.reduce((sum, curr) => (sum += curr), 0) + const applyPreset = (preset: keyof typeof WeightPresets) => { + const newWeights = WeightPresets[preset](options.length); + setWeights(newWeights.map((w) => Number(w.toFixed(2)))); + setSelectedPreset(preset); + setIsDirty(true); + }; + + const weightsSum = Number( + weights.reduce((sum, curr) => sum + curr, 0).toFixed(2) ); + const isValidWeightSum = Math.abs(weightsSum - 100) <= 0.1; + + // Add debounced error display + useEffect(() => { + const timer = setTimeout(() => { + setShowError(!isValidWeightSum); + }, 500); + + return () => clearTimeout(timer); + }, [isValidWeightSum]); + + // Force Select UI to update when reverting + useEffect(() => { + if (!isDirty) { + setSelectedPreset(undefined); + } + }, [isDirty]); const onSave = async () => { try { @@ -69,17 +207,20 @@ const Weights = ({ options }: { options: ClientOption[] }) => { await mutateAsync({ testId: options[0].testId, - weights: weights.map((weight, index) => ({ - // biome-ignore lint/style/noNonNullAssertion: - variantId: options[index]!.id, - weight: weight / 100, - })), + weights: weights.flatMap((weight, index) => { + if (!options[index]) return []; + return { + variantId: options[index].id, + weight: weight / 100, + }; + }), }); await trpcContext.project.getProjectData.invalidate({ projectId: router.query.projectId as string, }); + setIsDirty(false); toast.success("Weights saved"); } catch (e) { console.error(e); @@ -87,31 +228,198 @@ const Weights = ({ options }: { options: ClientOption[] }) => { } }; + const onRevert = () => { + setWeights(initialWeights); + setSelectedPreset(undefined); // Ensure preset is cleared + setIsDirty(false); + }; + + // Add tooltip content for revert button + const getRevertTooltipContent = () => { + return ( +
+

Original weights:

+
+ {options.map((option, i) => ( +
+ + {option.identifier}: + + + {initialWeights[i]?.toFixed(2)}% + +
+ ))} +
+
+ ); + }; + + if (options.length === 0) { + return ( +
+ Unfortunately, there is no data to display. +
+ ); + } + return ( -
- {options.map((option, index) => ( - - updateWeight(index, Number.parseInt(e.target.value, 10)) +
+
+ +
+ + {options.map((option, index) => ( +
+
+ + {option.identifier} + +
+ updateWeight(index, e.target.value)} + onFocus={() => setFocusedIndex(index)} + onBlur={() => setFocusedIndex(null)} + className={cn( + "pr-3 transition-shadow duration-200", // Increased padding for keyboard hints + focusedIndex === index + ? "ring-2 ring-primary ring-offset-2" + : "" + )} + /> +
+ % +
+
+
+
))} -
- {weightsSum !== 100 ? ( -

- Your weights must add up to 100%. Your weights currently make up{" "} - {weightsSum}% -

- ) : ( - - )} - + +
+
+ {options.map((option, i) => { + const curentWeight = weights[i]; + const initialWeight = initialWeights[i]; + if (!curentWeight || !initialWeight) return null; + return ( +
+ {isDirty && ( +
initialWeight ? 0 : "auto", + right: curentWeight <= initialWeight ? 0 : "auto", + borderRight: + curentWeight > initialWeight + ? "2px dashed currentColor" + : "none", + borderLeft: + curentWeight <= initialWeight + ? "2px dashed currentColor" + : "none", + }} + /> + )} +
+ ); + })} +
+
+ +
+ {isDirty && `Changes pending. Total weight: ${weightsSum.toFixed(2)}%`} +
+ +
+
+ {showError && ( +

+ Weights must add up to 100% (currently {weightsSum.toFixed(2)}%) +

+ )} +
+
+ {isDirty && ( + + + + + + + {getRevertTooltipContent()} + + + + )} + +
); diff --git a/apps/web/src/components/charts/Donut.tsx b/apps/web/src/components/charts/Donut.tsx index a96bbc3f..ce43f0a2 100644 --- a/apps/web/src/components/charts/Donut.tsx +++ b/apps/web/src/components/charts/Donut.tsx @@ -1,6 +1,7 @@ "use client"; import { Label, Pie, PieChart } from "recharts"; +import { DOCS_URL } from "@tryabby/core"; import { Card, CardContent, CardFooter } from "components/ui/card"; import { type ChartConfig, @@ -10,6 +11,7 @@ import { ChartTooltip, ChartTooltipContent, } from "components/ui/chart"; +import Link from "next/link"; import type { ProjectClientEvents } from "pages/projects/[projectId]"; import { useMemo } from "react"; @@ -55,13 +57,20 @@ export function DonutChart({ ); return ( - + {hasNoData ? (

Unfortunatly, there is no data to display.

Start by sending events from your app. +
+
+ Read more in the{" "} + + docs + + .

) : ( , + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/web/src/pages/projects/[projectId]/index.tsx b/apps/web/src/pages/projects/[projectId]/index.tsx index 3454968f..3c0686d7 100644 --- a/apps/web/src/pages/projects/[projectId]/index.tsx +++ b/apps/web/src/pages/projects/[projectId]/index.tsx @@ -5,10 +5,13 @@ import { Layout } from "components/Layout"; import { FullPageLoadingSpinner } from "components/LoadingSpinner"; import Section from "components/Test/Section"; import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import Fuse from "fuse.js"; import { useProjectId } from "lib/hooks/useProjectId"; +import { Search } from "lucide-react"; import type { GetStaticPaths, GetStaticProps } from "next"; import type { NextPageWithLayout } from "pages/_app"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { AiOutlinePlus } from "react-icons/ai"; import type { AppRouter } from "server/trpc/router/_app"; import { trpc } from "utils/trpc"; @@ -18,13 +21,24 @@ export type ProjectClientEvents = const Projects: NextPageWithLayout = () => { const [isCreateTestModalOpen, setIsCreateTestModalOpen] = useState(false); - + const [query, setQuery] = useState(""); const projectId = useProjectId(); const { data, isLoading, isError } = trpc.project.getProjectData.useQuery({ projectId: projectId, }); + const fuse = useMemo( + () => new Fuse(data?.project.tests ?? [], { keys: ["name"] }), + [data?.project?.tests] + ); + + const filteredTests = useMemo(() => { + if (!query) return data?.project.tests ?? []; + const results = fuse.search(query); + return results.map((result) => result.item); + }, [query, fuse.search, data?.project.tests]); + if (isLoading || isError) return ; if (data.project.tests.length === 0) @@ -33,10 +47,7 @@ const Projects: NextPageWithLayout = () => {

You don't have any A/B tests yet!

- { />
); + return ( <> -
- - setIsCreateTestModalOpen(false)} - projectId={projectId} - /> -
-
- {data?.project?.tests.map((test) => ( -
+
+
+ + { + setQuery(e.target.value); + }} + /> +
+ + setIsCreateTestModalOpen(false)} + projectId={projectId} /> - ))} +
+ +
+ {filteredTests.map((test) => ( +
+ ))} +
); diff --git a/apps/web/src/server/queue/event.ts b/apps/web/src/server/queue/event.ts index 423ca22f..392be13c 100644 --- a/apps/web/src/server/queue/event.ts +++ b/apps/web/src/server/queue/event.ts @@ -22,7 +22,6 @@ const EventTypeToRequestType = { const eventWorker = new Worker( eventQueue.name, async ({ data: event }) => { - // TODO: add those to a queue and process them in a background job as they are not critical switch (event.type) { case AbbyEventType.PING: case AbbyEventType.ACT: { diff --git a/apps/web/src/server/trpc/router/project.ts b/apps/web/src/server/trpc/router/project.ts index b515b1f5..0e085d08 100644 --- a/apps/web/src/server/trpc/router/project.ts +++ b/apps/web/src/server/trpc/router/project.ts @@ -38,6 +38,7 @@ export const projectRouter = router({ }, include: { tests: { + orderBy: { createdAt: "asc" }, include: { options: true, }, diff --git a/packages/core/tests/base.test.ts b/packages/core/tests/base.test.ts index 81a16f9d..839666e1 100644 --- a/packages/core/tests/base.test.ts +++ b/packages/core/tests/base.test.ts @@ -1,5 +1,4 @@ import { Abby } from "../src/index"; -import { validateWeights } from "../src/mathHelpers"; import * as validation from "../src/validation"; const OLD_ENV = process.env; @@ -670,16 +669,3 @@ describe("Abby", () => { expect(abby.getRemoteConfig("remoteConfig1")).toBe("defaultremoteConfig1"); }); }); - -describe("Math helpers", () => { - it("validates weight", () => { - const variants = ["variant1", "variant2"]; - const weight = [0.25, 0.75]; - - const validatedWeights1 = validateWeights(variants, weight); - expect(validatedWeights1).toEqual([0.25, 0.75]); - - const validatedWeights2 = validateWeights(variants); - expect(validatedWeights2).toEqual([0.5, 0.5]); - }); -}); diff --git a/packages/core/tests/math.test.ts b/packages/core/tests/math.test.ts new file mode 100644 index 00000000..8e8b9667 --- /dev/null +++ b/packages/core/tests/math.test.ts @@ -0,0 +1,48 @@ +import { getWeightedRandomVariant, validateWeights } from "../src/mathHelpers"; + +describe("Math helpers", () => { + it("validates weight", () => { + const variants = ["variant1", "variant2"]; + const weight = [0.25, 0.75]; + + const validatedWeights1 = validateWeights(variants, weight); + expect(validatedWeights1).toEqual([0.25, 0.75]); + + const validatedWeights2 = validateWeights(variants); + expect(validatedWeights2).toEqual([0.5, 0.5]); + + expect(validateWeights(["a", "b", "c"], [0.3, 0.3, 0.3])).toEqual([ + 1 / 3, + 1 / 3, + 1 / 3, + ]); + }); + + it("gets proper variants", () => { + const variants = ["a", "b", "c"]; + expect(getWeightedRandomVariant(variants, [0.3, 0.3, 0.3])).oneOf(variants); + }); + + it("produces roughly equal distribution for equal weights", () => { + const variants = ["a", "b", "c"] as const; + type Variant = (typeof variants)[number]; + const weights = [1 / 3, 1 / 3, 1 / 3]; + const iterations = 10_000; + const counts: Record = { a: 0, b: 0, c: 0 }; + + // Run many iterations to get a statistically significant sample + for (let i = 0; i < iterations; i++) { + const result = getWeightedRandomVariant(variants, weights); + counts[result as Variant]++; + } + + // Check that each variant appears roughly 1/3 of the time (within 5% margin) + const expectedCount = iterations / 3; + const marginOfError = 0.05 * iterations; // 5% margin of error + + Object.values(counts).forEach((count) => { + expect(count).toBeGreaterThan(expectedCount - marginOfError); + expect(count).toBeLessThan(expectedCount + marginOfError); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4095a40f..a24dcf30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ importers: version: 18.2.4 next: specifier: 14.1.1 - version: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + version: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) next-plausible: specifier: ^3.11.3 version: 3.11.3(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -117,7 +117,7 @@ importers: version: 4.5.1(monaco-editor@0.39.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@next-auth/prisma-adapter': specifier: 1.0.5 - version: 1.0.5(@prisma/client@5.19.0(prisma@5.19.0))(next-auth@4.22.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)) + version: 1.0.5(@prisma/client@5.19.0(prisma@5.19.0))(next-auth@4.22.1(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)) '@next/mdx': specifier: 14.0.4 version: 14.0.4(@mdx-js/loader@3.0.0(webpack@5.92.1))(@mdx-js/react@3.0.0(@types/react@18.0.14)(react@18.2.0)) @@ -126,7 +126,7 @@ importers: version: 13.3.0 '@openpanel/nextjs': specifier: ^1.0.3 - version: 1.0.3(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 1.0.3(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@prisma/client': specifier: 5.19.0 version: 5.19.0(prisma@5.19.0) @@ -167,7 +167,7 @@ importers: specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-tooltip': - specifier: ^1.0.5 + specifier: ^1.0.6 version: 1.0.6(@types/react-dom@18.0.5)(@types/react@18.0.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-email/button': specifier: 0.0.5 @@ -204,7 +204,7 @@ importers: version: 0.0.3 '@sentry/nextjs': specifier: ^8 - version: 8.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react@18.2.0)(webpack@5.92.1) + version: 8.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react@18.2.0)(webpack@5.92.1) '@stripe/stripe-js': specifier: ^1.52.1 version: 1.54.0 @@ -234,7 +234,7 @@ importers: version: 10.30.0(@trpc/server@10.30.0) '@trpc/next': specifier: ^10.19.1 - version: 10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/react-query@10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/server@10.30.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.30.0)(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/react-query@10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/server@10.30.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.30.0)(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/react-query': specifier: ^10.19.1 version: 10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/server@10.30.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -312,31 +312,31 @@ importers: version: 2.1.3 next: specifier: 14.1.1 - version: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + version: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) next-auth: specifier: 4.22.1 - version: 4.22.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 4.22.1(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) next-plausible: specifier: ^3.12.0 - version: 3.12.0(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 3.12.0(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) next-seo: specifier: ^5.15.0 - version: 5.15.0(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 5.15.0(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) next-sitemap: specifier: ^3.1.55 - version: 3.1.55(@next/env@14.2.4)(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)) + version: 3.1.55(@next/env@14.2.4)(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)) next-themes: specifier: ^0.2.1 version: 0.2.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) nextjs-cors: specifier: ^2.1.2 - version: 2.1.2(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)) + version: 2.1.2(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)) nodemailer: specifier: ^6.9.1 version: 6.9.3 nuqs: specifier: ^1.17.8 - version: 1.17.8(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)) + version: 1.17.8(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)) octokit: specifier: ^4.0.2 version: 4.0.2 @@ -18383,10 +18383,10 @@ snapshots: pump: 3.0.0 tar-fs: 2.1.1 - '@next-auth/prisma-adapter@1.0.5(@prisma/client@5.19.0(prisma@5.19.0))(next-auth@4.22.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))': + '@next-auth/prisma-adapter@1.0.5(@prisma/client@5.19.0(prisma@5.19.0))(next-auth@4.22.1(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))': dependencies: '@prisma/client': 5.19.0(prisma@5.19.0) - next-auth: 4.22.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + next-auth: 4.22.1(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@next/bundle-analyzer@13.3.4': dependencies: @@ -18693,10 +18693,10 @@ snapshots: '@open-draft/until@1.0.3': {} - '@openpanel/nextjs@1.0.3(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@openpanel/nextjs@1.0.3(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@openpanel/web': 1.0.0 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -20100,7 +20100,7 @@ snapshots: '@sentry/types': 8.29.0 '@sentry/utils': 8.29.0 - '@sentry/nextjs@8.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react@18.2.0)(webpack@5.92.1)': + '@sentry/nextjs@8.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react@18.2.0)(webpack@5.92.1)': dependencies: '@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.27.0 @@ -20114,7 +20114,7 @@ snapshots: '@sentry/vercel-edge': 8.29.0 '@sentry/webpack-plugin': 2.22.3(encoding@0.1.13)(webpack@5.92.1) chalk: 3.0.0 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) resolve: 1.22.8 rollup: 3.29.4 stacktrace-parser: 0.1.10 @@ -21237,13 +21237,13 @@ snapshots: dependencies: '@trpc/server': 10.30.0 - '@trpc/next@10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/react-query@10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/server@10.30.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.30.0)(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@trpc/next@10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/react-query@10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/server@10.30.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.30.0)(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@tanstack/react-query': 4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/client': 10.30.0(@trpc/server@10.30.0) '@trpc/react-query': 10.30.0(@tanstack/react-query@4.29.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.30.0(@trpc/server@10.30.0))(@trpc/server@10.30.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/server': 10.30.0 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-ssr-prepass: 1.5.0(react@18.2.0) @@ -27168,13 +27168,13 @@ snapshots: neo-async@2.6.2: {} - next-auth@4.22.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next-auth@4.22.1(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nodemailer@6.9.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.22.5 '@panva/hkdf': 1.1.1 cookie: 0.5.0 jose: 4.14.4 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) oauth: 0.9.15 openid-client: 5.4.2 preact: 10.15.1 @@ -27198,38 +27198,38 @@ snapshots: next-plausible@3.11.3(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - next-plausible@3.12.0(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next-plausible@3.12.0(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - next-seo@5.15.0(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next-seo@5.15.0(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) next-seo@6.4.0(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - next-sitemap@3.1.55(@next/env@14.2.4)(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)): + next-sitemap@3.1.55(@next/env@14.2.4)(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 14.2.4 minimist: 1.2.8 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) next-themes@0.2.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -27242,7 +27242,7 @@ snapshots: postcss: 8.4.14 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.24.7)(react@18.2.0) watchpack: 2.4.0 zod: 3.21.4 optionalDependencies: @@ -27261,7 +27261,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8): + next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8): dependencies: '@next/env': 14.1.1 '@swc/helpers': 0.5.2 @@ -27271,7 +27271,7 @@ snapshots: postcss: 8.4.31 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.24.7)(react@18.2.0) optionalDependencies: '@next/swc-darwin-arm64': 14.1.1 '@next/swc-darwin-x64': 14.1.1 @@ -27288,10 +27288,10 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextjs-cors@2.1.2(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)): + nextjs-cors@2.1.2(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)): dependencies: cors: 2.8.5 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) nextra-theme-docs@2.13.2(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(nextra@2.13.2(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: @@ -27304,7 +27304,7 @@ snapshots: git-url-parse: 13.1.1 intersection-observer: 0.12.2 match-sorter: 6.3.1 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) next-seo: 6.4.0(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) next-themes: 0.2.1(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) nextra: 2.13.2(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -27327,7 +27327,7 @@ snapshots: gray-matter: 4.0.3 katex: 0.16.9 lodash.get: 4.4.2 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) next-mdx-remote: 4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) p-limit: 3.1.0 react: 18.2.0 @@ -27579,10 +27579,10 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@1.17.8(next@14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)): + nuqs@1.17.8(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)): dependencies: mitt: 3.0.1 - next: 14.1.1(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) nwsapi@2.2.5: {} @@ -29626,10 +29626,12 @@ snapshots: dependencies: inline-style-parser: 0.2.2 - styled-jsx@5.1.1(react@18.2.0): + styled-jsx@5.1.1(@babel/core@7.24.7)(react@18.2.0): dependencies: client-only: 0.0.1 react: 18.2.0 + optionalDependencies: + '@babel/core': 7.24.7 stylis@4.3.0: {}