Skip to content

Conversation

@marcusljf
Copy link
Collaborator

@marcusljf marcusljf commented Oct 17, 2025

This is a major push to make the A/B testing really functional and useful for people. Defining the new logic and helping clarify a few unknowns.

2-way sync between the destination url and the url in position 1

It was saving from the destination URL to the testing URL, but not the other way around. This is now synced for two-way.

Reorganization of opened link view

When the A/B test is open in the link view, the traffic percentage has been moved to the right for better alignment with the stats.
CleanShot 2025-10-17 at 14 01 58@2x

A/B test tooltip

When hovering the A/B test icon in the links view, we now show that the A/B test is running and provide the completion date.
CleanShot 2025-10-17 at 14 02 21@2x

Completion date visual warning

If a user selects a completion date further out than six weeks, the note changes to a red color to indicate that it's outside that window.
CleanShot 2025-10-17 at 14 05 03@2x

Appending http to urls

When adding testing urls, if HTTPS:// isn't added when focus isn't on the input, it adds it in, much like the destination URL.

End test selection

If the user is ending the test early, we now show the stats when they're selecting the URL as the winner.
CleanShot 2025-10-17 at 14 06 33@2x

Ending modal overflow

If the link overflows to ellipses, and the user can't see the entire URL that they added.
CleanShot 2025-10-17 at 14 08 01@2x

Winner logic updates

  • Select the link with the highest amount of conversions
  • If conversions are tied, select the link with the highest conversion rate (conversions ÷ clicks)
  • If still tied, select the link with the highest clicks

OR - If no conversions are recorded or tracked

  • Select the link with the highest number of leads
  • If leads are tied, select the link with the highest lead rate (leads ÷ clicks)
  • If still tied, select the link with the highest clicks

Other minor updates

  • All tool tips have been linked up to the recently published help doc.
  • Reduce padding between the traffic split label and the input.
  • Small content updates to the ending modal.

Summary by CodeRabbit

  • New Features

    • Deterministic A/B winner selection using conversions with a multi-step tie-breaker cascade; no winner if still tied.
    • End-of-test modal shows per-URL analytics with overflow-friendly tooltips and selection flow.
  • Improvements

    • Per-field URL inputs normalize on blur, validate, and sync with variants; prettier URL display and dynamic completion-date styling.
    • Analytics and UI consistently use URL normalization.
    • Analytics/preview links use encoded query parameters.
  • Removed

    • Legacy A/B testing modal and its prior UI/controls.

@vercel
Copy link
Contributor

vercel bot commented Oct 17, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 21, 2025 3:04am

💡 Enable Vercel Agent with $100 free credit for automated AI reviews

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 17, 2025

Walkthrough

Aggregates analytics by normalized URL (clicks, leads, sales, saleAmount), selects a deterministic winner via a multi-step tie-break cascade, normalizes/synchronizes per-field URLs in forms, removes the old ABTesting modal, adds a new EndABTesting modal using aggregated analytics, and fixes query encoding with URLSearchParams.

Changes

Cohort / File(s) Summary
Winner selection & analytics aggregation
apps/web/lib/api/links/complete-ab-tests.ts
Aggregate analytics by normalized URL into VariantMetrics (clicks, leads, sales, saleAmount). Compute conversions and rates, then select a deterministic winner using tie-break cascade: primary by conversions → conversion rate → clicks; fallback by leads → lead rate → clicks. Only act when a single deterministic winner exists; log when none found; ensure chosen winner URL ≠ original.
Destination input & form sync
apps/web/ui/links/destination-url-input.tsx
Normalize URL on blur (set form value shouldDirty:true) and sync normalized URL into first testVariants entry when present.
Tests list & analytics badge integration
apps/web/ui/links/link-tests.tsx
Build analyticsByNormalizedUrl (using normalizeUrl) from fetched rows; lookup aggregated metrics by normalized URL for each variant; use getPrettyUrl and compute normalizedTestUrl for keys, analytics lookup, and badges; move percentage badge rendering block.
Tests badge / completion date
apps/web/ui/links/tests-badge.tsx
Use formatDateTime to show formatted scheduled completion date, compute isBeyondSixWeeks for red styling, and conditionally render running/completion UI.
End-testing modal & analytics UI
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx
Add UrlWithTooltip, fetch analytics via useSWR, compute analyticsByNormalizedUrl, show LinkAnalyticsBadge (or skeleton) per variant, allow selecting a variant to end the test (updates form url and testCompletedAt).
AB testing modal removal / refactor
apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx
Removed previous ABTesting modal implementation and its exports (edit/complete UI, hooks, ABTestingButton, etc.).
AB testing modal refactor & editor
apps/web/ui/modals/link-builder/ab-testing-modal.tsx
Introduces per-field urlInputs state, normalizeForForm helper, consolidated per-field validation on submit, per-field editing/blur normalization, keyboard accessibility for sliders, and ABTestingComplete summary component; normalizes URLs before persisting and syncs first variant to primary url.
Analytics URL/iframe encoding fixes
apps/web/ui/links/link-analytics-badge.tsx, apps/web/ui/links/link-builder/options-list.tsx
Replace manual query string concatenation with URLSearchParams to build analytics and iframeable API URLs (ensures proper encoding); extend memo deps where appropriate.

Sequence Diagram(s)

sequenceDiagram
    participant Handler as complete-ab-tests
    participant DB as Analytics Store
    participant Norm as normalizeUrl()
    participant Agg as Aggregator
    participant Decide as Tie-breaker
    participant Persist as Persist/Logger

    Handler->>DB: fetch analytics rows for link
    DB-->>Handler: rows (raw URL, clicks, leads, sales, saleAmount)
    loop per row
      Handler->>Norm: normalize row.url
      Norm-->>Agg: sum metrics under normalized URL
    end
    Agg->>Decide: compute conversions & rates per normalized URL
    alt any conversions > 0
      Decide->>Decide: rank by conversions → conversionRate → clicks
    else no conversions
      Decide->>Decide: rank by leads → leadRate → clicks
    end
    Decide->>Persist: single deterministic winner?
    alt Winner found
      Persist->>Persist: ensure winner != original URL, persist change
    else No deterministic winner
      Persist->>Persist: log and do not change
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • steven-tey

Poem

🐰 I hopped through URLs tidy and neat,
I counted clicks, leads, and every beat.
Conversions first, then leads in line,
A deterministic carrot — chosen fine.
Hop, fix, encode — tests now complete!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title Check ❓ Inconclusive The PR title "Larger updates, fixes and changes to the a/b testing" is vague and generic, using non-descriptive language that does not clearly convey what was actually changed. While the title is not unrelated to the changeset (it does mention A/B testing), it employs phrases like "Larger updates," "fixes and changes" that lack specificity and could apply to almost any A/B testing maintenance PR. The changeset contains several significant and distinct modifications including a major winner selection logic overhaul with new deterministic criteria, URL normalization and two-way sync implementation, UI reorganization with completion date warnings, and analytics aggregation improvements, but the title does not highlight any of these primary changes. A developer scanning the commit history would not gain meaningful information about what specifically was implemented from this title alone. Consider revising the title to be more specific and descriptive. For example, focus on one of the primary changes such as "Update A/B test winner selection logic with conversion metrics and tie-breaking," "Add URL normalization and two-way sync for A/B test variants," or "Refactor A/B testing modal with completion status indicators and analytics display." This would allow teammates to quickly understand the main scope of the change when reviewing commit history.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ab-testing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Changed back to "highest performing" to cover both leads and conversions.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (1)

51-67: Complete the AB testing modal refactor—migrate all imports to the nested path or remove the duplicate.

The two ab-testing-modal implementations are diverging: the new nested version (apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx) has improved component composition (ABTestingModal wrapper + ABTestingModalInner), but all three active consumers still import from the old path (@/ui/modals/link-builder/ab-testing-modal), leaving the new version unused. This suggests an incomplete refactor.

Recommended action:

  • Migrate imports in link-feature-buttons.tsx, more-dropdown.tsx, and constants.ts to the new nested path, or
  • Remove the new nested file and continue using the old implementation.

Leaving both in place risks maintaining two diverging codebases.

apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (1)

230-239: Stale prop risk: include onEndTest in useCallback deps.

Without it, updated callbacks won’t propagate.

-  }, [showEndABTestingModal, setShowEndABTestingModal]);
+  }, [showEndABTestingModal, setShowEndABTestingModal, onEndTest]);
🧹 Nitpick comments (16)
apps/web/ui/links/destination-url-input.tsx (1)

115-128: Great: https auto-append + first-variant sync. Two small tweaks.

  • Comment says “set the https:// prefix” but code only trims a trailing slash. Adjust comment for accuracy.
  • Trim multiple trailing slashes to avoid “//” artifacts.
-                // remove trailing slash and set the https:// prefix
-                const normalizedUrl = url.replace(/\/$/, "");
+                // append scheme if missing (handled by getUrlFromString) and remove trailing slashes
+                const normalizedUrl = url.replace(/\/+$/, "");

Optional: only set testVariants.0.url when it actually changes to avoid unnecessary dirtiness.

-                if (Array.isArray(testVariants) && testVariants.length > 0) {
+                if (Array.isArray(testVariants) && testVariants.length > 0) {
+                  const current0 = formContext.getValues("testVariants.0.url" as any);
+                  if (current0 !== normalizedUrl) {
                    formContext.setValue(
                      "testVariants.0.url" as any,
                      normalizedUrl,
                      { shouldDirty: true },
                    );
+                  }
                 }
apps/web/ui/links/link-tests.tsx (3)

57-92: Solid normalization-based aggregation; confirm intended grouping semantics.

Using normalizeUrl collapses protocol/query/hash differences. This merges http/https and strips UTM parameters when aggregating. If that’s the desired behavior for A/B test analytics, this is perfect; otherwise, consider a custom key that preserves protocol or selected query keys.

Would you like me to draft a small helper like normalizeUrlForABTestKey(hostname + pathname only) and wire it here and in complete-ab-tests.ts for consistency?


104-106: Lookup key consistency.

normalizeUrl(test.url) matches the map’s key generation above—good. If you ever change the key function, extract it to a shared util to keep UI and server logic in lockstep.


146-151: UI nit.

Percentage is rounded; if you later allow fractional splits in the slider, consider toFixed(1) for display while keeping integers in storage.

apps/web/lib/api/links/complete-ab-tests.ts (3)

35-59: Aggregation matches UI logic; be explicit about protocol/query collapsing.

normalizeUrl merges http/https and drops queries and fragments. That’s consistent with link-tests.tsx but changes attribution if variants differ only by UTM. If this is intentional, add a brief comment to document the decision.

-  // Aggregate analytics by normalized URL for stable matching with variants
+  // Aggregate by normalized URL (hostname + pathname). Intentionally collapses protocol and query/hash
+  // so http/https and UTM variants are grouped under the same destination.

79-114: Deterministic tie-breakers look good; consider extracting comparator + optional saleAmount tie.

Logic is correct and readable. Two small improvements:

  • Extract a compareVariants(a,b) to reduce branching and make this unit-testable.
  • Optional: when conversions tie and rates tie, consider saleAmount as an extra tie-breaker before clicks.

I can submit a follow-up refactor + unit tests for these paths if you want.

Also applies to: 115-144


146-154: Non-deterministic case handling: log + early return.

Good call to avoid random selection. Prefer structured logging (with link.id, projectId, window) over console.log for observability.

apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)

299-359: URL onBlur normalization is consistent; small robustness tweak.

  • Use //+$/ to trim multiple trailing slashes.
  • Consider updating parent destination URL immediately when editing index 0 (today it updates on Save) if you want truly live two-way sync.
-                            const normalizedUrl = url.replace(/\/$/, "");
+                            const normalizedUrl = url.replace(/\/+$/, "");

If live sync is desired:

+                            if (index === 0) {
+                              setValueParent("url", normalizedUrl, { shouldDirty: true });
+                            }

465-474: 6‑week visual warning works; edge of day rounding.

differenceInDays truncates; near the 6‑week boundary, users might see non-red until the next day. Acceptable, but flagging in case you want strict > 42 days using differenceInMilliseconds.

apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (2)

342-401: Per-field onBlur normalization mirrors Destination input; unify slash trimming.

Same suggestion: use //+$/ and optionally live-update parent url when index === 0 for immediate two-way sync.

-                              const normalizedUrl = url.replace(/\/$/, "");
+                              const normalizedUrl = url.replace(/\/+$/, "");

457-460: Completion date UX.

  • Copy + anchor updates LGTM.
  • Red text hint is correct; consider milliseconds for exact >6 weeks if precision matters.

Also applies to: 507-517

apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (5)

76-97: SWR key: return null when disabled; avoid building the URL eagerly.

Use a memoized key function that returns null until all dependencies are ready. Cleaner and prevents accidental fetches on falsy keys.

-  const { data, error, isLoading } = useSWR<
+  const analyticsKey = useMemo(() => {
+    if (!(testVariants?.length && linkId && workspaceId)) return null;
+    const qs = new URLSearchParams({
+      event: "composite",
+      groupBy: "top_urls",
+      linkId: linkId as string,
+      workspaceId: workspaceId!,
+      ...(testStartedAt && {
+        start: new Date(testStartedAt as Date).toISOString(),
+      }),
+    }).toString();
+    return `/api/analytics?${qs}`;
+  }, [testVariants, linkId, workspaceId, testStartedAt]);
+
+  const { data, error, isLoading } = useSWR<
     {
       url: string;
       clicks: number;
       leads: number;
       saleAmount: number;
       sales: number;
     }[]
-  >(
-    Boolean(testVariants && testVariants.length && linkId && workspaceId) &&
-      `/api/analytics?${new URLSearchParams({
-        event: "composite",
-        groupBy: "top_urls",
-        linkId: linkId as string,
-        workspaceId: workspaceId!,
-        ...(testStartedAt && {
-          start: new Date(testStartedAt as Date).toISOString(),
-        }),
-      }).toString()}`,
-    fetcher,
-    { revalidateOnFocus: false },
-  );
+  >(analyticsKey, fetcher, { revalidateOnFocus: false });

22-27: Make overflow detection responsive.

Recompute on container resize; current effect runs only on URL change.

-  useEffect(() => {
-    const element = textRef.current;
-    if (element) {
-      setIsOverflowing(element.scrollWidth > element.clientWidth);
-    }
-  }, [url]);
+  useEffect(() => {
+    const el = textRef.current;
+    if (!el) return;
+    const compute = () =>
+      setIsOverflowing(el.scrollWidth > el.clientWidth);
+    compute();
+    const ro = new ResizeObserver(compute);
+    ro.observe(el);
+    return () => ro.disconnect();
+  }, [url]);

141-143: A11y: use radio semantics for the selection list.

Declare the group as a radiogroup for screen readers.

-          <div className="mt-4 flex flex-col gap-2">
+          <div
+            className="mt-4 flex flex-col gap-2"
+            role="radiogroup"
+            aria-label="Select winning URL"
+          >

142-146: Avoid repeated getValues calls inside the map.

Call once per render outside the loop.

-            {testVariants?.map((test, index) => {
-              const normalized = normalizeUrl(test.url);
-              const analytics = analyticsByNormalizedUrl?.get(normalized);
-              const link = getValuesParent();
+            {/* move this above the map in component body if preferred */}
+            {testVariants?.map((test, index) => {
+              const normalized = normalizeUrl(test.url);
+              const analytics = analyticsByNormalizedUrl?.get(normalized);
+              const link = getValuesParent();

Alternatively, hoist:

@@
-  return (
+  const link = getValuesParent();
+  return (

…and remove the inner declaration at Line 145.


206-215: Reset selection on close/confirm for a clean next open.

Prevents stale selection persisting across modal sessions.

             onClick={() => {
               if (selectedUrl) {
                 setValueParent("url", selectedUrl, { shouldDirty: true });
                 setValueParent("testCompletedAt", new Date(), {
                   shouldDirty: true,
                 });
+                setSelectedUrl(null);
                 setShowEndABTestingModal(false);
                 onEndTest?.();
               }
             }}

Optionally also clear on hide:

@@ line 72
-  const [selectedUrl, setSelectedUrl] = useState<string | null>(null);
+  const [selectedUrl, setSelectedUrl] = useState<string | null>(null);
+  useEffect(() => {
+    if (!showEndABTestingModal) setSelectedUrl(null);
+  }, [showEndABTestingModal]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ef1e6fa and e201cdd.

📒 Files selected for processing (7)
  • apps/web/lib/api/links/complete-ab-tests.ts (2 hunks)
  • apps/web/ui/links/destination-url-input.tsx (1 hunks)
  • apps/web/ui/links/link-tests.tsx (5 hunks)
  • apps/web/ui/links/tests-badge.tsx (2 hunks)
  • apps/web/ui/modals/link-builder/ab-testing-modal.tsx (5 hunks)
  • apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (6 hunks)
  • apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
apps/web/lib/api/links/complete-ab-tests.ts (2)
apps/web/lib/analytics/get-analytics.ts (1)
  • getAnalytics (20-251)
packages/utils/src/functions/urls.ts (1)
  • normalizeUrl (173-180)
apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (2)
  • isValidUrl (1-8)
  • getUrlFromString (10-18)
apps/web/lib/zod/schemas/links.ts (1)
  • MAX_TEST_COUNT (30-30)
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (1)
  • normalizeUrl (173-180)
apps/web/ui/links/link-analytics-badge.tsx (1)
  • LinkAnalyticsBadge (28-192)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (2)
  • isValidUrl (1-8)
  • getUrlFromString (10-18)
apps/web/lib/zod/schemas/links.ts (1)
  • MAX_TEST_COUNT (30-30)
apps/web/ui/links/link-tests.tsx (1)
packages/utils/src/functions/urls.ts (1)
  • normalizeUrl (173-180)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (8)
apps/web/ui/links/tests-badge.tsx (3)

4-4: LGTM!

The formatDateTime import is correctly added and used for formatting the test completion date.


34-34: LGTM! Note on overflow handling.

The styling changes look appropriate. Removing overflow-hidden aligns with the PR's goal of improving overflow handling for long content. The increased padding (p-3) provides better spacing for the new centered text layout.


16-25: No issues found — formatDateTime already handles invalid dates.

The review comment's concern is unfounded. The formatDateTime function already includes a guard: if (datetime.toString() === "Invalid Date") return "";, so any invalid date string passed to it will safely return an empty string rather than cause unexpected behavior. The date formatting logic in tests-badge.tsx (lines 16-25) is correct and properly handles edge cases.

Likely an incorrect or invalid review comment.

apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)

389-389: Spacing/UI.

mt-2 matches the reduced padding objective. LGTM.


414-417: Tooltip copy updated.

Copy change aligns with new help doc. LGTM.

apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (2)

330-331: Help link anchor update.

Anchor update looks good.


279-283: Start timestamp.

Setting testStartedAt on first save is a nice touch for analytics windows. LGTM.

apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (1)

99-125: Confirm normalization semantics: query string is dropped.

normalizeUrl keeps hostname+pathname only. If variants differ via query params (e.g., utm vs. content changes), analytics will be merged.

Is this intentional for winner selection and display? If not, consider preserving or selectively stripping queries (e.g., drop known tracking params only).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (8)
apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (1)

371-384: Improve trailing slash removal to handle multiple slashes.

The current regex /\/$/ only removes a single trailing slash. URLs with multiple trailing slashes (e.g., https://example.com///) would not be fully normalized.

Apply this diff:

                          onBlur={(e) => {
                            field.onBlur(e);
                            const url = getUrlFromString(e.target.value);
                            if (url) {
-                              const normalizedUrl = url.replace(/\/$/, "");
+                              const normalizedUrl = url.replace(/\/+$/, "");
                              setValue(
                                `testVariants.${index}.url`,
                                normalizedUrl,
                                {
                                  shouldDirty: true,
                                },
                              );
                            }
                          }}
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (7)

23-29: Centralize URL normalization.

Good call importing getUrlFromString. Consider introducing/using a single normalizeUrl helper (scheme add, trim, trailing slash policy) across inputs to avoid drift between fields and analytics. Right now we partially normalize (only trailing “/”).


285-288: Make help copy dynamic w.r.t MAX_TEST_COUNT.

Avoid hard-coding “3 additional”; derive from MAX_TEST_COUNT to prevent future drift.

-                  title="Add up to 3 additional destination URLs to test for this short link."
+                  title={`Add up to ${Math.max(0, MAX_TEST_COUNT - 1)} additional destination URLs to test for this short link.`}

229-233: Guard against duplicate URLs.

Duplicated variants skew analytics and winner selection. Add a uniqueness check before save.

             // Validate all URLs are filled
             if (currentTests.some((test) => !test.url)) {
               toast.error("All test URLs must be filled");
               return;
             }
+            // Validate URLs are unique (case-insensitive)
+            const urls = currentTests.map((t) => t.url.trim().toLowerCase());
+            if (urls.some((u, i) => urls.indexOf(u) !== i)) {
+              toast.error("Each testing URL must be unique");
+              return;
+            }

464-474: Six‑week warning styling only changes color; consider an accessible status.

Optional: add role="status" or aria-live="polite" so screen readers notice when the warning turns red; also include the threshold in text for clarity (e.g., “Set within 6 weeks to enable saving”).


533-537: Minor: typo and small cleanup.

  • Fix “competion” → “completion”.
  • Compute difference once to avoid repeated calls.
-                    // Restrict competion date from -1 days to 6 weeks
-                    (differenceInDays(testCompletedAt, new Date()) > 6 * 7 ||
-                      differenceInDays(testCompletedAt, new Date()) < -1),
+                    // Restrict completion date from -1 days to 6 weeks
+                    ((d => d > 6 * 7 || d < -1)(
+                      differenceInDays(testCompletedAt, new Date())
+                    )),

689-723: Optional: stable keys to preserve focus when removing rows.

Using index as key may cause focus jumps when deleting in the middle. If feasible, add a stable id to each test variant and use it as key.


657-660: Comment accuracy.

Comment says “minimum 10%”, but you enforce MIN_TEST_PERCENTAGE. Update comment to avoid confusion.

-      // Ensure minimum 10% for each test
+      // Ensure minimum MIN_TEST_PERCENTAGE% for each test
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e201cdd and 0d1fa49.

📒 Files selected for processing (2)
  • apps/web/ui/modals/link-builder/ab-testing-modal.tsx (6 hunks)
  • apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (2)
  • isValidUrl (1-8)
  • getUrlFromString (10-18)
apps/web/lib/zod/schemas/links.ts (1)
  • MAX_TEST_COUNT (30-30)
apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (2)
  • isValidUrl (1-8)
  • getUrlFromString (10-18)
apps/web/lib/zod/schemas/links.ts (1)
  • MAX_TEST_COUNT (30-30)
🔇 Additional comments (7)
apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (6)

27-27: LGTM!

The import of getUrlFromString is necessary for the URL normalization feature implemented in the onBlur handler.


330-330: LGTM!

The anchor link provides better navigation to the specific help documentation section.


386-398: LGTM!

The Remove button is correctly restricted to URLs after the first one (index > 0), ensuring the first testing URL remains in sync with the destination URL as per the PR objectives.


431-431: LGTM!

The reduced spacing aligns with the PR objective to tighten the layout between the traffic split label and input.


457-459: LGTM!

The wording change to "Schedule" is clearer, and the updated anchor link provides better navigation to the relevant documentation section.


273-273: Verify two-way sync from destination URL to testVariants[0].

Line 273 syncs testVariants[0].url to the parent's main URL (destination URL), but the reverse sync (destination URL → testVariants[0].url) is not implemented. The PR objectives mention "two-way sync" between these URLs.

Ensure that when the destination URL changes, testVariants[0].url is updated accordingly. This may require adding a watch or useEffect hook in the parent form component or the main form controller to propagate destination URL changes back to the first test variant.

apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)

413-418: Verify help anchor.

Confirm the ab-testing#completion-date-has-passed anchor exists and matches the doc section title to avoid 404/scroll misses.

Replaces the legacy ab-testing-modal implementation with a refactored version, improving URL validation, input handling, and user experience. Updates related components to use encodeURIComponent for URLs, display pretty URLs, and streamline modal state management. Removes the obsolete ab-testing-modal file and updates references to utility functions and keyboard shortcuts.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/ui/links/link-builder/options-list.tsx (1)

155-163: Harden iframe preview (scheme check, sandbox, a11y).

Avoid javascript:/data: in iframes; add sandbox and title.

-              <iframe
-                src={url}
-                style={{
-                  zoom: 0.5,
-                }}
-                className="h-[500px] w-[888px]"
-              />
+              <iframe
+                src={/^https?:\/\//i.test(url) ? url : undefined}
+                title="Link cloaking preview"
+                sandbox="allow-scripts allow-popups"
+                referrerPolicy="no-referrer"
+                style={{ zoom: 0.5 }}
+                className="h-[500px] w-[888px]"
+              />

Optionally replace non‑standard zoom with CSS transform for Safari.

♻️ Duplicate comments (2)
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (1)

147-156: Fix unstable/invalid key; add radio semantics to item.

test.id doesn’t exist on this type; use a stable key (URL). Also add role/aria-checked.

-                <button
-                  key={test.id}
+                <button
+                  key={normalizeUrl(test.url) || test.url}
                   type="button"
+                  role="radio"
+                  aria-checked={selectedUrl === test.url}
                   onClick={() => setSelectedUrl(test.url)}
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)

411-428: Normalize on blur: trim, strip multi trailing slashes, validate, and two‑way sync index 0.

Current code misses trim, doesn’t set shouldValidate/shouldTouch, and doesn’t sync index 0 back to parent on blur.

-                          onBlur={(e) => {
-                            const url = getUrlFromString(e.target.value);
-                            if (url) {
-                              const normalizedUrl = url.replace(/\/$/, "");
-                              setValue(
-                                `testVariants.${index}.url`,
-                                normalizedUrl,
-                                {
-                                  shouldDirty: true,
-                                },
-                              );
-                              // Clear the local input state to show the pretty URL
-                              setUrlInputs((prev) => ({
-                                ...prev,
-                                [index]: undefined,
-                              }));
-                            }
-                          }}
+                          onBlur={(e) => {
+                            const raw = e.target.value.trim();
+                            const url = getUrlFromString(raw);
+                            if (url) {
+                              const normalizedUrl = url.replace(/\/+$/, "");
+                              setValue(`testVariants.${index}.url`, normalizedUrl, {
+                                shouldDirty: true,
+                                shouldValidate: true,
+                                shouldTouch: true,
+                              });
+                              if (index === 0) {
+                                setValueParent("url", normalizedUrl, { shouldDirty: true });
+                              }
+                              setUrlInputs((prev) => ({ ...prev, [index]: undefined }));
+                            }
+                          }}
🧹 Nitpick comments (8)
apps/web/ui/links/link-builder/options-list.tsx (2)

124-129: Good fix; encode domain too (or use URLSearchParams).

encodeURIComponent(debouncedUrl) is correct. Also encode domain to avoid edge cases. Prefer URLSearchParams for both.

-  ? `/api/links/iframeable?domain=${domain}&url=${encodeURIComponent(debouncedUrl)}`
+  ? `/api/links/iframeable?${new URLSearchParams({
+      domain,
+      url: debouncedUrl,
+    }).toString()}`

131-148: Memo deps include props for safety.

badge useMemo depends on toggle/onRemove; add to deps to avoid stale closures.

-  }, [data, isLoading]);
+  }, [data, isLoading, toggle, onRemove]);
apps/web/ui/links/link-analytics-badge.tsx (1)

149-156: Nice: URL param is now encoded. Consider URLSearchParams for all query parts.

This prevents breakage when url has &/? etc. For full safety and readability, build the href with URLSearchParams (encodes domain/key/interval too).

- href={`/${slug}/analytics?domain=${domain}&key=${key}${url ? `&url=${encodeURIComponent(url)}` : ""}&interval=${plan === "free" ? "30d" : plan === "pro" ? "1y" : "all"}`}
+ {(() => {
+   const q = new URLSearchParams({
+     domain,
+     key,
+     interval: plan === "free" ? "30d" : plan === "pro" ? "1y" : "all",
+   });
+   if (url) q.set("url", url);
+   return `/${slug}/analytics?${q.toString()}`;
+ })()}
apps/web/ui/links/link-tests.tsx (1)

103-151: Use a stable key per test (avoid index).

Index keys cause reconciliation issues on re‑order/delete. Use normalized URL.

-        {testVariants.map((test, idx) => {
+        {testVariants.map((test, idx) => {
           const normalizedTestUrl = normalizeUrl(test.url);
           const analytics = analyticsByNormalizedUrl?.get(normalizedTestUrl);
-
-          return (
-            <li
-              key={idx}
+          return (
+            <li
+              key={normalizedTestUrl || idx}
               className="flex items-center justify-between rounded-md border border-neutral-300 bg-white p-2.5"
             >
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (3)

18-49: Overflow detection won’t update on resize.

Add a ResizeObserver to recompute isOverflowing on container resize.

-  useEffect(() => {
-    const element = textRef.current;
-    if (element) {
-      setIsOverflowing(element.scrollWidth > element.clientWidth);
-    }
-  }, [url]);
+  useEffect(() => {
+    const el = textRef.current;
+    if (!el) return;
+    const compute = () => setIsOverflowing(el.scrollWidth > el.clientWidth);
+    compute();
+    const ro = new ResizeObserver(compute);
+    ro.observe(el);
+    return () => ro.disconnect();
+  }, [url]);

142-151: Add radiogroup semantics for accessibility.

Mark the list as a radio group.

-          <div className="mt-4 flex flex-col gap-2">
+          <div className="mt-4 flex flex-col gap-2" role="radiogroup" aria-label="Select winning URL">

142-188: Hoist getValuesParent() out of map.

Avoid re-reading form values per row.

-            {testVariants?.map((test, index) => {
-              const normalized = normalizeUrl(test.url);
-              const analytics = analyticsByNormalizedUrl?.get(normalized);
-              const link = getValuesParent();
+            const parentLink = getValuesParent();
+            {testVariants?.map((test, index) => {
+              const normalized = normalizeUrl(test.url);
+              const analytics = analyticsByNormalizedUrl?.get(normalized);
               return (
                 <button
 ...
-                      <LinkAnalyticsBadge
+                      <LinkAnalyticsBadge
                         link={{
-                          ...(link as any),
+                          ...(parentLink as any),
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)

383-456: Input type vs. displayed value.

You render pretty URLs without scheme into type="url", which can trigger native invalid state. Either always show full URL while focused (you do) or switch to type="text" with inputMode="url".

-                        <input
-                          type="url"
+                        <input
+                          type="text"
+                          inputMode="url"
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0d1fa49 and 74e0886.

📒 Files selected for processing (6)
  • apps/web/ui/links/link-analytics-badge.tsx (1 hunks)
  • apps/web/ui/links/link-builder/options-list.tsx (1 hunks)
  • apps/web/ui/links/link-tests.tsx (5 hunks)
  • apps/web/ui/modals/link-builder/ab-testing-modal.tsx (14 hunks)
  • apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx (0 hunks)
  • apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (4 hunks)
💤 Files with no reviewable changes (1)
  • apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (2)
  • getPrettyUrl (130-138)
  • normalizeUrl (173-180)
apps/web/ui/links/link-analytics-badge.tsx (1)
  • LinkAnalyticsBadge (28-192)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)
packages/utils/src/functions/urls.ts (3)
  • isValidUrl (1-8)
  • getPrettyUrl (130-138)
  • getUrlFromString (10-18)
apps/web/ui/links/link-tests.tsx (1)
packages/utils/src/functions/urls.ts (2)
  • normalizeUrl (173-180)
  • getPrettyUrl (130-138)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
apps/web/ui/links/link-tests.tsx (1)

57-92: Aggregation by normalized URL looks solid.

Clean summation with null guards; Map avoids N^2 scans. LGTM.

apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)

565-575: 6‑week visual cue looks correct.

Clear condition and text. LGTM.


785-788: Keyboard shortcut wired to modal context.

Good use of modal=true to avoid global conflicts. LGTM.

Refactors URL normalization logic in A/B testing modals to trim whitespace, ensure protocol, and remove trailing slashes for consistency with analytics. Updates key props in test lists for stability, enhances accessibility in radio group selection, and improves overflow detection in tooltips. Also updates query parameter construction to use URLSearchParams for better encoding and maintainability.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
apps/web/ui/links/tests-badge.tsx (1)

16-29: Six‑week warning implemented as specified.

Date derivation and conditional coloring look good; resolves the earlier review concern.

Also applies to: 37-60

apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (1)

188-194: Good fix for nested interactive controls.

Capturing click to prevent inner Link navigation addresses the invalid nested interactive pattern.

🧹 Nitpick comments (9)
apps/web/ui/links/link-analytics-badge.tsx (1)

87-94: Unify href construction and guard undefined plan.

Reuse the same URLSearchParams block for mobile and default interval when plan is not yet loaded to avoid briefly linking to “all”.

-    <Link
-      href={`/${slug}/analytics?domain=${domain}&key=${key}`}
+    <Link
+      href={`/${slug}/analytics?${(() => {
+        const interval =
+          plan ? (plan === "free" ? "30d" : plan === "pro" ? "1y" : "all") : "30d";
+        const params = new URLSearchParams({ domain, key, interval });
+        if (url) params.set("url", url);
+        return params.toString();
+      })()}`}
       className="flex items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 px-2 py-0.5 text-sm text-neutral-800"
     >

Also applies to: 150-158

apps/web/ui/links/link-tests.tsx (2)

31-55: Use null for disabled SWR keys.

Passing false is accepted but not idiomatic; prefer null for clarity and typings.

-  >(
-    Boolean(testVariants && testVariants.length) &&
-      showTests &&
-      `/api/analytics?${new URLSearchParams({
+  >(
+    testVariants?.length && showTests
+      ? `/api/analytics?${new URLSearchParams({
         event: "composite",
         groupBy: "top_urls",
         linkId: link.id,
         workspaceId: workspaceId!,
         ...(link.testStartedAt && {
           start: new Date(link.testStartedAt).toISOString(),
         }),
-      }).toString()}`,
+      }).toString()}`
+      : null,
     fetcher,
     {
       revalidateOnFocus: false,
     },
   );

104-110: Pass normalized URL to analytics and keep badge on errors.

  • Use normalizedTestUrl for the url param to align with analytics normalization.
  • Instead of hiding the badge on error, render it with zeroed metrics for consistent UI.
-          const normalizedTestUrl = normalizeUrl(test.url);
+          const normalizedTestUrl = normalizeUrl(test.url);
           const analytics = analyticsByNormalizedUrl?.get(normalizedTestUrl);
...
-                <div className="flex justify-end sm:min-w-48">
-                  {isLoading ? (
+                <div className="flex justify-end sm:min-w-48">
+                  {isLoading ? (
                     <div className="h-7 w-32 animate-pulse rounded-md bg-neutral-100" />
-                  ) : error ? null : (
+                  ) : (
                     <LinkAnalyticsBadge
                       link={{
                         ...link,
                         clicks: analytics?.clicks ?? 0,
                         leads: analytics?.leads ?? 0,
                         sales: analytics?.sales ?? 0,
                         saleAmount: analytics?.saleAmount ?? 0,
                       }}
-                      url={test.url}
+                      url={normalizedTestUrl}
                       sharingEnabled={false}
                     />
                   )}
                 </div>

Also applies to: 126-151

apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (3)

96-108: Prefer null over false for disabled SWR keys.

Clearer, typed, and matches SWR docs.

-  >(
-    Boolean(testVariants && testVariants.length && linkId && workspaceId) &&
-      `/api/analytics?${new URLSearchParams({
+  >(
+    testVariants?.length && linkId && workspaceId
+      ? `/api/analytics?${new URLSearchParams({
         event: "composite",
         groupBy: "top_urls",
         linkId: linkId as string,
         workspaceId: workspaceId!,
         ...(testStartedAt && {
           start: new Date(testStartedAt as Date).toISOString(),
         }),
-      }).toString()}`,
+      }).toString()}`
+      : null,
     fetcher,
     { revalidateOnFocus: false },
   );

164-165: Reuse normalized value and ensure unique keys.

Avoid recomputing normalizeUrl and guard against duplicate normalized URLs.

-                  <button
-                    key={normalizeUrl(test.url) || test.url}
+                  <button
+                    key={normalized || `${test.url}-${index}`}

158-161: Avoid name shadowing and any; clarify intent.

Rename local form value to prevent confusion with the LinkAnalyticsBadge prop and add a minimal type.

-              const link = getValuesParent();
+              const parentLink = getValuesParent() as Partial<ResponseLink>;
...
-                          link={{
-                            ...(link as any),
+                          link={{
+                            ...(parentLink as any),
                             clicks: analytics?.clicks ?? 0,
                             leads: analytics?.leads ?? 0,
                             sales: analytics?.sales ?? 0,
                             saleAmount: analytics?.saleAmount ?? 0,
                           }}

Also applies to: 199-205

apps/web/ui/modals/link-builder/ab-testing-modal.tsx (3)

148-195: Guard add-split when no variant ≥ 2× min.

findLastIndex can return -1; fall back to the last item to avoid undefined access.

-      const toSplitIndex = testVariants.findLastIndex(
-        ({ percentage }) => percentage >= MIN_TEST_PERCENTAGE * 2,
-      );
-      const toSplit = testVariants[toSplitIndex];
+      const toSplitIndexRaw = testVariants.findLastIndex(
+        ({ percentage }) => percentage >= MIN_TEST_PERCENTAGE * 2,
+      );
+      const toSplitIndex =
+        toSplitIndexRaw === -1 ? testVariants.length - 1 : toSplitIndexRaw;
+      const toSplit = testVariants[toSplitIndex];

385-455: Deduplicate normalization into a helper.

Define a small normalizeForForm helper and reuse in onBlur and submit/save to keep behavior identical.

+// top-level (near other utils)
+const normalizeForForm = (raw: string) =>
+  getUrlFromString(raw.trim()).replace(/\/+$/, "");
...
-                            const raw = e.target.value.trim();
-                            const url = getUrlFromString(raw);
-                            if (url) {
-                              const normalizedUrl = url.replace(/\/+$/, "");
+                            const normalizedUrl = normalizeForForm(e.target.value);
+                            if (normalizedUrl) {
...
-            const normalizedTests = currentTests.map((test) => {
-              const trimmedUrl = test.url.trim();
-              const urlWithProtocol = getUrlFromString(trimmedUrl);
-              const normalizedUrl = urlWithProtocol.replace(/\/+$/, "");
+            const normalizedTests = currentTests.map((test) => {
+              const normalizedUrl = normalizeForForm(test.url || "");
               return {
                 ...test,
                 url: normalizedUrl,
               };
             });

Also applies to: 291-309, 689-699


854-961: Add keyboard accessibility to the slider.

Current slider is mouse-only. Provide arrow key support and ARIA (role="slider", aria-valuemin/max/now). Improves a11y without changing visuals.

I can draft a minimal accessible handler (Left/Right adjust ±1%, honoring MIN_TEST_PERCENTAGE).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 74e0886 and 304b79d.

📒 Files selected for processing (6)
  • apps/web/ui/links/link-analytics-badge.tsx (1 hunks)
  • apps/web/ui/links/link-builder/options-list.tsx (2 hunks)
  • apps/web/ui/links/link-tests.tsx (5 hunks)
  • apps/web/ui/links/tests-badge.tsx (2 hunks)
  • apps/web/ui/modals/link-builder/ab-testing-modal.tsx (14 hunks)
  • apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/links/link-builder/options-list.tsx
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (2)
  • getPrettyUrl (130-138)
  • normalizeUrl (173-180)
apps/web/ui/links/link-analytics-badge.tsx (1)
  • LinkAnalyticsBadge (28-200)
apps/web/ui/links/link-tests.tsx (1)
packages/utils/src/functions/urls.ts (2)
  • normalizeUrl (173-180)
  • getPrettyUrl (130-138)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)
packages/utils/src/functions/urls.ts (3)
  • isValidUrl (1-8)
  • getUrlFromString (10-18)
  • getPrettyUrl (130-138)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (5)
apps/web/ui/links/link-analytics-badge.tsx (1)

150-158: Correct: safe query construction with URLSearchParams.

Fixes encoding pitfalls for url/domain/key/interval. Good change.

apps/web/ui/links/link-tests.tsx (1)

57-92: Aggregation by normalized URL is solid.

Handles duplicates, nullish values, and consolidates metrics correctly.

apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (1)

110-136: Aggregation looks correct.

Normalization + summation handles duplicates and nullish metrics well.

apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)

132-146: Cleaner URL validation separation.

Per-form validateUrls isolates cross-field rules; good structure.


299-317: Normalization + two‑way sync achieved.

Trim → getUrlFromString → strip trailing slashes, then sync index 0 to parent url; meets PR objectives.

Also applies to: 310-316

Refactored URL normalization in AB testing modals for consistency and accessibility. Enhanced TrafficSplitSlider with keyboard support and improved focus handling. Updated LinkAnalyticsBadge and related components to ensure correct query parameter construction and key usage.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
apps/web/ui/links/link-tests.tsx (1)

103-111: Use a stable, non-colliding key.

Two tests can normalize to the same URL; keys may collide. Include the index.

- key={normalizedTestUrl || `${test.url}-${idx}`}
+ key={`${normalizedTestUrl}-${idx}`}
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (2)

190-211: Avoid nested interactive controls; prefer non-interactive badge variant.

A Link (inside LinkAnalyticsBadge) within a button is invalid. Stopping propagation prevents navigation but not semantics. Expose a non-interactive mode in LinkAnalyticsBadge (render a

/) and use it here.


165-171: Strengthen list keys.

Keys may collide if two URLs normalize the same. Include the index for stability.

- key={normalized || `${test.url}-${index}`}
+ key={`${normalized}-${index}`}
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (3)

381-388: Avoid index keys in dynamic lists.

Index keys can mis-associate inputs after add/remove. Prefer a stable key.

-<div key={index} className="flex items-center gap-2">
+<div key={`${test.url}-${index}`} className="flex items-center gap-2">

If feasible, use a generated id per item instead of URL.


403-417: Trim in onChange to reduce transient invalid state.

Pre-trim user input before adding scheme.

-const inputValue = e.target.value;
+const inputValue = e.target.value.trim();

271-324: Deduplicate submit logic.

Form onSubmit and primary button onClick duplicate the same validations and updates. Extract a shared handler to reduce drift.

Example (outline):

function applyAbTestChanges(currentTests, completedAt) {
  // validations...
  const normalizedTests = currentTests.map(t => ({ ...t, url: normalizeForForm(t.url || "") }));
  setValueParent("url", normalizedTests[0].url, { shouldDirty: true });
  setValueParent("trackConversion", true);
  setValueParent("testVariants", normalizedTests, { shouldDirty: true });
  setValueParent("testCompletedAt", completedAt, { shouldDirty: true });
  setShowABTestingModal(false);
}

Call it from both places.

Also applies to: 655-711

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 304b79d and 1a6b47c.

📒 Files selected for processing (4)
  • apps/web/ui/links/link-analytics-badge.tsx (2 hunks)
  • apps/web/ui/links/link-tests.tsx (5 hunks)
  • apps/web/ui/modals/link-builder/ab-testing-modal.tsx (18 hunks)
  • apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/links/link-analytics-badge.tsx
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/ui/links/link-tests.tsx (1)
packages/utils/src/functions/urls.ts (2)
  • normalizeUrl (173-180)
  • getPrettyUrl (130-138)
apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (3)
packages/utils/src/functions/urls.ts (2)
  • getPrettyUrl (130-138)
  • normalizeUrl (173-180)
apps/web/ui/links/links-container.tsx (1)
  • ResponseLink (20-22)
apps/web/ui/links/link-analytics-badge.tsx (1)
  • LinkAnalyticsBadge (28-208)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (3)
  • getUrlFromString (10-18)
  • isValidUrl (1-8)
  • getPrettyUrl (130-138)
apps/web/lib/zod/schemas/links.ts (1)
  • MIN_TEST_PERCENTAGE (29-29)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
apps/web/ui/links/link-tests.tsx (1)

57-92: Confirm URL normalization level matches backend grouping.

You aggregate by normalizeUrl(host+path), dropping query/hash. If variants differ only by query, analytics collapse. Confirm this matches /api/analytics?groupBy=top_urls semantics and product intent.

Also applies to: 103-106

apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx (1)

149-158: Nice accessibility upgrades.

Radiogroup, aria-checked, and focus styles are solid; UrlWithTooltip improves long-URL readability.

Also applies to: 165-213

apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)

904-937: Great keyboard accessibility on the traffic slider.

Arrow key support, roles, and focus styles look good.

Also applies to: 978-999

Adds workspaceId check to analytics API call in LinkTests and trims input before normalizing URLs in ABTestingModal to prevent issues with trailing spaces.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)

52-54: normalizeForForm now trims before normalization — resolved.

This fixes the space-related normalization gap flagged earlier.

🧹 Nitpick comments (5)
apps/web/ui/links/link-tests.tsx (2)

57-92: Align trailing‑slash normalization to avoid split analytics buckets.

Analytics aggregation keys currently differ for /path vs /path/. Strip trailing slashes both when building the map and when deriving normalizedTestUrl so lookups match.

-      const key = normalizeUrl(row.url);
+      const key = normalizeUrl(row.url).replace(/\/+$/, "");
@@
-          const normalizedTestUrl = normalizeUrl(test.url);
+          const normalizedTestUrl = normalizeUrl(test.url).replace(/\/+$/, "");

Also applies to: 104-106, 140-141


109-110: Make list item keys unambiguous even if URLs duplicate.

Two variants could point to the same URL; include index in the key to avoid React key collisions.

-              key={normalizedTestUrl || `${test.url}-${idx}`}
+              key={`${idx}-${normalizedTestUrl || test.url}`}
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (3)

172-177: Array.findLastIndex browser support.

findLastIndex is relatively new; confirm your target browsers/runtime support it or replace with a small backwards-compatible loop.

-      const toSplitIndexRaw = testVariants.findLastIndex(
-        ({ percentage }) => percentage >= MIN_TEST_PERCENTAGE * 2,
-      );
+      const toSplitIndexRaw = (() => {
+        for (let i = testVariants.length - 1; i >= 0; i--) {
+          if (testVariants[i].percentage >= MIN_TEST_PERCENTAGE * 2) return i;
+        }
+        return -1;
+      })();

870-879: Clamp mouse percentage to [0, 100] for smoother dragging.

Out-of-bounds cursor positions can produce negative or >100 calculations; clamp before applying.

-      const mouseX = e.clientX - containerRect.x;
-      const mousePercentage = Math.round((mouseX / containerWidth) * 100);
+      const mouseX = e.clientX - containerRect.x;
+      const raw = (mouseX / containerWidth) * 100;
+      const mousePercentage = Math.max(0, Math.min(100, Math.round(raw)));

806-807: Remove unused variable.

complete is defined but never used.

-  const complete = enabled && new Date() > new Date(testCompletedAt!);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1a6b47c and ca8bb41.

📒 Files selected for processing (2)
  • apps/web/ui/links/link-tests.tsx (5 hunks)
  • apps/web/ui/modals/link-builder/ab-testing-modal.tsx (18 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/ui/links/link-tests.tsx (1)
packages/utils/src/functions/urls.ts (2)
  • normalizeUrl (173-180)
  • getPrettyUrl (130-138)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (1)
packages/utils/src/functions/urls.ts (3)
  • getUrlFromString (10-18)
  • isValidUrl (1-8)
  • getPrettyUrl (130-138)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
apps/web/ui/links/link-tests.tsx (1)

40-51: SWR key gating now includes workspaceId — LGTM.

This prevents requests like workspaceId=undefined. Good guard.

URLs are now validated after normalization in AB test editing, displaying an error if any are invalid. Traffic split slider logic is updated to check minimum percentage constraints before applying changes, preventing invalid states.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)

135-149: Consider aligning validation with normalization logic.

validateUrls only adds the protocol but doesn't trim or remove trailing slashes before validation, whereas the submit handlers use normalizeForForm (which does both). While this works in practice—invalid URLs after normalization would still fail—it creates inconsistency.

For cleaner validation, consider:

 const validateUrls = () => {
   if (!testVariants || testVariants.length <= 1) return false;

   return testVariants.every((test) => {
     if (!test.url || test.url.trim() === "") return false;
-
-    // Check if it's a valid URL (with or without protocol)
-    const urlToValidate = test.url.startsWith("http")
-      ? test.url
-      : `https://${test.url}`;
-
-    return isValidUrl(urlToValidate);
+    
+    // Use the same normalization as submit-time
+    const normalizedUrl = normalizeForForm(test.url);
+    return isValidUrl(normalizedUrl);
   });
 };

This ensures the button is only enabled when URLs will pass post-normalization validation.


661-723: Extract duplicated validation logic into a shared function.

The onClick handler duplicates the normalization and validation logic from the form's onSubmit handler (lines 271-331). Both paths:

  • Validate empty URLs and total percentage
  • Normalize with normalizeForForm
  • Validate normalized URLs with isValidUrl
  • Sync to parent form

Extract to a shared function to reduce duplication and improve maintainability:

const validateAndNormalizeTests = (currentTests: typeof testVariants) => {
  if (!currentTests || currentTests.length <= 1) return null;

  const totalPercentage = currentTests.reduce(
    (sum, test) => sum + test.percentage,
    0,
  );

  if (totalPercentage !== 100) {
    toast.error("Total percentage must equal 100%");
    return null;
  }

  if (currentTests.some((test) => !test.url || test.url.trim() === "")) {
    toast.error("All test URLs must be filled");
    return null;
  }

  const normalizedTests = currentTests.map((test) => ({
    ...test,
    url: normalizeForForm(test.url || ""),
  }));

  if (normalizedTests.some((test) => !isValidUrl(test.url))) {
    toast.error("Please enter valid URLs");
    return null;
  }

  return normalizedTests;
};

Then use it in both handlers:

const normalizedTests = validateAndNormalizeTests(currentTests);
if (!normalizedTests) return;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ca8bb41 and 6f36ed6.

📒 Files selected for processing (1)
  • apps/web/ui/modals/link-builder/ab-testing-modal.tsx (18 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (2)
packages/utils/src/functions/urls.ts (3)
  • getUrlFromString (10-18)
  • isValidUrl (1-8)
  • getPrettyUrl (130-138)
apps/web/lib/zod/schemas/links.ts (1)
  • MIN_TEST_PERCENTAGE (29-29)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (6)
apps/web/ui/modals/link-builder/ab-testing-modal.tsx (6)

52-54: LGTM: URL normalization is now robust.

The normalizeForForm helper correctly trims whitespace before URL parsing and strips trailing slashes, addressing the previous review concerns.


73-79: Clarify the purpose of this component.

ABTestingModalInner is a pass-through that simply renders ABTestingEdit. Since ABTestingComplete (defined at lines 732-786) is never rendered in the modal flow, this suggests either:

  1. Incomplete implementation: ABTestingModalInner should route between Edit/Complete views based on test status
  2. Unnecessary abstraction: This component can be removed and ABTestingEdit rendered directly in ABTestingModal

Which was intended? If routing logic is planned but not yet implemented, consider adding a conditional to show ABTestingComplete when the test is finished.


424-449: Excellent: All previous onBlur issues resolved.

The blur handler now correctly:

  1. Trims whitespace before parsing
  2. Normalizes using normalizeForForm
  3. Passes shouldValidate, shouldTouch, and shouldDirty to setValue
  4. Syncs changes to parent URL when index === 0

This comprehensively addresses all four concerns from the previous review.


732-786: Clarify usage of ABTestingComplete component.

This component is fully implemented but never rendered in the modal flow. Given the PR objective "display of stats when ending a test early and selecting a winner," this appears to be an incomplete integration.

Should ABTestingModalInner conditionally render ABTestingComplete when the test has finished? For example:

function ABTestingModalInner({
  setShowABTestingModal,
}: {
  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;
}) {
  const { watch } = useFormContext<LinkFormData>();
  const testCompletedAt = watch("testCompletedAt");
  const isComplete = testCompletedAt && new Date() > new Date(testCompletedAt);
  
  return isComplete ? (
    <ABTestingComplete setShowABTestingModal={setShowABTestingModal} />
  ) : (
    <ABTestingEdit setShowABTestingModal={setShowABTestingModal} />
  );
}

If this component is used elsewhere or the routing is intentionally deferred, clarify with a comment.


916-950: Excellent fix: Keyboard slider bounds checking is now correct.

The handler now computes proposed values (nextLeft, nextRight) before checking bounds, preventing arrow keys from pushing percentages below MIN_TEST_PERCENTAGE. This resolves the issue flagged in the previous review.


993-1001: Well-implemented accessibility for traffic split slider.

The slider includes comprehensive keyboard navigation (arrow keys) and ARIA attributes (role="slider", aria-valuenow, aria-valuemin, aria-valuemax, aria-label), making it fully accessible to screen reader users and keyboard-only navigation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants