diff --git a/.codespellignore b/.codespellignore
index da4c972dae9f..cb20144e71a2 100644
--- a/.codespellignore
+++ b/.codespellignore
@@ -5,4 +5,4 @@ Taht
 taht
 referer
 referers
-
+statics
diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml
index ad045739159c..30f2b3295d89 100644
--- a/.github/workflows/elixir.yml
+++ b/.github/workflows/elixir.yml
@@ -16,25 +16,25 @@ env:
 
 jobs:
   build:
-    name: "Build and test (${{ matrix.mix_env }}, ${{ matrix.postgres_image }}${{ matrix.test_experimental_reduced_joins == '1' && ', experimental_reduced_joins' || '' }})"
+    name: "Build and test (${{ matrix.mix_env }}, ${{ matrix.postgres_image }}${{ matrix.test_read_team_schemas_and_experimental_reduced_joins == '1' && ', read_team_schemas_and_experimental_reduced_joins' || '' }})"
     runs-on: ubuntu-latest
     strategy:
       matrix:
         mix_env: ["test", "ce_test"]
         postgres_image: ["postgres:16"]
-        test_experimental_reduced_joins: ["0"]
+        test_read_team_schemas_and_experimental_reduced_joins: ["0"]
 
         include:
           - mix_env: "test"
             postgres_image: "postgres:15"
-            test_experimental_reduced_joins: "0"
+            test_read_team_schemas_and_experimental_reduced_joins: "0"
           - mix_env: "test"
             postgres_image: "postgres:16"
-            test_experimental_reduced_joins: "1"
+            test_read_team_schemas_and_experimental_reduced_joins: "1"
 
     env:
       MIX_ENV: ${{ matrix.mix_env }}
-      TEST_EXPERIMENTAL_REDUCED_JOINS: ${{ matrix.test_experimental_reduced_joins }}
+      TEST_READ_TEAM_SCHEMAS_AND_EXPERIMENTAL_REDUCED_JOINS: ${{ matrix.test_read_team_schemas_and_experimental_reduced_joins }}
     services:
       postgres:
         image: ${{ matrix.postgres_image }}
diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml
index b5aec1de0f0e..d2ddefe800ba 100644
--- a/.github/workflows/node.yml
+++ b/.github/workflows/node.yml
@@ -32,3 +32,4 @@ jobs:
     - run: npm run check-format --prefix ./assets
     - run: npm run test --prefix ./assets
     - run: npm run deploy --prefix ./tracker
+    - run: npm run report-sizes --prefix ./tracker
diff --git a/.gitignore b/.gitignore
index 684a1a938f9a..2e11afb9084b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,16 @@ npm-debug.log
 /assets/node_modules/
 /tracker/node_modules/
 
+# Files generated by Playwright when running tracker tests
+/tracker/test-results/
+/tracker/playwright-report/
+/tracker/blob-report/
+/tracker/playwright/.cache/
+
+# Stored hash of source tracker files used in development environment
+# to detect changes in /tracker/src and avoid unnecessary compilation.
+/tracker/dev-compile/last-hash.txt
+
 # test coverage directory
 /assets/coverage
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95bced65316c..f82896e45e30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
 ## Unreleased
 
 ### Added
+- Dashboard shows comparisons for all reports
+
 ### Removed
 ### Changed
 ### Fixed
diff --git a/README.md b/README.md
index cef13b1d5d0c..80e6efb12f70 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
     <br /><br />
 </p>
 
-[Plausible Analytics](https://plausible.io/) is an easy to use, lightweight (< 1 KB), open source and privacy-friendly alternative to Google Analytics. It doesn’t use cookies and is fully compliant with GDPR, CCPA and PECR. You can self-host Plausible Community Edition or have us manage Plausible Analytics for you in the cloud. Here's [the live demo of our own website stats](https://plausible.io/plausible.io). Made and hosted in the EU 🇪🇺
+[Plausible Analytics](https://plausible.io/) is an easy to use, lightweight, open source and privacy-friendly alternative to Google Analytics. It doesn’t use cookies and is fully compliant with GDPR, CCPA and PECR. You can self-host Plausible Community Edition or have us manage Plausible Analytics for you in the cloud. Here's [the live demo of our own website stats](https://plausible.io/plausible.io). Made and hosted in the EU 🇪🇺
 
 We are dedicated to making web analytics more privacy-friendly. Our mission is to reduce corporate surveillance by providing an alternative web analytics tool which doesn’t come from the AdTech world. We are completely independent and solely funded by our subscribers.
 
@@ -23,14 +23,14 @@ We are dedicated to making web analytics more privacy-friendly. Our mission is t
 
 ## Why Plausible?
 
-Here's what makes Plausible a great Google Analytics alternative and why we're trusted by 12,000+ paying subscribers to deliver their website and business insights:
+Here's what makes Plausible a great Google Analytics alternative and why we're trusted by thousands of paying subscribers to deliver their website and business insights:
 
 - **Clutter Free**: Plausible Analytics provides [simple web analytics](https://plausible.io/simple-web-analytics) and it cuts through the noise. No layers of menus, no need for custom reports. Get all the important insights on one single page. No training necessary.
 - **GDPR/CCPA/PECR compliant**: Measure traffic, not individuals. No personal data or IP addresses are ever stored in our database. We don't use cookies or any other persistent identifiers. [Read more about our data policy](https://plausible.io/data-policy)
-- **Lightweight**: Plausible Analytics works by loading a script on your website, like Google Analytics. Our script is [45x smaller](https://plausible.io/lightweight-web-analytics), making your website quicker to load. You can also send events directly to our [events API](https://plausible.io/docs/events-api).
+- **Lightweight**: Plausible Analytics works by loading a script on your website, like Google Analytics. Our script is [small](https://plausible.io/lightweight-web-analytics), making your website quicker to load. You can also send events directly to our [events API](https://plausible.io/docs/events-api).
 - **Email or Slack reports**: Keep an eye on your traffic with weekly and/or monthly email or Slack reports. You can also get traffic spike notifications.
 - **Invite team members and share stats**: You have the option to be transparent and open your web analytics to everyone. Your website stats are private by default but you can choose to make them public so anyone with your custom link can view them. You can [invite team members](https://plausible.io/docs/users-roles) and assign user roles too.
-- **Define key goals and track conversions**: Create custom events with custom dimensions to track conversions and attribution to understand and identify the trends that matter. Includes easy ways to track outbound link clicks, file downloads and 404 error pages.
+- **Define key goals and track conversions**: Create custom events with custom dimensions to track conversions and attribution to understand and identify the trends that matter. Track ecommerce revenue, outbound link clicks, file downloads and 404 error pages. Increase conversions using funnel analysis.
 - **Search keywords**: Integrate your dashboard with Google Search Console to get the most accurate reporting on your search keywords.
 - **SPA support**: Plausible is built with modern web frameworks in mind and it works automatically with any pushState based router on the frontend. We also support frameworks that use the URL hash for routing. See [our documentation](https://plausible.io/docs/hash-based-routing).
 - **Smooth transition from Google Analytics**: There's a realtime dashboard, entry pages report and integration with Search Console. You can track your paid campaigns and conversions. You can invite team members. You can even [import your historical Google Analytics stats](https://plausible.io/docs/google-analytics-import). Learn how to [get the most out of your Plausible experience](https://plausible.io/docs/your-plausible-experience) and join thousands who have already migrated from Google Analytics.
diff --git a/assets/jest.config.json b/assets/jest.config.json
index 540d9ec36dd0..37c13e8d4113 100644
--- a/assets/jest.config.json
+++ b/assets/jest.config.json
@@ -6,7 +6,6 @@
   "globals": {
     "BUILD_EXTRA": true
   },
-  "setupFiles": ["<rootDir>/test-utils/set-fixed-timezone.ts"],
   "setupFilesAfterEnv": [
     "<rootDir>/test-utils/extend-expect.ts",
     "<rootDir>/test-utils/reset-state.ts"
diff --git a/assets/js/dashboard/date-range-calendar.test.tsx b/assets/js/dashboard/date-range-calendar.test.tsx
new file mode 100644
index 000000000000..0511d963279d
--- /dev/null
+++ b/assets/js/dashboard/date-range-calendar.test.tsx
@@ -0,0 +1,81 @@
+/** @format */
+
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { DateRangeCalendar } from './date-range-calendar'
+import userEvent from '@testing-library/user-event'
+
+test('renders with default dates in view, respects max and min dates', async () => {
+  const onCloseWithNoSelection = jest.fn()
+  const onCloseWithSelection = jest.fn()
+  const handlers = { onCloseWithNoSelection, onCloseWithSelection }
+
+  render(
+    <DateRangeCalendar
+      minDate="2024-09-10"
+      maxDate="2024-09-25"
+      defaultDates={['2024-09-12', '2024-09-19']}
+      {...handlers}
+    />
+  )
+
+  const days = await screen.queryAllByLabelText(/, 2024/)
+
+  expect(
+    days.map((d) => [d.getAttribute('aria-label'), d.getAttribute('class')])
+  ).toEqual([
+    ['September 1, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 2, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 3, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 4, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 5, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 6, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 7, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 8, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 9, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 10, 2024', 'flatpickr-day'],
+    ['September 11, 2024', 'flatpickr-day'],
+    ['September 12, 2024', 'flatpickr-day selected startRange'],
+    ['September 13, 2024', 'flatpickr-day inRange'],
+    ['September 14, 2024', 'flatpickr-day inRange'],
+    ['September 15, 2024', 'flatpickr-day inRange'],
+    ['September 16, 2024', 'flatpickr-day inRange'],
+    ['September 17, 2024', 'flatpickr-day inRange'],
+    ['September 18, 2024', 'flatpickr-day inRange'],
+    ['September 19, 2024', 'flatpickr-day selected endRange'],
+    ['September 20, 2024', 'flatpickr-day'],
+    ['September 21, 2024', 'flatpickr-day'],
+    ['September 22, 2024', 'flatpickr-day'],
+    ['September 23, 2024', 'flatpickr-day'],
+    ['September 24, 2024', 'flatpickr-day'],
+    ['September 25, 2024', 'flatpickr-day'],
+    ['September 26, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 27, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 28, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 29, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['September 30, 2024', 'flatpickr-day flatpickr-disabled'],
+    ['October 1, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 2, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 3, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 4, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 5, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 6, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 7, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 8, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 9, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 10, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 11, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled'],
+    ['October 12, 2024', 'flatpickr-day nextMonthDay flatpickr-disabled']
+  ])
+
+  const newStart = await screen.getByLabelText('September 20, 2024')
+  await userEvent.click(newStart)
+  const newEnd = await screen.getByLabelText('September 25, 2024')
+  await userEvent.click(newEnd)
+
+  expect(onCloseWithSelection).toHaveBeenCalledTimes(1)
+  expect(onCloseWithSelection).toHaveBeenLastCalledWith([
+    new Date('2024-09-20'),
+    new Date('2024-09-25')
+  ])
+})
diff --git a/assets/js/dashboard/datepicker.tsx b/assets/js/dashboard/datepicker.tsx
index 9deb9f74e26e..8d6cf6442a09 100644
--- a/assets/js/dashboard/datepicker.tsx
+++ b/assets/js/dashboard/datepicker.tsx
@@ -1,6 +1,6 @@
 /* @format */
 import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
-import { formatDateRange, formatISO } from './util/date'
+import { formatDateRange, formatISO, nowForSite } from './util/date'
 import {
   shiftQueryPeriod,
   getDateForShiftedPeriod,
@@ -322,14 +322,16 @@ export default function QueryPeriodPicker() {
     () => getCompareLinkItem({ site, query }),
     [site, query]
   )
-  const groups = useMemo(() => {
+
+  const datePeriodGroups = useMemo(() => {
     const groups = getDatePeriodGroups(site)
     // add Custom Range link to the last group
     groups[groups.length - 1].push(customRangeLink)
+
     if (COMPARISON_DISABLED_PERIODS.includes(query.period)) {
       return groups
     }
-    // maybe ass Compare link as another group to the very end
+    // maybe add Compare link as another group to the very end
     return groups.concat([[compareLink]])
   }, [site, query, customRangeLink, compareLink])
 
@@ -364,7 +366,7 @@ export default function QueryPeriodPicker() {
         }}
       >
         {menuVisible === 'datemenu' && (
-          <QueryPeriodsMenu groups={groups} closeMenu={closeMenu} />
+          <QueryPeriodsMenu groups={datePeriodGroups} closeMenu={closeMenu} />
         )}
         {menuVisible === 'datemenu-calendar' && (
           <DateRangeCalendar
@@ -372,6 +374,7 @@ export default function QueryPeriodPicker() {
               navigate({ search: getSearchToApplyCustomDates(selection) })
             }
             minDate={site.statsBegin}
+            maxDate={formatISO(nowForSite(site))}
             defaultDates={
               query.to && query.from
                 ? [formatISO(query.from), formatISO(query.to)]
@@ -415,6 +418,7 @@ export default function QueryPeriodPicker() {
                   })
                 }
                 minDate={site.statsBegin}
+                maxDate={formatISO(nowForSite(site))}
                 defaultDates={
                   query.compare_from && query.compare_to
                     ? [
@@ -432,7 +436,7 @@ export default function QueryPeriodPicker() {
         <>
           <ArrowKeybind keyboardKey="ArrowLeft" />
           <ArrowKeybind keyboardKey="ArrowRight" />
-          {groups
+          {datePeriodGroups
             .concat([[last6MonthsLinkItem]])
             .flatMap((group) =>
               group
diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js
new file mode 100644
index 000000000000..40fc8ec01c1e
--- /dev/null
+++ b/assets/js/dashboard/filters.js
@@ -0,0 +1,294 @@
+import React, { Fragment, useEffect, useState } from 'react';
+import { useQueryContext } from './query-context';
+import { useSiteContext } from './site-context';
+import { filterRoute } from './router';
+import { AppNavigationLink, useAppNavigate } from './navigation/use-app-navigate';
+import { AdjustmentsVerticalIcon, MagnifyingGlassIcon, XMarkIcon, PencilSquareIcon } from '@heroicons/react/20/solid';
+import classNames from 'classnames';
+import { Menu, Transition } from '@headlessui/react';
+
+import {
+  FILTER_GROUP_TO_MODAL_TYPE,
+  cleanLabels,
+  FILTER_MODAL_TO_FILTER_GROUP,
+  formatFilterGroup,
+  EVENT_PROPS_PREFIX,
+  plainFilterText,
+  styledFilterText
+} from "./util/filters";
+
+const WRAPSTATE = { unwrapped: 0, waiting: 1, wrapped: 2 }
+
+function removeFilter(filterIndex, navigate, query) {
+  const newFilters = query.filters.filter((_filter, index) => filterIndex != index)
+  const newLabels = cleanLabels(newFilters, query.labels)
+
+  navigate({
+    search: (search) => ({
+      ...search,
+      filters: newFilters,
+      labels: newLabels
+    })
+  })
+}
+
+function clearAllFilters(navigate) {
+  navigate({
+    search: (search) => ({
+      ...search,
+      filters: null,
+      labels: null
+    })
+  })
+}
+
+function AppliedFilterPillVertical({filterIndex, filter}) {
+  const { query } = useQueryContext();
+  const navigate = useAppNavigate();
+  const [_operation, filterKey, _clauses] = filter
+
+  const type = filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
+
+  return (
+    <Menu.Item key={filterIndex}>
+      <div className="px-3 md:px-4 sm:py-2 py-3 text-sm leading-tight flex items-center justify-between" key={filterIndex}>
+        <AppNavigationLink
+          title={`Edit filter: ${plainFilterText(query, filter)}`}
+          path={filterRoute.path}
+          params={{field: FILTER_GROUP_TO_MODAL_TYPE[type]}}
+          search={(search) => search}
+          className="group flex w-full justify-between items-center"
+          style={{ width: 'calc(100% - 1.5rem)' }}
+        >
+          <span className="inline-block w-full truncate">{styledFilterText(query, filter)}</span>
+          <PencilSquareIcon className="w-4 h-4 ml-1 cursor-pointer group-hover:text-indigo-700 dark:group-hover:text-indigo-500" />
+        </AppNavigationLink>
+        <b
+          title={`Remove filter: ${plainFilterText(query, filter)}`}
+          className="ml-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500"
+          onClick={() => removeFilter(filterIndex, navigate, query)}
+        >
+          <XMarkIcon className="w-4 h-4" />
+        </b>
+      </div>
+    </Menu.Item>
+  )
+}
+
+function OpenFilterGroupOptionsButton({option}) {
+  return (
+    <Menu.Item>
+      {({ active }) => (
+        <AppNavigationLink
+          path={filterRoute.path}
+          params={{field: option}}
+          search={(search) => search}
+          className={classNames(
+            active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100' : 'text-gray-800 dark:text-gray-300',
+            'block px-4 py-2 text-sm font-medium'
+          )}
+        >
+          {formatFilterGroup(option)}
+        </AppNavigationLink>
+      )}
+    </Menu.Item>
+  )
+}
+
+function DropdownContent({ wrapped }) {
+  const navigate = useAppNavigate();
+  const site = useSiteContext();
+  const { query } = useQueryContext();
+  const [addingFilter, setAddingFilter] = useState(false);
+
+  if (wrapped === WRAPSTATE.unwrapped || addingFilter) {
+    let filterModals = { ...FILTER_MODAL_TO_FILTER_GROUP }
+    if (!site.propsAvailable) delete filterModals.props
+
+    return <>{Object.keys(filterModals).map((option) => <OpenFilterGroupOptionsButton key={option} option={option} />)}</>
+  }
+
+  return (
+    <>
+      <div className="border-b border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => setAddingFilter(true)}>
+        + Add filter
+      </div>
+      {query.filters.map((filter, index) => <AppliedFilterPillVertical key={index} filterIndex={index} filter={filter}/>)}
+      <Menu.Item key="clear">
+        <div className="border-t border-gray-200 dark:border-gray-500 px-4 sm:py-2 py-3 text-sm leading-tight hover:text-indigo-700 dark:hover:text-indigo-500 hover:cursor-pointer" onClick={() => clearAllFilters(navigate)}>
+          Clear All Filters
+        </div>
+      </Menu.Item>
+    </>
+  )
+}
+
+function Filters() {
+  const navigate = useAppNavigate();
+  const { query } = useQueryContext();
+
+  const [wrapped, setWrapped] = useState(WRAPSTATE.waiting)
+  const [viewport, setViewport] = useState(1080)
+
+  useEffect(() => {
+    handleResize()
+
+    window.addEventListener('resize', handleResize, false)
+
+    return () => {
+      window.removeEventListener('resize', handleResize, false)
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  useEffect(() => {
+    setWrapped(WRAPSTATE.waiting)
+  }, [query, viewport])
+
+  useEffect(() => {
+    if (wrapped === WRAPSTATE.waiting) { updateDisplayMode() }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [wrapped])
+
+  function handleResize() {
+    setViewport(window.innerWidth || 639)
+  }
+
+  // Checks if the filter container is wrapping items
+  function updateDisplayMode() {
+    const container = document.getElementById('filters')
+    const children = container && [...container.childNodes] || []
+
+    // Always wrap on mobile
+    if (query.filters.length > 0 && viewport <= 768) {
+      setWrapped(WRAPSTATE.wrapped)
+      return
+    }
+
+    setWrapped(WRAPSTATE.unwrapped)
+
+    // Check for different y value between all child nodes - this indicates a wrap
+    children.forEach(child => {
+      const currentChildY = child.getBoundingClientRect().top
+      const firstChildY = children[0].getBoundingClientRect().top
+      if (currentChildY !== firstChildY) {
+        setWrapped(WRAPSTATE.wrapped)
+      }
+    })
+  }
+
+  function AppliedFilterPillHorizontal({filterIndex, filter}) {
+    const { query } = useQueryContext();
+    const [_operation, filterKey, _clauses] = filter
+    const type = filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
+    return (
+      <span className="flex bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 shadow text-sm rounded mr-2 items-center">
+        <AppNavigationLink
+          title={`Edit filter: ${plainFilterText(query, filter)}`}
+          className="flex w-full h-full items-center py-2 pl-3"
+          path={filterRoute.path}
+          params={{field: FILTER_GROUP_TO_MODAL_TYPE[type]}}
+          search={(search)=> search}
+        >
+          <span className="inline-block max-w-2xs md:max-w-xs truncate">{styledFilterText(query, filter)}</span>
+        </AppNavigationLink>
+        <span
+          title={`Remove filter: ${plainFilterText(query, filter)}`}
+          className="flex h-full w-full px-2 cursor-pointer hover:text-indigo-700 dark:hover:text-indigo-500 items-center"
+          onClick={() => removeFilter(filterIndex, navigate, query)}
+        >
+          <XMarkIcon className="w-4 h-4" />
+        </span>
+      </span>
+    )
+  }
+
+  function renderDropdownButton() {
+    if (wrapped === WRAPSTATE.wrapped) {
+      const filterCount = query.filters.length
+      return (
+        <>
+          <AdjustmentsVerticalIcon className="-ml-1 mr-1 h-4 w-4 shrink-0" aria-hidden="true" />
+          {filterCount} Filter{filterCount === 1 ? '' : 's'}
+        </>
+      )
+    }
+
+    return (
+      <>
+        <MagnifyingGlassIcon className="-ml-1 mr-1 h-4 w-4 shrink-0" aria-hidden="true" />
+        {/* This would have been a good use-case for JSX! But in the interest of keeping the breakpoint width logic with TailwindCSS, this is a better long-term way to deal with it. */}
+        <span className="sm:hidden">Filter</span><span className="hidden sm:inline-block">Filter</span>
+      </>
+    )
+  }
+
+  function trackFilterMenu() {
+    if (window.trackCustomEvent) {
+      window.trackCustomEvent('Filter Menu: Open')
+    }
+  }
+
+  function renderDropDown() {
+    return (
+      <Menu as="div" className="md:relative ml-auto">
+        {({ open }) => (
+          <>
+            <div>
+              <Menu.Button onClick={trackFilterMenu} className="flex items-center text-xs md:text-sm font-medium leading-tight px-3 py-2 cursor-pointer ml-auto text-gray-500 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900 rounded">
+                {renderDropdownButton()}
+              </Menu.Button>
+            </div>
+
+            <Transition
+              show={open}
+              as={Fragment}
+              enter="transition ease-out duration-100"
+              enterFrom="opacity-0 scale-95"
+              enterTo="opacity-100 scale-100"
+              leave="transition ease-in duration-75"
+              leaveFrom="opacity-100 scale-100"
+              leaveTo="opacity-0 scale-95"
+            >
+              <Menu.Items
+                static
+                className="absolute w-full left-0 right-0 md:w-72 md:absolute md:top-auto md:left-auto md:right-0 mt-2 origin-top-right z-10"
+              >
+                <div
+                  className="rounded-md shadow-lg  bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5
+                  font-medium text-gray-800 dark:text-gray-200"
+                >
+                  <DropdownContent wrapped={wrapped} />
+                </div>
+              </Menu.Items>
+            </Transition>
+          </>
+        )}
+      </Menu>
+    );
+  }
+
+  function renderFilterList() {
+    // The filters are rendered even when `wrapped === WRAPSTATE.waiting`.
+    // Otherwise, if they don't exist in the DOM, we can't check whether
+    // the flex-wrap is actually putting them on multiple lines.
+    if (wrapped !== WRAPSTATE.wrapped) {
+      return (
+        <div id="filters" className="flex flex-wrap">
+          {query.filters.map((filter, index) => <AppliedFilterPillHorizontal key={index} filterIndex={index} filter={filter} />)}
+        </div>
+      )
+    }
+
+    return null
+  }
+
+  return (
+    <>
+      {renderFilterList()}
+      {renderDropDown()}
+    </>
+  )
+}
+
+export default Filters;
diff --git a/assets/js/dashboard/nav-menu/filters-bar.test.tsx b/assets/js/dashboard/nav-menu/filters-bar.test.tsx
index 1a280f594d34..9cca865b04df 100644
--- a/assets/js/dashboard/nav-menu/filters-bar.test.tsx
+++ b/assets/js/dashboard/nav-menu/filters-bar.test.tsx
@@ -11,7 +11,7 @@ import { stringifySearch } from '../util/url'
 const domain = 'dummy.site'
 
 beforeAll(() => {
-  global.ResizeObserver = jest.fn(
+  const mockResizeObserver = jest.fn(
     (handleEntries) =>
       ({
         observe: jest
@@ -23,6 +23,7 @@ beforeAll(() => {
         disconnect: jest.fn()
       }) as unknown as ResizeObserver
   )
+  global.ResizeObserver = mockResizeObserver
 })
 
 test('user can see expected filters and clear them one by one or all together', async () => {
diff --git a/assets/js/dashboard/nav-menu/filters-bar.tsx b/assets/js/dashboard/nav-menu/filters-bar.tsx
index b3f9d100f7a3..87f3bfa60e44 100644
--- a/assets/js/dashboard/nav-menu/filters-bar.tsx
+++ b/assets/js/dashboard/nav-menu/filters-bar.tsx
@@ -245,7 +245,7 @@ export const FiltersBar = () => {
 export const ClearAction = () => (
   <AppNavigationLink
     title="Clear all filters"
-    className="w-9 hover:text-indigo-700 dark:hover:text-indigo-500 flex items-center justify-center"
+    className="w-9 text-gray-500 hover:text-indigo-700 dark:hover:text-indigo-500 flex items-center justify-center"
     search={(search) => ({
       ...search,
       filters: null,
diff --git a/assets/js/dashboard/nav-menu/top-bar.test.tsx b/assets/js/dashboard/nav-menu/top-bar.test.tsx
index f5aef8ec471f..bc6c8f389f30 100644
--- a/assets/js/dashboard/nav-menu/top-bar.test.tsx
+++ b/assets/js/dashboard/nav-menu/top-bar.test.tsx
@@ -13,6 +13,9 @@ import { TestContextProviders } from '../../../test-utils/app-context-providers'
 import { TopBar } from './top-bar'
 import { MockAPI } from '../../../test-utils/mock-api'
 
+const flags = {
+  saved_segments: true
+}
 const domain = 'dummy.site'
 const domains = [domain, 'example.com', 'blog.example.com']
 
@@ -42,7 +45,7 @@ beforeEach(() => {
 test('user can open and close site switcher', async () => {
   render(<TopBar showCurrentVisitors={false} />, {
     wrapper: (props) => (
-      <TestContextProviders siteOptions={{ domain }} {...props} />
+      <TestContextProviders siteOptions={{ domain, flags }} {...props} />
     )
   })
 
@@ -64,7 +67,7 @@ test('user can open and close site switcher', async () => {
 test('user can open and close filters dropdown', async () => {
   render(<TopBar showCurrentVisitors={false} />, {
     wrapper: (props) => (
-      <TestContextProviders siteOptions={{ domain }} {...props} />
+      <TestContextProviders siteOptions={{ domain, flags }} {...props} />
     )
   })
 
@@ -89,7 +92,7 @@ test('current visitors renders when visitors are present and disappears after vi
   mockAPI.get(`/api/stats/${domain}/current-visitors?`, 500)
   render(<TopBar showCurrentVisitors={true} />, {
     wrapper: (props) => (
-      <TestContextProviders siteOptions={{ domain }} {...props} />
+      <TestContextProviders siteOptions={{ domain, flags }} {...props} />
     )
   })
 
diff --git a/assets/js/dashboard/nav-menu/top-bar.tsx b/assets/js/dashboard/nav-menu/top-bar.tsx
index cceeff92abe5..8bc8adb76fe7 100644
--- a/assets/js/dashboard/nav-menu/top-bar.tsx
+++ b/assets/js/dashboard/nav-menu/top-bar.tsx
@@ -9,6 +9,7 @@ import QueryPeriodPicker from '../datepicker'
 import classNames from 'classnames'
 import { useInView } from 'react-intersection-observer'
 import { FilterMenu } from './filter-menu'
+import Filters from '../filters'
 
 interface TopBarProps {
   showCurrentVisitors: boolean
@@ -20,6 +21,7 @@ export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) {
   const user = useUserContext()
   const tooltipBoundary = useRef(null)
   const { ref, inView } = useInView({ threshold: 0 })
+  const { saved_segments } = site.flags
 
   return (
     <>
@@ -42,11 +44,11 @@ export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) {
             {showCurrentVisitors && (
               <CurrentVisitors tooltipBoundary={tooltipBoundary.current} />
             )}
-            <FilterMenu />
+            {saved_segments ? <FilterMenu /> : <Filters />}
           </div>
           <QueryPeriodPicker />
         </div>
-        {!!extraBar && extraBar}
+        {!!saved_segments && !!extraBar && extraBar}
       </div>
     </>
   )
diff --git a/assets/js/dashboard/query-time-periods.ts b/assets/js/dashboard/query-time-periods.ts
index 59f94ec8e522..ea59be8fe4b4 100644
--- a/assets/js/dashboard/query-time-periods.ts
+++ b/assets/js/dashboard/query-time-periods.ts
@@ -398,7 +398,12 @@ export const getDatePeriodGroups = (
 export const last6MonthsLinkItem: LinkItem = [
   ['Last 6 months', 'S'],
   {
-    search: (s) => ({ ...s, period: QueryPeriod['6mo'], keybindHint: 'S' }),
+    search: (s) => ({
+      ...s,
+      ...clearedDateSearch,
+      period: QueryPeriod['6mo'],
+      keybindHint: 'S'
+    }),
     isActive: ({ query }) => query.period === QueryPeriod['6mo']
   }
 ]
diff --git a/assets/js/dashboard/query.ts b/assets/js/dashboard/query.ts
index c93e2b03ddfd..c9d345220147 100644
--- a/assets/js/dashboard/query.ts
+++ b/assets/js/dashboard/query.ts
@@ -55,6 +55,11 @@ export const queryDefaultValue = {
 
 export type DashboardQuery = typeof queryDefaultValue
 
+export type BreakdownResultMeta = {
+  date_range_label: string
+  comparison_date_range_label?: string
+}
+
 export function addFilter(
   query: DashboardQuery,
   filter: Filter
diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx
index 863745cec040..80e6f2ba5219 100644
--- a/assets/js/dashboard/site-context.tsx
+++ b/assets/js/dashboard/site-context.tsx
@@ -25,6 +25,11 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
   }
 }
 
+type FeatureFlags = {
+  channels?: boolean
+  saved_segments?: boolean
+}
+
 const siteContextDefaultValue = {
   domain: '',
   /** offset in seconds from UTC at site load time, @example 7200 */
@@ -45,7 +50,7 @@ const siteContextDefaultValue = {
   embedded: false,
   background: undefined as string | undefined,
   isDbip: false,
-  flags: {} as { breakdown_comparisons_ui?: boolean },
+  flags: {} as FeatureFlags,
   validIntervalsByPeriod: {} as Record<string, Array<string>>,
   shared: false
 }
diff --git a/assets/js/dashboard/site-switcher.js b/assets/js/dashboard/site-switcher.js
index b35e3a8f2011..06c5258741d7 100644
--- a/assets/js/dashboard/site-switcher.js
+++ b/assets/js/dashboard/site-switcher.js
@@ -247,7 +247,7 @@ export default class SiteSwitcher extends React.Component {
             {this.props.site.domain}
           </span>
           {this.props.loggedIn && (
-            <ChevronDownIcon className="ml-2 h-4 w-4 shrink-0" />
+            <ChevronDownIcon className="ml-2 h-5 w-5 shrink-0" />
           )}
         </button>
 
diff --git a/assets/js/dashboard/stats/current-visitors.js b/assets/js/dashboard/stats/current-visitors.js
index 8b39496e922e..66d5735a259c 100644
--- a/assets/js/dashboard/stats/current-visitors.js
+++ b/assets/js/dashboard/stats/current-visitors.js
@@ -33,7 +33,11 @@ export default function CurrentVisitors({ tooltipBoundary }) {
     updateCount()
   }, [query, updateCount])
 
-  if (currentVisitors !== null) {
+  if (
+    site.flags.saved_segments
+      ? currentVisitors !== null
+      : currentVisitors !== null && query.filters.length === 0
+  ) {
     return (
       <Tooltip
         info={
diff --git a/assets/js/dashboard/stats/graph/graph-tooltip.js b/assets/js/dashboard/stats/graph/graph-tooltip.js
index fffeec828c8f..046e6b651dcd 100644
--- a/assets/js/dashboard/stats/graph/graph-tooltip.js
+++ b/assets/js/dashboard/stats/graph/graph-tooltip.js
@@ -1,6 +1,9 @@
+import React from 'react'
+import { createRoot } from 'react-dom/client'
 import dateFormatter from './date-formatter'
 import { METRIC_LABELS } from './graph-util'
 import { MetricFormatterShort } from '../reports/metric-formatter'
+import { ChangeArrow } from '../reports/change-arrow'
 
 const renderBucketLabel = function(query, graphData, label, comparison = false) {
   let isPeriodFull = graphData.full_intervals?.[label]
@@ -52,6 +55,9 @@ const buildTooltipData = function(query, graphData, metric, tooltipModel) {
   return { label, formattedValue, comparisonLabel, formattedComparisonValue, comparisonDifference }
 }
 
+
+let tooltipRoot
+
 export default function GraphTooltip(graphData, metric, query) {
   return (context) => {
     const tooltipModel = context.tooltip
@@ -64,6 +70,7 @@ export default function GraphTooltip(graphData, metric, query) {
       tooltipEl.style.display = 'none'
       tooltipEl.style.opacity = 0
       document.body.appendChild(tooltipEl)
+      tooltipRoot = createRoot(tooltipEl)
     }
 
     if (tooltipEl && offset && window.innerWidth < 768) {
@@ -81,42 +88,41 @@ export default function GraphTooltip(graphData, metric, query) {
     if (tooltipModel.body) {
       const tooltipData = buildTooltipData(query, graphData, metric, tooltipModel)
 
-      tooltipEl.innerHTML = `
-        <aside class="text-gray-100 flex flex-col">
-          <div class="flex justify-between items-center">
-            <span class="font-semibold mr-4 text-lg">${METRIC_LABELS[metric]}</span>
-            ${tooltipData.comparisonDifference ?
-            `<div class="inline-flex items-center space-x-1">
-              ${tooltipData.comparisonDifference > 0 ? `<span class="font-semibold text-sm text-green-500">&uarr;</span><span>${tooltipData.comparisonDifference}%</span>` : ""}
-              ${tooltipData.comparisonDifference < 0 ? `<span class="font-semibold text-sm text-red-400">&darr;</span><span>${tooltipData.comparisonDifference * -1}%</span>` : ""}
-              ${tooltipData.comparisonDifference == 0 ? `<span class="font-semibold text-sm">〰 0%</span>` : ""}
-            </div>` : ''}
+      tooltipRoot.render(
+        <aside className="text-gray-100 flex flex-col">
+          <div className="flex justify-between items-center">
+            <span className="font-semibold mr-4 text-lg">{METRIC_LABELS[metric]}</span>
+            {tooltipData.comparisonDifference ? (
+            <div className="inline-flex items-center space-x-1">
+              <ChangeArrow metric={metric} change={tooltipData.comparisonDifference} />
+            </div>) : null}
           </div>
 
-          ${tooltipData.label ?
-          `<div class="flex flex-col">
-            <div class="flex flex-row justify-between items-center">
-              <span class="flex items-center mr-4">
-                <div class="w-3 h-3 mr-1 rounded-full" style="background-color: rgba(101,116,205)"></div>
-                <span>${tooltipData.label}</span>
+          {tooltipData.label ? (
+          <div className="flex flex-col">
+            <div className="flex flex-row justify-between items-center">
+              <span className="flex items-center mr-4">
+                <div className="w-3 h-3 mr-1 rounded-full" style={{ backgroundColor: "rgba(101,116,205)" }}></div>
+                <span>{tooltipData.label}</span>
               </span>
-              <span class="text-base font-bold">${tooltipData.formattedValue}</span>
-            </div>` : ''}
-
-            ${tooltipData.comparisonLabel ?
-            `<div class="flex flex-row justify-between items-center">
-              <span class="flex items-center mr-4">
-                <div class="w-3 h-3 mr-1 rounded-full bg-gray-500"></div>
-                <span>${tooltipData.comparisonLabel}</span>
+              <span className="text-base font-bold">{tooltipData.formattedValue}</span>
+            </div>
+
+            {tooltipData.comparisonLabel ? (
+            <div className="flex flex-row justify-between items-center">
+              <span className="flex items-center mr-4">
+                <div className="w-3 h-3 mr-1 rounded-full bg-gray-500"></div>
+                <span>{tooltipData.comparisonLabel}</span>
               </span>
-              <span class="text-base font-bold">${tooltipData.formattedComparisonValue}</span>
-            </div>` : ""}
+              <span className="text-base font-bold">{tooltipData.formattedComparisonValue}</span>
+            </div>) : null}
           </div>
+          ) : null}
 
-          ${graphData.interval === "month" ? `<span class="font-semibold italic">Click to view month</span>` : ""}
-          ${graphData.interval === "day" ? `<span class="font-semibold italic">Click to view day</span>` : ""}
+          {graphData.interval === "month" ? (<span className="font-semibold italic">Click to view month</span>) : null}
+          {graphData.interval === "day" ? (<span className="font-semibold italic">Click to view day</span>) : null}
         </aside>
-      `
+      )
     }
     tooltipEl.style.display = null
   }
diff --git a/assets/js/dashboard/stats/graph/stats-export.js b/assets/js/dashboard/stats/graph/stats-export.js
index 47608e87ad62..1b1dd5b3b0d9 100644
--- a/assets/js/dashboard/stats/graph/stats-export.js
+++ b/assets/js/dashboard/stats/graph/stats-export.js
@@ -34,7 +34,7 @@ export default function StatsExport() {
 
   function renderExportLink() {
     const interval = getCurrentInterval(site, query)
-    const queryParams = api.serializeQuery(query, [{ interval }])
+    const queryParams = api.serializeQuery(query, [{ interval, comparison: undefined }])
     const endpoint = `/${encodeURIComponent(site.domain)}/export${queryParams}`
 
     return (
diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js
index 3cc6c52e6180..24b9d02e0476 100644
--- a/assets/js/dashboard/stats/graph/top-stats.js
+++ b/assets/js/dashboard/stats/graph/top-stats.js
@@ -31,13 +31,15 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
   const lastLoadTimestamp = useLastLoadContext()
   const site = useSiteContext()
 
+  const isComparison = query.comparison && data && data.comparing_from
+
   function tooltip(stat) {
     let statName = stat.name.toLowerCase()
     statName = stat.value === 1 ? statName.slice(0, -1) : statName
 
     return (
       <div>
-        {query.comparison && (
+        {isComparison && (
           <div className="whitespace-nowrap">
             {topStatNumberLong(stat.graph_metric, stat.value)} vs.{' '}
             {topStatNumberLong(stat.graph_metric, stat.comparison_value)}{' '}
@@ -50,7 +52,7 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
           </div>
         )}
 
-        {!query.comparison && (
+        {!isComparison && (
           <div className="whitespace-nowrap">
             {topStatNumberLong(stat.graph_metric, stat.value)} {statName}
           </div>
@@ -147,7 +149,7 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
               >
                 {topStatNumberShort(stat.graph_metric, stat.value)}
               </p>
-              {query.comparison && stat.change != null ? (
+              {!isComparison && stat.change != null ? (
                 <ChangeArrow
                   metric={stat.graph_metric}
                   change={stat.change}
@@ -155,14 +157,14 @@ export default function TopStats({ data, onMetricUpdate, tooltipBoundary }) {
                 />
               ) : null}
             </span>
-            {query.comparison ? (
+            {isComparison ? (
               <p className="text-xs dark:text-gray-100">
                 {formatDateRange(site, data.from, data.to)}
               </p>
             ) : null}
           </div>
 
-          {query.comparison ? (
+          {isComparison ? (
             <div>
               <p className="font-bold text-xl text-gray-500 dark:text-gray-400">
                 {topStatNumberShort(stat.graph_metric, stat.comparison_value)}
diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js
index 8d2172bf3afb..4f74868f5c1c 100644
--- a/assets/js/dashboard/stats/graph/visitor-graph.js
+++ b/assets/js/dashboard/stats/graph/visitor-graph.js
@@ -19,7 +19,7 @@ import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
 function fetchTopStats(site, query) {
   const q = { ...query }
 
-  if (!isComparisonEnabled(q.comparison)) {
+  if (!isComparisonEnabled(q.comparison) && query.period !== 'realtime') {
     q.comparison = 'previous_period'
   }
 
diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx
index 1d56df1d82ae..e40d5db16910 100644
--- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx
+++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx
@@ -14,7 +14,7 @@ import {
   useRememberOrderBy
 } from '../../hooks/use-order-by'
 import { Metric } from '../reports/metrics'
-import { DashboardQuery } from '../../query'
+import { BreakdownResultMeta, DashboardQuery } from '../../query'
 import { ColumnConfiguraton } from '../../components/table'
 import { BreakdownTable } from './breakdown-table'
 import { useSiteContext } from '../../site-context'
@@ -30,7 +30,7 @@ export type ReportInfo = {
   defaultOrder?: Order
 }
 
-/** 
+/**
   BreakdownModal is for rendering the "Details" reports on the dashboard,
   i.e. a breakdown by a single (non-time) dimension, with a given set of metrics.
 
@@ -79,6 +79,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
 }) {
   const site = useSiteContext()
   const { query } = useQueryContext()
+  const [meta, setMeta] = useState<BreakdownResultMeta | undefined>(undefined)
 
   const [search, setSearch] = useState('')
   const defaultOrderBy = getStoredOrderBy({
@@ -97,7 +98,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
     reportInfo
   })
   const apiState = usePaginatedGetAPI<
-    { results: Array<TListItem> },
+    { results: Array<TListItem>; meta: BreakdownResultMeta },
     [string, { query: DashboardQuery; search: string; orderBy: OrderBy }]
   >({
     key: [reportInfo.endpoint, { query, search, orderBy }],
@@ -122,7 +123,10 @@ export default function BreakdownModal<TListItem extends { name: string }>({
         }
       ]
     },
-    afterFetchData,
+    afterFetchData: (response) => {
+      setMeta(response.meta)
+      afterFetchData?.(response)
+    },
     afterFetchNextPage
   })
 
@@ -148,7 +152,7 @@ export default function BreakdownModal<TListItem extends { name: string }>({
           key: m.key,
           width: m.width,
           align: 'right',
-          renderValue: m.renderValue,
+          renderValue: (item) => m.renderValue(item, meta),
           onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
           sortDirection: orderByDictionary[m.key]
         })
@@ -162,7 +166,8 @@ export default function BreakdownModal<TListItem extends { name: string }>({
       orderByDictionary,
       toggleSortByMetric,
       renderIcon,
-      getExternalLinkURL
+      getExternalLinkURL,
+      meta
     ]
   )
 
diff --git a/assets/js/dashboard/stats/reports/change-arrow.test.tsx b/assets/js/dashboard/stats/reports/change-arrow.test.tsx
index eff8b7239bb3..eae2abeed23d 100644
--- a/assets/js/dashboard/stats/reports/change-arrow.test.tsx
+++ b/assets/js/dashboard/stats/reports/change-arrow.test.tsx
@@ -4,6 +4,15 @@ import React from 'react'
 import { render, screen } from '@testing-library/react'
 import { ChangeArrow } from './change-arrow'
 
+jest.mock('@heroicons/react/24/solid', () => ({
+  ArrowUpRightIcon: ({ className }: { className: string }) => (
+    <span className={className}>↑</span>
+  ),
+  ArrowDownRightIcon: ({ className }: { className: string }) => (
+    <span className={className}>↓</span>
+  )
+}))
+
 it('renders green for positive change', () => {
   render(<ChangeArrow change={1} className="text-xs" metric="visitors" />)
 
@@ -58,3 +67,12 @@ it('renders with text hidden', () => {
   expect(arrowElement).toHaveTextContent('↓')
   expect(arrowElement.children[0]).toHaveClass('text-red-400')
 })
+
+it('renders no content with text hidden and 0 change', () => {
+  render(
+    <ChangeArrow change={0} className="text-xs" metric="visitors" hideNumber />
+  )
+
+  const arrowElement = screen.getByTestId('change-arrow')
+  expect(arrowElement).toHaveTextContent('')
+})
diff --git a/assets/js/dashboard/stats/reports/change-arrow.tsx b/assets/js/dashboard/stats/reports/change-arrow.tsx
index 3f77e1055867..959a5f33ffe1 100644
--- a/assets/js/dashboard/stats/reports/change-arrow.tsx
+++ b/assets/js/dashboard/stats/reports/change-arrow.tsx
@@ -3,6 +3,8 @@
 import React from 'react'
 import { Metric } from '../../../types/query-api'
 import { numberShortFormatter } from '../../util/number-formatter'
+import { ArrowDownRightIcon, ArrowUpRightIcon } from '@heroicons/react/24/solid'
+import classNames from 'classnames'
 
 export function ChangeArrow({
   change,
@@ -19,31 +21,30 @@ export function ChangeArrow({
     ? null
     : ` ${numberShortFormatter(Math.abs(change))}%`
 
-  let content = null
+  let icon = null
+  const arrowClassName = classNames(
+    color(change, metric),
+    'inline-block h-3 w-3 stroke-[1px] stroke-current'
+  )
 
   if (change > 0) {
-    const color = metric === 'bounce_rate' ? 'text-red-400' : 'text-green-500'
-    content = (
-      <>
-        <span className={color + ' font-bold'}>&uarr;</span>
-        {formattedChange}
-      </>
-    )
+    icon = <ArrowUpRightIcon className={arrowClassName} />
   } else if (change < 0) {
-    const color = metric === 'bounce_rate' ? 'text-green-500' : 'text-red-400'
-    content = (
-      <>
-        <span className={color + ' font-bold'}>&darr;</span>
-        {formattedChange}
-      </>
-    )
-  } else if (change === 0) {
-    content = <>&#12336;{formattedChange}</>
+    icon = <ArrowDownRightIcon className={arrowClassName} />
+  } else if (change === 0 && !hideNumber) {
+    icon = <>&#12336;</>
   }
 
   return (
     <span className={className} data-testid="change-arrow">
-      {content}
+      {icon}
+      {formattedChange}
     </span>
   )
 }
+
+function color(change: number, metric: Metric) {
+  const invert = metric === 'bounce_rate'
+
+  return change > 0 != invert ? 'text-green-500' : 'text-red-400'
+}
diff --git a/assets/js/dashboard/stats/reports/list.js b/assets/js/dashboard/stats/reports/list.js
index 48474f459326..2908778e20f5 100644
--- a/assets/js/dashboard/stats/reports/list.js
+++ b/assets/js/dashboard/stats/reports/list.js
@@ -163,7 +163,7 @@ export default function ListReport({
         afterFetchData(response)
       }
 
-      setState({ loading: false, list: response.results })
+      setState({ loading: false, list: response.results, meta: response.meta })
     })
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [keyLabel, query])
@@ -316,7 +316,7 @@ export default function ListReport({
           style={{ width: colMinWidth, minWidth: colMinWidth }}
         >
           <span className="font-medium text-sm dark:text-gray-200 text-right">
-            {metric.renderValue(listItem)}
+            {metric.renderValue(listItem, state.meta)}
           </span>
         </div>
       )
diff --git a/assets/js/dashboard/stats/reports/metric-value.test.tsx b/assets/js/dashboard/stats/reports/metric-value.test.tsx
index 612187bb80ef..ddd2db3acdea 100644
--- a/assets/js/dashboard/stats/reports/metric-value.test.tsx
+++ b/assets/js/dashboard/stats/reports/metric-value.test.tsx
@@ -1,14 +1,13 @@
 /** @format */
 
 import React from 'react'
-import {
-  render as libraryRender,
-  screen,
-  fireEvent,
-  waitFor
-} from '@testing-library/react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
 import MetricValue from './metric-value'
-import SiteContextProvider, { PlausibleSite } from '../../site-context'
+
+jest.mock('@heroicons/react/24/solid', () => ({
+  ArrowUpRightIcon: () => <>↑</>,
+  ArrowDownRightIcon: () => <>↓</>
+}))
 
 const REVENUE = { long: '$1,659.50', short: '$1.7K' }
 
@@ -81,7 +80,14 @@ describe('comparisons', () => {
 
     expect(screen.getByTestId('metric-value')).toHaveTextContent('10↑')
     expect(screen.getByRole('tooltip')).toHaveTextContent(
-      '10 vs. 5 visitors↑ 100%'
+      [
+        '10 visitors',
+        '↑ 100%',
+        '01 Aug - 31 Aug',
+        'vs',
+        '5 visitors',
+        '01 July - 31 July'
+      ].join('')
     )
   })
 
@@ -92,7 +98,14 @@ describe('comparisons', () => {
 
     expect(screen.getByTestId('metric-value')).toHaveTextContent('5↓')
     expect(screen.getByRole('tooltip')).toHaveTextContent(
-      '5 vs. 10 visitors↓ 50%'
+      [
+        '5 visitors',
+        '↓ 50%',
+        '01 Aug - 31 Aug',
+        'vs',
+        '10 visitors',
+        '01 July - 31 July'
+      ].join('')
     )
   })
 
@@ -101,9 +114,16 @@ describe('comparisons', () => {
       <MetricValue {...valueProps('visitors', 10, { value: 10, change: 0 })} />
     )
 
-    expect(screen.getByTestId('metric-value')).toHaveTextContent('10〰')
+    expect(screen.getByTestId('metric-value')).toHaveTextContent('10')
     expect(screen.getByRole('tooltip')).toHaveTextContent(
-      '10 vs. 10 visitors〰 0%'
+      [
+        '10 visitors',
+        '〰 0%',
+        '01 Aug - 31 Aug',
+        'vs',
+        '10 visitors',
+        '01 July - 31 July'
+      ].join('')
     )
   })
 
@@ -116,7 +136,14 @@ describe('comparisons', () => {
     )
 
     expect(screen.getByRole('tooltip')).toHaveTextContent(
-      '10 vs. 10 conversions〰 0%'
+      [
+        '10 conversions',
+        '〰 0%',
+        '01 Aug - 31 Aug',
+        'vs',
+        '10 conversions',
+        '01 July - 31 July'
+      ].join('')
     )
   })
 
@@ -128,7 +155,16 @@ describe('comparisons', () => {
       />
     )
 
-    expect(screen.getByRole('tooltip')).toHaveTextContent('10% vs. 10%〰 0%')
+    expect(screen.getByRole('tooltip')).toHaveTextContent(
+      [
+        '10% ',
+        '〰 0%',
+        '01 Aug - 31 Aug',
+        'vs',
+        '10% ',
+        '01 July - 31 July'
+      ].join('')
+    )
   })
 
   it('renders with custom formatter', async () => {
@@ -141,7 +177,14 @@ describe('comparisons', () => {
 
     expect(screen.getByTestId('metric-value')).toHaveTextContent('10$↑')
     expect(screen.getByRole('tooltip')).toHaveTextContent(
-      '10$ vs. 5$ test↑ 100%'
+      [
+        '10$ test',
+        '↑ 100%',
+        '01 Aug - 31 Aug',
+        'vs',
+        '5$ test',
+        '01 July - 31 July'
+      ].join('')
     )
   })
 
@@ -155,9 +198,16 @@ describe('comparisons', () => {
       />
     )
 
-    expect(screen.getByTestId('metric-value')).toHaveTextContent('$1.7K〰')
+    expect(screen.getByTestId('metric-value')).toHaveTextContent('$1.7K')
     expect(screen.getByRole('tooltip')).toHaveTextContent(
-      '$1,659.50 vs. $1,659.50 average_revenue〰 0%'
+      [
+        '$1,659.50 average_revenue',
+        '〰 0%',
+        '01 Aug - 31 Aug',
+        'vs',
+        '$1,659.50 average_revenue',
+        '01 July - 31 July'
+      ].join('')
     )
   })
 
@@ -190,17 +240,14 @@ function valueProps<T>(
         }
       }
     },
+    meta: {
+      date_range_label: '01 Aug - 31 Aug',
+      comparison_date_range_label: '01 July - 31 July'
+    },
     renderLabel: (_query: unknown) => metric.toUpperCase()
   } as any /* eslint-disable-line @typescript-eslint/no-explicit-any */
 }
 
-function render(ui: React.ReactNode) {
-  const site = {
-    flags: { breakdown_comparisons_ui: true }
-  } as unknown as PlausibleSite
-  libraryRender(<SiteContextProvider site={site}>{ui}</SiteContextProvider>)
-}
-
 async function renderWithTooltip(ui: React.ReactNode) {
   render(ui)
   await waitForTooltip()
diff --git a/assets/js/dashboard/stats/reports/metric-value.tsx b/assets/js/dashboard/stats/reports/metric-value.tsx
index b9187ef0e5a2..58ea1681b2df 100644
--- a/assets/js/dashboard/stats/reports/metric-value.tsx
+++ b/assets/js/dashboard/stats/reports/metric-value.tsx
@@ -9,9 +9,8 @@ import {
   MetricFormatterShort,
   ValueType
 } from './metric-formatter'
-import { DashboardQuery } from '../../query'
+import { BreakdownResultMeta, DashboardQuery } from '../../query'
 import { useQueryContext } from '../../query-context'
-import { PlausibleSite, useSiteContext } from '../../site-context'
 
 type MetricValues = Record<Metric, ValueType>
 
@@ -19,15 +18,11 @@ type ListItem = MetricValues & {
   comparison: MetricValues & { change: Record<Metric, number> }
 }
 
-function valueRenderProps(
-  listItem: ListItem,
-  metric: Metric,
-  site: PlausibleSite
-) {
+function valueRenderProps(listItem: ListItem, metric: Metric) {
   const value = listItem[metric]
 
   let comparison = null
-  if (site.flags.breakdown_comparisons_ui && listItem.comparison) {
+  if (listItem.comparison) {
     comparison = {
       value: listItem.comparison[metric],
       change: listItem.comparison.change[metric]
@@ -42,14 +37,14 @@ export default function MetricValue(props: {
   metric: Metric
   renderLabel: (query: DashboardQuery) => string
   formatter?: (value: ValueType) => string
+  meta: BreakdownResultMeta
 }) {
   const { query } = useQueryContext()
-  const site = useSiteContext()
 
   const { metric, listItem } = props
   const { value, comparison } = useMemo(
-    () => valueRenderProps(listItem, metric, site),
-    [listItem, metric, site]
+    () => valueRenderProps(listItem, metric),
+    [listItem, metric]
   )
   const metricLabel = useMemo(() => props.renderLabel(query), [query, props])
   const shortFormatter = props.formatter ?? MetricFormatterShort[metric]
@@ -69,13 +64,13 @@ export default function MetricValue(props: {
         />
       }
     >
-      <span data-testid="metric-value">
+      <span className="cursor-default" data-testid="metric-value">
         {shortFormatter(value)}
         {comparison ? (
           <ChangeArrow
             change={comparison.change}
             metric={metric}
-            className="pl-2"
+            className="inline-block pl-1 w-4"
             hideNumber
           />
         ) : null}
@@ -89,13 +84,15 @@ function ComparisonTooltipContent({
   comparison,
   metric,
   metricLabel,
-  formatter
+  formatter,
+  meta
 }: {
   value: ValueType
   comparison: { value: ValueType; change: number } | null
   metric: Metric
   metricLabel: string
   formatter?: (value: ValueType) => string
+  meta: BreakdownResultMeta
 }) {
   const longFormatter = formatter ?? MetricFormatterLong[metric]
 
@@ -109,17 +106,36 @@ function ComparisonTooltipContent({
 
   if (comparison) {
     return (
-      <div className="whitespace-nowrap">
-        {longFormatter(value)} vs. {longFormatter(comparison.value)}
-        {label}
-        <ChangeArrow
-          metric={metric}
-          change={comparison.change}
-          className="pl-4 text-xs text-gray-100"
-        />
+      <div className="text-left whitespace-nowrap py-1 space-y-2">
+        <div>
+          <div className="flex items-center">
+            <span className="font-bold text-base">
+              {longFormatter(value)} {label}
+            </span>
+            <ChangeArrow
+              metric={metric}
+              change={comparison.change}
+              className="pl-4 text-xs text-gray-100"
+            />
+          </div>
+          <div className="font-normal text-xs">{meta.date_range_label}</div>
+        </div>
+        <div>vs</div>
+        <div>
+          <div className="font-bold text-base">
+            {longFormatter(comparison.value)} {label}
+          </div>
+          <div className="font-normal text-xs">
+            {meta.comparison_date_range_label}
+          </div>
+        </div>
       </div>
     )
   } else {
-    return <div className="whitespace-nowrap">{longFormatter(value)}</div>
+    return (
+      <div className="whitespace-nowrap">
+        {longFormatter(value)} {label}
+      </div>
+    )
   }
 }
diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js
index 81a16139560b..5ea1a227e886 100644
--- a/assets/js/dashboard/stats/reports/metrics.js
+++ b/assets/js/dashboard/stats/reports/metrics.js
@@ -45,12 +45,13 @@ export class Metric {
     this.renderValue = this.renderValue.bind(this)
   }
 
-  renderValue(listItem) {
+  renderValue(listItem, meta) {
     return (
       <MetricValue
         listItem={listItem}
         metric={this.key}
         renderLabel={this.renderLabel}
+        meta={meta}
         formatter={this.formatter}
       />
     )
@@ -97,7 +98,7 @@ export const createVisitors = (props) => {
 export const createConversionRate = (props) => {
   const renderLabel = (_query) => 'CR'
   return new Metric({
-    width: 'w-16',
+    width: 'w-24',
     ...props,
     key: 'conversion_rate',
     renderLabel,
@@ -108,7 +109,7 @@ export const createConversionRate = (props) => {
 export const createPercentage = (props) => {
   const renderLabel = (_query) => '%'
   return new Metric({
-    width: 'w-16',
+    width: 'w-24',
     ...props,
     key: 'percentage',
     renderLabel,
diff --git a/assets/js/dashboard/util/date.test.ts b/assets/js/dashboard/util/date.test.ts
index 98624162ec81..99a2085fa70f 100644
--- a/assets/js/dashboard/util/date.test.ts
+++ b/assets/js/dashboard/util/date.test.ts
@@ -4,6 +4,21 @@ import { formatISO, nowForSite, shiftMonths, yesterday } from './date'
 
 jest.useFakeTimers()
 
+describe(`${nowForSite.name} and ${formatISO.name}`, () => {
+  /* prettier-ignore */
+  const cases = [
+    [ 'Los Angeles/America', -3600 * 6, '2024-11-01T20:00:00.000Z', '2024-11-01' ],
+    [ 'Sydney/Australia', 3600 * 6, '2024-11-01T20:00:00.000Z', '2024-11-02' ]
+  ]
+  test.each(cases)(
+    'in timezone of %s (offset %p) at %s, today is %s',
+    (_tz, offset, utcTime, expectedToday) => {
+      jest.setSystemTime(new Date(utcTime))
+      expect(formatISO(nowForSite({ offset }))).toEqual(expectedToday)
+    }
+  )
+})
+
 /* prettier-ignore */
 const dstChangeOverDayEstonia = [
 //  system time                 today         yesterday     today-2mo     today+2mo     today-12mo    offset 
diff --git a/assets/js/dashboard/util/url-search-params.test.ts b/assets/js/dashboard/util/url-search-params.test.ts
index fb47ab15a98b..fb73c018088e 100644
--- a/assets/js/dashboard/util/url-search-params.test.ts
+++ b/assets/js/dashboard/util/url-search-params.test.ts
@@ -11,6 +11,11 @@ import {
   stringifySearchEntry
 } from './url'
 
+beforeEach(() => {
+  // Silence logs in tests
+  jest.spyOn(console, 'error').mockImplementation(jest.fn())
+})
+
 describe('using json URL parsing with URLSearchParams intermediate', () => {
   it.each([['#'], ['&'], ['=']])('throws on special symbol %p', (s) => {
     const searchString = `?param=${encodeURIComponent(s)}`
diff --git a/assets/package.json b/assets/package.json
index 4e9078d5ee82..09ca12902bb5 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -3,7 +3,7 @@
   "version": "1.4.0",
   "license": "AGPL-3.0-or-later",
   "scripts": {
-    "test": "jest",
+    "test": "TZ=UTC jest",
     "format": "prettier --write",
     "check-format": "prettier --check **/*.{js,css,ts,tsx} --require-pragma",
     "eslint": "eslint js/**",
diff --git a/assets/test-utils/set-fixed-timezone.ts b/assets/test-utils/set-fixed-timezone.ts
deleted file mode 100644
index 42da38697fe1..000000000000
--- a/assets/test-utils/set-fixed-timezone.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * @format
- */
-
-/**
- * @returns sets a fixed timezone for the test process,
- * otherwise test runs on different servers and machines may be inconsistent
- */
-function setFixedTimezone() {
-  process.env.TZ = 'UTC'
-}
-
-setFixedTimezone()
diff --git a/extra/lib/plausible/stats/sampling.ex b/extra/lib/plausible/stats/sampling.ex
index bfe3d88d7612..19b132e40e37 100644
--- a/extra/lib/plausible/stats/sampling.ex
+++ b/extra/lib/plausible/stats/sampling.ex
@@ -17,8 +17,8 @@ defmodule Plausible.Stats.Sampling do
     end
   end
 
-  @spec add_query_hint(Ecto.Query.t(), pos_integer()) :: Ecto.Query.t()
-  def add_query_hint(%Ecto.Query{} = query, threshold) when is_integer(threshold) do
+  @spec add_query_hint(Ecto.Query.t(), pos_integer() | float()) :: Ecto.Query.t()
+  def add_query_hint(%Ecto.Query{} = query, threshold) when is_number(threshold) do
     from(x in query, hints: unsafe_fragment(^"SAMPLE #{threshold}"))
   end
 
@@ -27,15 +27,32 @@ defmodule Plausible.Stats.Sampling do
     add_query_hint(query, @default_sample_threshold)
   end
 
-  @spec put_threshold(Plausible.Stats.Query.t(), map()) :: Plausible.Stats.Query.t()
-  def put_threshold(query, params) do
+  @spec put_threshold(Plausible.Stats.Query.t(), Plausible.Site.t(), map()) ::
+          Plausible.Stats.Query.t()
+  def put_threshold(query, site, params) do
     sample_threshold =
       case params["sample_threshold"] do
-        nil -> @default_sample_threshold
-        "infinite" -> :infinite
-        value -> String.to_integer(value)
+        nil ->
+          site_default_threshold(site)
+
+        "infinite" ->
+          :infinite
+
+        value_string ->
+          {value, _} = Float.parse(value_string)
+          value
       end
 
     Map.put(query, :sample_threshold, sample_threshold)
   end
+
+  defp site_default_threshold(site) do
+    if FunWithFlags.enabled?(:fractional_hardcoded_sample_rate, for: site) do
+      # Hard-coded sample rate to temporarily fix an issue for a client.
+      # To be solved as part of https://3.basecamp.com/5308029/buckets/39750953/messages/7978775089
+      0.1
+    else
+      @default_sample_threshold
+    end
+  end
 end
diff --git a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex
index 6d5d90054438..447fa0e5469e 100644
--- a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex
+++ b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex
@@ -97,7 +97,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
   def delete_site(conn, %{"site_id" => site_id}) do
     case get_site(conn.assigns.current_user, site_id, [:owner]) do
       {:ok, site} ->
-        {:ok, _} = Plausible.Site.Removal.run(site.domain)
+        {:ok, _} = Plausible.Site.Removal.run(site)
         json(conn, %{"deleted" => true})
 
       {:error, :site_not_found} ->
diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex
index 04fea5be7112..32bc19658a17 100644
--- a/lib/plausible/application.ex
+++ b/lib/plausible/application.ex
@@ -108,7 +108,7 @@ defmodule Plausible.Application do
 
     setup_geolocation()
     Location.load_all()
-    Plausible.Ingestion.Acquisition.init()
+    Plausible.Ingestion.Source.init()
     Plausible.Geo.await_loader()
 
     Supervisor.start_link(List.flatten(children), opts)
diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex
index ba1ecae1c2c7..b5a31faf9e11 100644
--- a/lib/plausible/auth/auth.ex
+++ b/lib/plausible/auth/auth.ex
@@ -95,18 +95,23 @@ defmodule Plausible.Auth do
 
   def delete_user(user) do
     Repo.transaction(fn ->
-      user =
-        user
-        |> Repo.preload(site_memberships: :site)
+      user = Repo.preload(user, site_memberships: :site)
 
       for membership <- user.site_memberships do
-        Repo.delete!(membership)
-
         if membership.role == :owner do
-          Plausible.Site.Removal.run(membership.site.domain)
+          Plausible.Site.Removal.run(membership.site)
         end
+
+        Repo.delete_all(
+          from(
+            sm in Plausible.Site.Membership,
+            where: sm.id == ^membership.id
+          )
+        )
       end
 
+      {:ok, team} = Plausible.Teams.get_or_create(user)
+      Repo.delete!(team)
       Repo.delete!(user)
     end)
   end
diff --git a/lib/plausible/auth/grace_period.ex b/lib/plausible/auth/grace_period.ex
index 226736144060..f1ab6fc37b3d 100644
--- a/lib/plausible/auth/grace_period.ex
+++ b/lib/plausible/auth/grace_period.ex
@@ -82,7 +82,7 @@ defmodule Plausible.Auth.GracePeriod do
   def active?(user_or_team)
 
   def active?(%{grace_period: %__MODULE__{end_date: %Date{} = end_date}}) do
-    Timex.diff(end_date, Date.utc_today(), :days) >= 0
+    Date.diff(end_date, Date.utc_today()) >= 0
   end
 
   def active?(%{grace_period: %__MODULE__{manual_lock: true}}) do
diff --git a/lib/plausible/auth/user_admin.ex b/lib/plausible/auth/user_admin.ex
index 72df24b487b4..a93ddd84bc6d 100644
--- a/lib/plausible/auth/user_admin.ex
+++ b/lib/plausible/auth/user_admin.ex
@@ -106,7 +106,7 @@ defmodule Plausible.Auth.UserAdmin do
         "ended"
 
       %{end_date: %Date{} = end_date} ->
-        days_left = Timex.diff(end_date, DateTime.utc_now(), :days)
+        days_left = Date.diff(end_date, Date.utc_today())
         "#{days_left} days left"
     end
   end
diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex
index 2a224cce2dc8..9589c1e0d82d 100644
--- a/lib/plausible/billing/enterprise_plan.ex
+++ b/lib/plausible/billing/enterprise_plan.ex
@@ -2,6 +2,8 @@ defmodule Plausible.Billing.EnterprisePlan do
   use Ecto.Schema
   import Ecto.Changeset
 
+  @type t() :: %__MODULE__{}
+
   @required_fields [
     :user_id,
     :paddle_plan_id,
diff --git a/lib/plausible/billing/plan.ex b/lib/plausible/billing/plan.ex
index 78a6b8551c5a..132a7c585d33 100644
--- a/lib/plausible/billing/plan.ex
+++ b/lib/plausible/billing/plan.ex
@@ -4,7 +4,7 @@ defmodule Plausible.Billing.Plan do
   use Ecto.Schema
   import Ecto.Changeset
 
-  @type t() :: %__MODULE__{} | :enterprise
+  @type t() :: %__MODULE__{}
 
   embedded_schema do
     # Due to grandfathering, we sometimes need to check the "generation" (e.g.
diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex
index 901ed2d968b8..15f56e4964ae 100644
--- a/lib/plausible/billing/plans.ex
+++ b/lib/plausible/billing/plans.ex
@@ -105,6 +105,8 @@ defmodule Plausible.Billing.Plans do
     end)
   end
 
+  @spec get_subscription_plan(nil | Subscription.t()) ::
+          nil | :free_10k | Plan.t() | EnterprisePlan.t()
   def get_subscription_plan(nil), do: nil
 
   def get_subscription_plan(subscription) do
diff --git a/lib/plausible/clickhouse_event_v2.ex b/lib/plausible/clickhouse_event_v2.ex
index 017d576392eb..77dfd446b7ee 100644
--- a/lib/plausible/clickhouse_event_v2.ex
+++ b/lib/plausible/clickhouse_event_v2.ex
@@ -26,6 +26,7 @@ defmodule Plausible.ClickhouseEventV2 do
     # Session attributes
     field :referrer, :string
     field :referrer_source, :string
+    field :click_id_param, Ch, type: "LowCardinality(String)"
     field :channel, Ch, type: "LowCardinality(String)"
     field :utm_medium, :string
     field :utm_source, :string
@@ -72,6 +73,7 @@ defmodule Plausible.ClickhouseEventV2 do
     :referrer,
     :referrer_source,
     :channel,
+    :click_id_param,
     :utm_medium,
     :utm_source,
     :utm_campaign,
diff --git a/lib/plausible/clickhouse_session_v2.ex b/lib/plausible/clickhouse_session_v2.ex
index edfbc233c6cb..ef1ead43c0fd 100644
--- a/lib/plausible/clickhouse_session_v2.ex
+++ b/lib/plausible/clickhouse_session_v2.ex
@@ -59,6 +59,7 @@ defmodule Plausible.ClickhouseSessionV2 do
     field :referrer, :string
     field :referrer_source, :string
     field :channel, Ch, type: "LowCardinality(String)"
+    field :click_id_param, Ch, type: "LowCardinality(String)"
 
     field :country_code, Ch, type: "LowCardinality(FixedString(2))"
     field :subdivision1_code, Ch, type: "LowCardinality(String)"
diff --git a/lib/plausible/data_migration/backfill_teams.ex b/lib/plausible/data_migration/backfill_teams.ex
index 89314e57a6d3..c1a87a51e2c9 100644
--- a/lib/plausible/data_migration/backfill_teams.ex
+++ b/lib/plausible/data_migration/backfill_teams.ex
@@ -17,7 +17,9 @@ defmodule Plausible.DataMigration.BackfillTeams do
     end
   end
 
-  def run() do
+  def run(opts \\ []) do
+    dry_run? = Keyword.get(opts, :dry_run?, true)
+
     # Teams backfill
     db_url =
       System.get_env(
@@ -27,10 +29,30 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     @repo.start(db_url, pool_size: 2 * @max_concurrency)
 
-    backfill()
+    backfill(dry_run?)
   end
 
-  defp backfill() do
+  defp backfill(dry_run?) do
+    orphaned_teams =
+      from(
+        t in Plausible.Teams.Team,
+        left_join: tm in assoc(t, :team_memberships),
+        where: is_nil(tm.id),
+        left_join: sub in assoc(t, :subscription),
+        where: is_nil(sub.id),
+        left_join: s in assoc(t, :sites),
+        where: is_nil(s.id)
+      )
+      |> @repo.all(timeout: :infinity)
+
+    log("Found #{length(orphaned_teams)} orphaned teams...")
+
+    if not dry_run? do
+      delete_orphaned_teams(orphaned_teams)
+
+      log("Deleted orphaned teams")
+    end
+
     sites_without_teams =
       from(
         s in Plausible.Site,
@@ -44,9 +66,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(sites_without_teams)} sites without teams...")
 
-    teams_count = backfill_teams(sites_without_teams)
+    if not dry_run? do
+      teams_count = backfill_teams(sites_without_teams)
 
-    log("Backfilled #{teams_count} teams.")
+      log("Backfilled #{teams_count} teams.")
+    end
 
     owner_site_memberships_query =
       from(
@@ -72,9 +96,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
       "Found #{length(users_with_subscriptions_without_sites)} users with subscriptions without sites..."
     )
 
-    teams_count = backfill_teams_for_users(users_with_subscriptions_without_sites)
+    if not dry_run? do
+      teams_count = backfill_teams_for_users(users_with_subscriptions_without_sites)
 
-    log("Backfilled #{teams_count} teams from users with subscriptions without sites.")
+      log("Backfilled #{teams_count} teams from users with subscriptions without sites.")
+    end
 
     # Stale teams sync
 
@@ -88,21 +114,30 @@ defmodule Plausible.DataMigration.BackfillTeams do
           is_distinct(o.trial_expiry_date, t.trial_expiry_date) or
             is_distinct(o.accept_traffic_until, t.accept_traffic_until) or
             is_distinct(o.allow_next_upgrade_override, t.allow_next_upgrade_override) or
-            is_distinct(o.grace_period["id"], t.grace_period["id"]) or
-            is_distinct(o.grace_period["is_over"], t.grace_period["is_over"]) or
-            is_distinct(o.grace_period["end_date"], t.grace_period["end_date"]) or
-            is_distinct(o.grace_period["manual_lock"], t.grace_period["manual_lock"]),
+            (is_distinct(o.grace_period, t.grace_period) and
+               (is_distinct(o.grace_period["id"], t.grace_period["id"]) or
+                  (is_nil(o.grace_period["is_over"]) and t.grace_period["is_over"] == true) or
+                  (o.grace_period["is_over"] == true and t.grace_period["is_over"] == false) or
+                  (o.grace_period["is_over"] == false and t.grace_period["is_over"] == true) or
+                  is_distinct(o.grace_period["end_date"], t.grace_period["end_date"]) or
+                  (is_nil(o.grace_period["manual_lock"]) and t.grace_period["manual_lock"] == true) or
+                  (o.grace_period["manual_lock"] == true and
+                     t.grace_period["manual_lock"] == false) or
+                  (o.grace_period["manual_lock"] == false and
+                     t.grace_period["manual_lock"] == true))),
         preload: [team_memberships: {tm, user: o}]
       )
       |> @repo.all(timeout: :infinity)
 
     log("Found #{length(stale_teams)} teams which have fields out of sync...")
 
-    sync_teams(stale_teams)
+    if not dry_run? do
+      sync_teams(stale_teams)
 
-    # Subsciprtions backfill
+      log("Brought out of sync teams up to date.")
+    end
 
-    log("Brought out of sync teams up to date.")
+    # Subsciprtions backfill
 
     subscriptions_without_teams =
       from(
@@ -118,9 +153,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(subscriptions_without_teams)} subscriptions without team...")
 
-    backfill_subscriptions(subscriptions_without_teams)
+    if not dry_run? do
+      backfill_subscriptions(subscriptions_without_teams)
 
-    log("All subscriptions are linked to a team now.")
+      log("All subscriptions are linked to a team now.")
+    end
 
     # Enterprise plans backfill
 
@@ -138,9 +175,40 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(enterprise_plans_without_teams)} enterprise plans without team...")
 
-    backfill_enterprise_plans(enterprise_plans_without_teams)
+    if not dry_run? do
+      backfill_enterprise_plans(enterprise_plans_without_teams)
+
+      log("All enterprise plans are linked to a team now.")
+    end
+
+    # Guest memberships with mismatched team site
+
+    mismatched_guest_memberships_to_remove =
+      from(
+        gm in Teams.GuestMembership,
+        inner_join: tm in assoc(gm, :team_membership),
+        inner_join: s in assoc(gm, :site),
+        where: tm.team_id != s.team_id
+      )
+      |> @repo.all()
+
+    log(
+      "Found #{length(mismatched_guest_memberships_to_remove)} guest memberships with mismatched team to remove..."
+    )
 
-    log("All enterprise plans are linked to a team now.")
+    if not dry_run? do
+      team_ids_to_prune = remove_guest_memberships(mismatched_guest_memberships_to_remove)
+
+      log("Pruning guest team memberships for #{length(team_ids_to_prune)} teams...")
+
+      from(t in Teams.Team, where: t.id in ^team_ids_to_prune)
+      |> @repo.all(timeout: :infinity)
+      |> Enum.each(fn team ->
+        Plausible.Teams.Memberships.prune_guests(team)
+      end)
+
+      log("Guest memberships with mismatched team cleared.")
+    end
 
     # Guest Memberships cleanup
 
@@ -165,17 +233,19 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(guest_memberships_to_remove)} guest memberships to remove...")
 
-    team_ids_to_prune = remove_guest_memberships(guest_memberships_to_remove)
+    if not dry_run? do
+      team_ids_to_prune = remove_guest_memberships(guest_memberships_to_remove)
 
-    log("Pruning guest team memberships for #{length(team_ids_to_prune)} teams...")
+      log("Pruning guest team memberships for #{length(team_ids_to_prune)} teams...")
 
-    from(t in Teams.Team, where: t.id in ^team_ids_to_prune)
-    |> @repo.all(timeout: :infinity)
-    |> Enum.each(fn team ->
-      Plausible.Teams.Memberships.prune_guests(team)
-    end)
+      from(t in Teams.Team, where: t.id in ^team_ids_to_prune)
+      |> @repo.all(timeout: :infinity)
+      |> Enum.each(fn team ->
+        Plausible.Teams.Memberships.prune_guests(team)
+      end)
 
-    log("Guest memberships cleared.")
+      log("Guest memberships cleared.")
+    end
 
     # Guest Memberships backfill
 
@@ -205,9 +275,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
       "Found #{length(site_memberships_to_backfill)} site memberships without guest membership..."
     )
 
-    backfill_guest_memberships(site_memberships_to_backfill)
+    if not dry_run? do
+      backfill_guest_memberships(site_memberships_to_backfill)
 
-    log("Backfilled missing guest memberships.")
+      log("Backfilled missing guest memberships.")
+    end
 
     # Stale guest memberships sync
 
@@ -228,9 +300,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(stale_guest_memberships)} guest memberships with role out of sync...")
 
-    sync_guest_memberships(stale_guest_memberships)
+    if not dry_run? do
+      sync_guest_memberships(stale_guest_memberships)
 
-    log("All guest memberships are up to date now.")
+      log("All guest memberships are up to date now.")
+    end
 
     # Guest invitations cleanup
 
@@ -256,17 +330,19 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(guest_invitations_to_remove)} guest invitations to remove...")
 
-    team_ids_to_prune = remove_guest_invitations(guest_invitations_to_remove)
+    if not dry_run? do
+      team_ids_to_prune = remove_guest_invitations(guest_invitations_to_remove)
 
-    log("Pruning guest team invitations for #{length(team_ids_to_prune)} teams...")
+      log("Pruning guest team invitations for #{length(team_ids_to_prune)} teams...")
 
-    from(t in Teams.Team, where: t.id in ^team_ids_to_prune)
-    |> @repo.all(timeout: :infinity)
-    |> Enum.each(fn team ->
-      Plausible.Teams.Invitations.prune_guest_invitations(team)
-    end)
+      from(t in Teams.Team, where: t.id in ^team_ids_to_prune)
+      |> @repo.all(timeout: :infinity)
+      |> Enum.each(fn team ->
+        Plausible.Teams.Invitations.prune_guest_invitations(team)
+      end)
 
-    log("Guest invitations cleared.")
+      log("Guest invitations cleared.")
+    end
 
     # Guest invitations backfill
 
@@ -296,9 +372,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
       "Found #{length(site_invitations_to_backfill)} site invitations without guest invitation..."
     )
 
-    backfill_guest_invitations(site_invitations_to_backfill)
+    if not dry_run? do
+      backfill_guest_invitations(site_invitations_to_backfill)
 
-    log("Backfilled missing guest invitations.")
+      log("Backfilled missing guest invitations.")
+    end
 
     # Stale guest invitations sync
 
@@ -319,9 +397,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(stale_guest_invitations)} guest invitations with role out of sync...")
 
-    sync_guest_invitations(stale_guest_invitations)
+    if not dry_run? do
+      sync_guest_invitations(stale_guest_invitations)
 
-    log("All guest invitations are up to date now.")
+      log("All guest invitations are up to date now.")
+    end
 
     # Site transfers cleanup
 
@@ -343,9 +423,11 @@ defmodule Plausible.DataMigration.BackfillTeams do
 
     log("Found #{length(site_transfers_to_remove)} site transfers to remove...")
 
-    remove_site_transfers(site_transfers_to_remove)
+    if not dry_run? do
+      remove_site_transfers(site_transfers_to_remove)
 
-    log("Site transfers cleared.")
+      log("Site transfers cleared.")
+    end
 
     # Site transfers backfill
 
@@ -373,11 +455,17 @@ defmodule Plausible.DataMigration.BackfillTeams do
       "Found #{length(site_invitations_to_backfill)} ownership transfers without site transfer..."
     )
 
-    backfill_site_transfers(site_invitations_to_backfill)
+    if not dry_run? do
+      backfill_site_transfers(site_invitations_to_backfill)
 
-    log("Backfilled missing site transfers.")
+      log("Backfilled missing site transfers.")
 
-    log("All data are up to date now!")
+      log("All data are up to date now!")
+    end
+  end
+
+  def delete_orphaned_teams(teams) do
+    Enum.each(teams, &@repo.delete!(&1))
   end
 
   defp backfill_teams(sites) do
@@ -482,11 +570,17 @@ defmodule Plausible.DataMigration.BackfillTeams do
         :allow_next_upgrade_override,
         owner.allow_next_upgrade_override
       )
-      |> Ecto.Changeset.put_embed(:grace_period, owner.grace_period)
+      |> Ecto.Changeset.put_embed(:grace_period, embed_params(owner.grace_period))
       |> @repo.update!()
     end)
   end
 
+  defp embed_params(nil), do: nil
+
+  defp embed_params(grace_period) do
+    Map.from_struct(grace_period)
+  end
+
   defp backfill_subscriptions(subscriptions) do
     subscriptions
     |> Enum.with_index()
diff --git a/lib/plausible/data_migration/teams_consistency_check.ex b/lib/plausible/data_migration/teams_consistency_check.ex
new file mode 100644
index 000000000000..63e59f74242d
--- /dev/null
+++ b/lib/plausible/data_migration/teams_consistency_check.ex
@@ -0,0 +1,334 @@
+defmodule Plausible.DataMigration.TeamsConsitencyCheck do
+  @moduledoc """
+  Verify consistency of teams.
+  """
+
+  import Ecto.Query
+
+  alias Plausible.Teams
+
+  @repo Plausible.DataMigration.PostgresRepo
+
+  defmacrop is_distinct(f1, f2) do
+    quote do
+      fragment("? IS DISTINCT FROM ?", unquote(f1), unquote(f2))
+    end
+  end
+
+  def run() do
+    # Teams consistency check
+    db_url =
+      System.get_env(
+        "TEAMS_MIGRATION_DB_URL",
+        Application.get_env(:plausible, Plausible.Repo)[:url]
+      )
+
+    @repo.start(db_url, pool_size: 1)
+
+    check()
+  end
+
+  defp check() do
+    # Sites without teams
+
+    sites_without_teams_count =
+      from(
+        s in Plausible.Site,
+        where: is_nil(s.team_id)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{sites_without_teams_count} sites without teams")
+
+    # Teams without owner
+
+    owner_membership_query =
+      from(
+        tm in Teams.Membership,
+        where: tm.team_id == parent_as(:team).id,
+        where: tm.role == :owner,
+        select: 1
+      )
+
+    teams_without_owner_count =
+      from(
+        t in Plausible.Teams.Team,
+        as: :team,
+        where: not exists(owner_membership_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{teams_without_owner_count} teams without owner")
+
+    # Subscriptions without teams
+
+    subscriptions_without_teams_count =
+      from(
+        s in Plausible.Billing.Subscription,
+        where: is_nil(s.team_id)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{subscriptions_without_teams_count} subscriptions without teams")
+
+    # Subscriptions out of sync
+
+    subscriptions_out_of_sync_count =
+      from(
+        s in Plausible.Billing.Subscription,
+        inner_join: u in assoc(s, :user),
+        left_join: tm in assoc(u, :team_memberships),
+        on: tm.role == :owner,
+        where: s.team_id != tm.team_id
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{subscriptions_out_of_sync_count} subscriptions out of sync")
+
+    # Enterprise plans without teams
+
+    enterprise_plans_without_teams_count =
+      from(
+        ep in Plausible.Billing.EnterprisePlan,
+        where: is_nil(ep.team_id)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{enterprise_plans_without_teams_count} enterprise_plans without teams")
+
+    # Enterprise plans out of sync
+
+    enterprise_plans_out_of_sync_count =
+      from(
+        ep in Plausible.Billing.EnterprisePlan,
+        inner_join: u in assoc(ep, :user),
+        left_join: tm in assoc(u, :team_memberships),
+        on: tm.role == :owner,
+        where: ep.team_id != tm.team_id
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{enterprise_plans_out_of_sync_count} enterprise_plans out of sync")
+
+    # Teams out of sync
+
+    teams_out_of_sync_count =
+      from(
+        t in Teams.Team,
+        inner_join: tm in assoc(t, :team_memberships),
+        inner_join: o in assoc(tm, :user),
+        where: tm.role == :owner,
+        where:
+          is_distinct(o.trial_expiry_date, t.trial_expiry_date) or
+            is_distinct(o.accept_traffic_until, t.accept_traffic_until) or
+            is_distinct(o.allow_next_upgrade_override, t.allow_next_upgrade_override) or
+            (is_distinct(o.grace_period, t.grace_period) and
+               (is_distinct(o.grace_period["id"], t.grace_period["id"]) or
+                  (is_nil(o.grace_period["is_over"]) and t.grace_period["is_over"] == true) or
+                  (o.grace_period["is_over"] == true and t.grace_period["is_over"] == false) or
+                  (o.grace_period["is_over"] == false and t.grace_period["is_over"] == true) or
+                  is_distinct(o.grace_period["end_date"], t.grace_period["end_date"]) or
+                  (is_nil(o.grace_period["manual_lock"]) and t.grace_period["manual_lock"] == true) or
+                  (o.grace_period["manual_lock"] == true and
+                     t.grace_period["manual_lock"] == false) or
+                  (o.grace_period["manual_lock"] == false and
+                     t.grace_period["manual_lock"] == true))),
+        preload: [team_memberships: {tm, user: o}]
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{teams_out_of_sync_count} teams out of sync")
+
+    # Non-owner site memberships out of sync
+
+    respective_guest_memberships_query =
+      from(
+        tm in Teams.Membership,
+        inner_join: gm in assoc(tm, :guest_memberships),
+        on:
+          gm.site_id == parent_as(:site_membership).site_id and
+            ((gm.role == :viewer and parent_as(:site_membership).role == :viewer) or
+               (gm.role == :editor and parent_as(:site_membership).role == :admin)),
+        where: tm.user_id == parent_as(:site_membership).user_id,
+        select: 1
+      )
+
+    out_of_sync_nonowner_memberships_count =
+      from(
+        m in Plausible.Site.Membership,
+        as: :site_membership,
+        where: m.role != :owner,
+        where: not exists(respective_guest_memberships_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_nonowner_memberships_count} out of sync non-owner site memberships")
+
+    # Owner site memberships out of sync
+
+    respective_owner_memberships_query =
+      from(
+        tm in Teams.Membership,
+        where: tm.team_id == parent_as(:site).team_id and tm.role == :owner,
+        select: 1
+      )
+
+    out_of_sync_owner_memberships_count =
+      from(
+        m in Plausible.Site.Membership,
+        as: :site_membership,
+        inner_join: s in assoc(m, :site),
+        as: :site,
+        where: m.role == :owner,
+        where: not exists(respective_owner_memberships_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_owner_memberships_count} out of sync owner site memberships")
+
+    # Site invitations out of sync
+
+    respective_guest_invitations_query =
+      from(
+        gi in Teams.GuestInvitation,
+        inner_join: ti in assoc(gi, :team_invitation),
+        on: ti.email == parent_as(:site_invitation).email,
+        where: gi.site_id == parent_as(:site_invitation).site_id,
+        select: 1
+      )
+
+    out_of_sync_site_invitations_count =
+      from(
+        i in Plausible.Auth.Invitation,
+        as: :site_invitation,
+        where: i.role != :owner,
+        where: not exists(respective_guest_invitations_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_site_invitations_count} out of sync site invitations")
+
+    # Site invitations out of sync
+
+    respective_site_transfers_query =
+      from(
+        st in Teams.SiteTransfer,
+        where: st.email == parent_as(:site_invitation).email,
+        where: st.site_id == parent_as(:site_invitation).site_id,
+        select: 1
+      )
+
+    out_of_sync_site_transfers_count =
+      from(
+        i in Plausible.Auth.Invitation,
+        as: :site_invitation,
+        where: i.role == :owner,
+        where: not exists(respective_site_transfers_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_site_transfers_count} out of sync site transfers")
+
+    # Guest memberships out of sync
+
+    respective_site_memberships_query =
+      from(
+        sm in Plausible.Site.Membership,
+        where: sm.site_id == parent_as(:guest_membership).site_id,
+        where: sm.user_id == parent_as(:team_membership).user_id,
+        where:
+          (sm.role == :viewer and parent_as(:guest_membership).role == :viewer) or
+            (sm.role == :admin and parent_as(:guest_membership).role == :editor),
+        select: 1
+      )
+
+    out_of_sync_guest_memberships_count =
+      from(
+        gm in Plausible.Teams.GuestMembership,
+        as: :guest_membership,
+        inner_join: tm in assoc(gm, :team_membership),
+        as: :team_membership,
+        where: tm.role != :owner,
+        where: not exists(respective_site_memberships_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_guest_memberships_count} out of sync guest memberships")
+
+    # Owner memberships out of sync
+
+    respective_site_memberships_query =
+      from(
+        sm in Plausible.Site.Membership,
+        where: sm.site_id == parent_as(:site).id,
+        where: sm.user_id == parent_as(:team_membership).user_id,
+        where: sm.role == :owner,
+        select: 1
+      )
+
+    out_of_sync_owner_memberships_count =
+      from(
+        tm in Plausible.Teams.Membership,
+        as: :team_membership,
+        inner_join: t in assoc(tm, :team),
+        inner_join: s in assoc(t, :sites),
+        as: :site,
+        where: tm.role == :owner,
+        where: not exists(respective_site_memberships_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_owner_memberships_count} out of sync owner team memberships")
+
+    # Guest invitations out of sync
+
+    respective_site_invitations_query =
+      from(
+        i in Plausible.Auth.Invitation,
+        where: i.site_id == parent_as(:guest_invitation).site_id,
+        where: i.email == parent_as(:team_invitation).email,
+        where:
+          (i.role == :viewer and parent_as(:guest_invitation).role == :viewer) or
+            (i.role == :admin and parent_as(:guest_invitation).role == :editor),
+        select: 1
+      )
+
+    out_of_sync_guest_invitations_count =
+      from(
+        gi in Plausible.Teams.GuestInvitation,
+        as: :guest_invitation,
+        inner_join: ti in assoc(gi, :team_invitation),
+        as: :team_invitation,
+        where: ti.role != :owner,
+        where: not exists(respective_site_invitations_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_guest_invitations_count} out of sync guest invitations")
+
+    # Team site transfers out of sync
+
+    respective_site_transfers_query =
+      from(
+        i in Plausible.Auth.Invitation,
+        where: i.site_id == parent_as(:site_transfer).site_id,
+        where: i.email == parent_as(:site_transfer).email,
+        where: i.role == :owner,
+        select: 1
+      )
+
+    out_of_sync_site_transfers_count =
+      from(
+        st in Plausible.Teams.SiteTransfer,
+        as: :site_transfer,
+        where: not exists(respective_site_transfers_query)
+      )
+      |> @repo.aggregate(:count, timeout: :infinity)
+
+    log("#{out_of_sync_site_transfers_count} out of sync team site transfers")
+  end
+
+  defp log(msg) do
+    IO.puts("[#{NaiveDateTime.utc_now(:second)}] #{msg}")
+  end
+end
diff --git a/lib/plausible/google/ga4/api.ex b/lib/plausible/google/ga4/api.ex
index 729914981447..b5ebc98bdedf 100644
--- a/lib/plausible/google/ga4/api.ex
+++ b/lib/plausible/google/ga4/api.ex
@@ -14,7 +14,7 @@ defmodule Plausible.Google.GA4.API do
           expires_at :: String.t()
         }
 
-  @per_page 250_000
+  @per_page 200_000
   @backoff_factor :timer.seconds(10)
   @max_attempts 5
 
diff --git a/lib/plausible/google/ga4/http.ex b/lib/plausible/google/ga4/http.ex
index 437fd1ed106f..aedbf5f92922 100644
--- a/lib/plausible/google/ga4/http.ex
+++ b/lib/plausible/google/ga4/http.ex
@@ -48,7 +48,7 @@ defmodule Plausible.Google.GA4.HTTP do
         url,
         [{"Authorization", "Bearer #{report_request.access_token}"}],
         params,
-        receive_timeout: 60_000
+        receive_timeout: 80_000
       )
 
     with {:ok, %{body: body}} <- response,
diff --git a/lib/plausible/imported/google_analytics4.ex b/lib/plausible/imported/google_analytics4.ex
index 2118d5797275..4cc4de2187ed 100644
--- a/lib/plausible/imported/google_analytics4.ex
+++ b/lib/plausible/imported/google_analytics4.ex
@@ -174,7 +174,7 @@ defmodule Plausible.Imported.GoogleAnalytics4 do
       site_id: site_id,
       import_id: import_id,
       date: get_date(row),
-      source: row.dimensions |> Map.fetch!("sessionSource") |> parse_referrer(),
+      source: row.dimensions |> Map.fetch!("sessionSource") |> parse_source(),
       referrer: nil,
       # Only `source` exists in GA4 API
       utm_source: nil,
@@ -343,14 +343,13 @@ defmodule Plausible.Imported.GoogleAnalytics4 do
   defp default_if_missing(value, default) when value in @missing_values, do: default
   defp default_if_missing(value, _default), do: value
 
-  defp parse_referrer(nil), do: nil
-  defp parse_referrer("(direct)"), do: nil
-  defp parse_referrer("google"), do: "Google"
-  defp parse_referrer("bing"), do: "Bing"
-  defp parse_referrer("duckduckgo"), do: "DuckDuckGo"
+  defp parse_source(nil), do: nil
+  defp parse_source("(direct)"), do: nil
+  defp parse_source("google"), do: "Google"
+  defp parse_source("bing"), do: "Bing"
+  defp parse_source("duckduckgo"), do: "DuckDuckGo"
 
-  defp parse_referrer(ref) do
-    RefInspector.parse("https://" <> ref)
-    |> PlausibleWeb.RefInspector.parse()
+  defp parse_source(ref) do
+    Plausible.Ingestion.Source.parse("https://" <> ref)
   end
 end
diff --git a/lib/plausible/ingestion/acquisition.ex b/lib/plausible/ingestion/acquisition.ex
index d6024bf0e154..d72dc11cbaf2 100644
--- a/lib/plausible/ingestion/acquisition.ex
+++ b/lib/plausible/ingestion/acquisition.ex
@@ -1,37 +1,53 @@
 defmodule Plausible.Ingestion.Acquisition do
-  @moduledoc false
+  @moduledoc """
+  This module is responsible for figuring out acquisition channel from event referrer_source.
+
+  Acquisition channel is the marketing channel where people come from and convert and help
+  users to understand and improve their marketing flow.
+
+  Note it uses priv/ga4-source-categories.csv as a source, which comes from https://support.google.com/analytics/answer/9756891?hl=en.
+
+  Notable differences from GA4 that have been implemented just for Plausible:
+  1. The @custom_source_categories module attribute contains a list of custom source categories that we have manually
+  added based on our own judgement and user feedback. For example we treat AI tools (ChatGPT, Perplexity) as search engines.
+  2. Google is in a privileged position to analyze paid traffic from within their own network. The biggest use-case is auto-tagged adwords campaigns.
+  We do our best by categorizing as paid search when source is Google and the url has `gclid` parameter. Same for source Bing and `msclkid` url parameter.
+  3. The @paid_sources module attribute in Plausible.Ingestion.Source contains a list of utm_sources that we will automatically categorize as paid traffic
+  regardless of the medium. Examples are `yt-ads`, `facebook_ad`, `adwords`, etc. See also: Plausible.Ingestion.Source.paid_source?/1
+  """
+
   @external_resource "priv/ga4-source-categories.csv"
+  @custom_source_categories [
+    {"hacker news", "SOURCE_CATEGORY_SOCIAL"},
+    {"yahoo!", "SOURCE_CATEGORY_SEARCH"},
+    {"gmail", "SOURCE_CATEGORY_EMAIL"},
+    {"telegram", "SOURCE_CATEGORY_SOCIAL"},
+    {"slack", "SOURCE_CATEGORY_SOCIAL"},
+    {"producthunt", "SOURCE_CATEGORY_SOCIAL"},
+    {"github", "SOURCE_CATEGORY_SOCIAL"},
+    {"steamcommunity.com", "SOURCE_CATEGORY_SOCIAL"},
+    {"statics.teams.cdn.office.net", "SOURCE_CATEGORY_SOCIAL"},
+    {"vkontakte", "SOURCE_CATEGORY_SOCIAL"},
+    {"threads", "SOURCE_CATEGORY_SOCIAL"},
+    {"ecosia", "SOURCE_CATEGORY_SEARCH"},
+    {"perplexity", "SOURCE_CATEGORY_SEARCH"},
+    {"brave", "SOURCE_CATEGORY_SEARCH"},
+    {"chatgpt.com", "SOURCE_CATEGORY_SEARCH"},
+    {"temu.com", "SOURCE_CATEGORY_SHOPPING"},
+    {"discord", "SOURCE_CATEGORY_SOCIAL"},
+    {"sogou", "SOURCE_CATEGORY_SEARCH"},
+    {"microsoft teams", "SOURCE_CATEGORY_SOCIAL"}
+  ]
   @source_categories Application.app_dir(:plausible, "priv/ga4-source-categories.csv")
                      |> File.read!()
                      |> NimbleCSV.RFC4180.parse_string(skip_headers: false)
                      |> Enum.map(fn [source, category] -> {source, category} end)
+                     |> then(&(@custom_source_categories ++ &1))
                      |> Enum.into(%{})
 
-  def init() do
-    :ets.new(__MODULE__, [
-      :named_table,
-      :set,
-      :public,
-      {:read_concurrency, true}
-    ])
-
-    [{"referers.yml", map}] = RefInspector.Database.list(:default)
-
-    Enum.flat_map(map, fn {_, entries} ->
-      Enum.map(entries, fn {_, _, _, _, _, _, name} ->
-        :ets.insert(__MODULE__, {String.downcase(name), name})
-      end)
-    end)
-  end
-
-  def find_mapping(source) do
-    case :ets.lookup(__MODULE__, source) do
-      [{_, name}] -> name
-      _ -> source
-    end
-  end
-
   def get_channel(request, source) do
+    source = source && String.downcase(source)
+
     cond do
       cross_network?(request) -> "Cross-network"
       paid_shopping?(request, source) -> "Paid Shopping"
@@ -44,7 +60,7 @@ defmodule Plausible.Ingestion.Acquisition do
       organic_social?(request, source) -> "Organic Social"
       organic_video?(request, source) -> "Organic Video"
       search_source?(source) -> "Organic Search"
-      email?(request) -> "Email"
+      email?(request, source) -> "Email"
       affiliates?(request) -> "Affiliates"
       audio?(request) -> "Audio"
       sms?(request) -> "SMS"
@@ -55,30 +71,32 @@ defmodule Plausible.Ingestion.Acquisition do
   end
 
   defp cross_network?(request) do
-    String.contains?(request.query_params["utm_campaign"] || "", "cross-network")
+    String.contains?(query_param(request, "utm_campaign"), "cross-network")
   end
 
   defp paid_shopping?(request, source) do
-    (shopping_source?(source) or shopping_campaign?(request.query_params["utm_campaign"])) and
-      paid_medium?(request.query_params["utm_medium"])
+    (shopping_source?(source) or shopping_campaign?(request)) and paid_medium?(request)
   end
 
   defp paid_search?(request, source) do
-    (search_source?(source) and paid_medium?(request.query_params["utm_medium"])) or
-      (source == "Google" and !!request.query_params["gclid"]) or
-      (source == "Bing" and !!request.query_params["msclkid"])
+    (search_source?(source) and paid_medium?(request)) or
+      (search_source?(source) and paid_source?(request)) or
+      (source == "google" and !!request.query_params["gclid"]) or
+      (source == "bing" and !!request.query_params["msclkid"])
   end
 
   defp paid_social?(request, source) do
-    social_source?(source) and paid_medium?(request.query_params["utm_medium"])
+    (social_source?(source) and paid_medium?(request)) or
+      (social_source?(source) and paid_source?(request))
   end
 
   defp paid_video?(request, source) do
-    video_source?(source) and paid_medium?(request.query_params["utm_medium"])
+    (video_source?(source) and paid_medium?(request)) or
+      (video_source?(source) and paid_source?(request))
   end
 
   defp display?(request) do
-    request.query_params["utm_medium"] in [
+    query_param(request, "utm_medium") in [
       "display",
       "banner",
       "expandable",
@@ -88,16 +106,16 @@ defmodule Plausible.Ingestion.Acquisition do
   end
 
   defp paid_other?(request) do
-    paid_medium?(request.query_params["utm_medium"])
+    paid_medium?(request)
   end
 
   defp organic_shopping?(request, source) do
-    shopping_source?(source) or shopping_campaign?(request.query_params["utm_campaign"])
+    shopping_source?(source) or shopping_campaign?(request)
   end
 
   defp organic_social?(request, source) do
     social_source?(source) or
-      request.query_params["utm_medium"] in [
+      query_param(request, "utm_medium") in [
         "social",
         "social-network",
         "social-media",
@@ -108,71 +126,88 @@ defmodule Plausible.Ingestion.Acquisition do
   end
 
   defp organic_video?(request, source) do
-    video_source?(source) or String.contains?(request.query_params["utm_medium"] || "", "video")
+    video_source?(source) or String.contains?(query_param(request, "utm_medium"), "video")
   end
 
   defp referral?(request, source) do
-    request.query_params["utm_medium"] in ["referral", "app", "link"] or
+    query_param(request, "utm_medium") in ["referral", "app", "link"] or
       !!source
   end
 
-  @email_tags ["email", "e-mail", "e_mail", "e mail"]
-  defp email?(request) do
-    String.contains?(request.query_params["utm_source"] || "", @email_tags) or
-      String.contains?(request.query_params["utm_medium"] || "", @email_tags)
+  @email_tags ["email", "e-mail", "e_mail", "e mail", "newsletter"]
+  defp email?(request, source) do
+    email_source?(source) or
+      String.contains?(query_param(request, "utm_source"), @email_tags) or
+      String.contains?(query_param(request, "utm_medium"), @email_tags)
   end
 
   defp affiliates?(request) do
-    request.query_params["utm_medium"] == "affiliate"
+    query_param(request, "utm_medium") == "affiliate"
   end
 
   defp audio?(request) do
-    request.query_params["utm_medium"] == "audio"
+    query_param(request, "utm_medium") == "audio"
   end
 
   defp sms?(request) do
-    request.query_params["utm_source"] == "sms" or
-      request.query_params["utm_medium"] == "sms"
+    query_param(request, "utm_source") == "sms" or
+      query_param(request, "utm_medium") == "sms"
   end
 
   defp mobile_push_notifications?(request, source) do
-    medium = request.query_params["utm_medium"] || ""
+    medium = query_param(request, "utm_medium")
 
     String.ends_with?(medium, "push") or
       String.contains?(medium, ["mobile", "notification"]) or
       source == "firebase"
   end
 
-  # # Helper functions for source and medium checks
   defp shopping_source?(nil), do: false
 
   defp shopping_source?(source) do
-    @source_categories[String.downcase(source)] == "SOURCE_CATEGORY_SHOPPING"
-  end
-
-  defp shopping_campaign?(campaign_name) do
-    Regex.match?(~r/^(.*(([^a-df-z]|^)shop|shopping).*)$/, campaign_name || "")
+    @source_categories[source] == "SOURCE_CATEGORY_SHOPPING"
   end
 
   defp search_source?(nil), do: false
 
   defp search_source?(source) do
-    @source_categories[String.downcase(source)] == "SOURCE_CATEGORY_SEARCH"
+    @source_categories[source] == "SOURCE_CATEGORY_SEARCH"
   end
 
   defp social_source?(nil), do: false
 
   defp social_source?(source) do
-    @source_categories[String.downcase(source)] == "SOURCE_CATEGORY_SOCIAL"
+    @source_categories[source] == "SOURCE_CATEGORY_SOCIAL"
   end
 
   defp video_source?(nil), do: false
 
   defp video_source?(source) do
-    @source_categories[String.downcase(source)] == "SOURCE_CATEGORY_VIDEO"
+    @source_categories[source] == "SOURCE_CATEGORY_VIDEO"
+  end
+
+  defp email_source?(nil), do: false
+
+  defp email_source?(source) do
+    @source_categories[source] == "SOURCE_CATEGORY_EMAIL"
+  end
+
+  defp shopping_campaign?(request) do
+    campaign_name = query_param(request, "utm_campaign")
+    Regex.match?(~r/^(.*(([^a-df-z]|^)shop|shopping).*)$/, campaign_name)
+  end
+
+  defp paid_medium?(request) do
+    medium = query_param(request, "utm_medium")
+    Regex.match?(~r/^(.*cp.*|ppc|retargeting|paid.*)$/, medium)
+  end
+
+  defp paid_source?(request) do
+    query_param(request, "utm_source")
+    |> Plausible.Ingestion.Source.paid_source?()
   end
 
-  defp paid_medium?(medium) do
-    Regex.match?(~r/^(.*cp.*|ppc|retargeting|paid.*)$/, medium || "")
+  defp query_param(request, name) do
+    String.downcase(request.query_params[name] || "")
   end
 end
diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex
index f177ee940816..313bf25dff23 100644
--- a/lib/plausible/ingestion/event.ex
+++ b/lib/plausible/ingestion/event.ex
@@ -251,14 +251,14 @@ defmodule Plausible.Ingestion.Event do
   end
 
   defp put_referrer(%__MODULE__{} = event, _context) do
-    ref = parse_referrer(event.request.uri, event.request.referrer)
-    source = get_referrer_source(event.request, ref)
+    source = Plausible.Ingestion.Source.resolve(event.request)
     channel = Plausible.Ingestion.Acquisition.get_channel(event.request, source)
 
     update_session_attrs(event, %{
       channel: channel,
       referrer_source: source,
-      referrer: clean_referrer(ref)
+      referrer: Plausible.Ingestion.Source.format_referrer(event.request.referrer),
+      click_id_param: get_click_id_param(event.request.query_params)
     })
   end
 
@@ -392,38 +392,13 @@ defmodule Plausible.Ingestion.Event do
     event
   end
 
-  defp parse_referrer(_uri, _referrer_str = nil), do: nil
+  @click_id_params ["gclid", "gbraid", "wbraid", "msclkid", "fbclid", "twclid"]
 
-  defp parse_referrer(uri, referrer_str) do
-    referrer_uri = URI.parse(referrer_str)
+  defp get_click_id_param(nil), do: nil
 
-    if Request.sanitize_hostname(referrer_uri.host) !== Request.sanitize_hostname(uri.host) &&
-         referrer_uri.host !== "localhost" do
-      RefInspector.parse(referrer_str)
-    end
-  end
-
-  defp get_referrer_source(request, ref) do
-    tagged_source =
-      request.query_params["utm_source"] ||
-        request.query_params["source"] ||
-        request.query_params["ref"]
-
-    if tagged_source do
-      Plausible.Ingestion.Acquisition.find_mapping(tagged_source)
-    else
-      PlausibleWeb.RefInspector.parse(ref)
-    end
-  end
-
-  defp clean_referrer(nil), do: nil
-
-  defp clean_referrer(ref) do
-    uri = URI.parse(ref.referer)
-
-    if PlausibleWeb.RefInspector.right_uri?(uri) do
-      PlausibleWeb.RefInspector.format_referrer(uri)
-    end
+  defp get_click_id_param(query_params) do
+    @click_id_params
+    |> Enum.find(fn param_name -> Map.has_key?(query_params, param_name) end)
   end
 
   defp parse_user_agent(%Request{user_agent: user_agent}) when is_binary(user_agent) do
diff --git a/lib/plausible/ingestion/source.ex b/lib/plausible/ingestion/source.ex
new file mode 100644
index 000000000000..2866db1914aa
--- /dev/null
+++ b/lib/plausible/ingestion/source.ex
@@ -0,0 +1,147 @@
+defmodule Plausible.Ingestion.Source do
+  @moduledoc """
+  Resolves the `source` dimension from a combination of `referer` header and either `utm_source`, `source`, or `ref` query parameter.
+
+  """
+  alias Plausible.Ingestion.Request
+
+  @external_resource "priv/custom_sources.json"
+  @custom_sources Application.app_dir(:plausible, "priv/custom_sources.json")
+                  |> File.read!()
+                  |> Jason.decode!()
+
+  @paid_sources Map.keys(@custom_sources)
+                |> Enum.filter(&String.ends_with?(&1, ["ads", "ad"]))
+                |> then(&["adwords" | &1])
+                |> MapSet.new()
+
+  def init() do
+    :ets.new(__MODULE__, [
+      :named_table,
+      :set,
+      :public,
+      {:read_concurrency, true}
+    ])
+
+    [{"referers.yml", map}] = RefInspector.Database.list(:default)
+
+    Enum.each(map, fn {_, entries} ->
+      Enum.each(entries, fn {_, _, _, _, _, _, name} ->
+        :ets.insert(__MODULE__, {String.downcase(name), name})
+      end)
+    end)
+
+    Enum.each(@custom_sources, fn {key, val} ->
+      :ets.insert(__MODULE__, {key, val})
+      :ets.insert(__MODULE__, {String.downcase(val), val})
+    end)
+  end
+
+  def paid_source?(source) do
+    MapSet.member?(@paid_sources, source)
+  end
+
+  @doc """
+  Resolves the source of a session based on query params and the `Referer` header.
+
+  When a query parameter like `utm_source` is present, it will be prioritized over the `Referer` header. When the URL does not contain a source tag, we fall
+  back to using `Referer` to determine the source. This module also takes care of certain transformations to make the data more useful for the user:
+  1. The RefInspector library is used to categorize referrers into "known" sources. For example, when the referrer is google.com or google.co.uk,
+  it will always be stored as "Google" which is more useful for marketers.
+  2. On top of the standard RefInspector behaviour, we also keep a list of `custom_sources.json` which extends it with referrers that we have seen in the wild.
+  For example, Wikipedia has many domains that need to be combined into a single known source. These could all in theory be [upstreamed](https://github.com/snowplow-referer-parser/referer-parser).
+  3. When a known source is supplied in utm_source (or source, ref) query parameter, we merge it with our known sources in a case-insensitive manner.
+  4. Our list of `custom_sources.json` also contains some commonly used utm_source shorthands for certain sources. URL tagging is a mess, and we can never do it
+  perfectly, but at least we're making an effort for the most commonly used ones. For example, `ig -> Instagram` and `adwords -> Google`.
+
+  ### Examples:
+
+    iex> alias Plausible.Ingestion.{Source, Request}
+    iex> base_request = %Request{uri: URI.parse("https://plausible.io")}
+    iex> Source.resolve(%{base_request | referrer: "https://google.com"}) # Known referrer from RefInspector
+    "Google"
+    iex> Source.resolve(%{base_request | query_params: %{"utm_source" => "google"}}) # Known source from RefInspector supplied as downcased utm_source by user
+    "Google"
+    iex> Source.resolve(%{base_request | query_params: %{"utm_source" => "GOOGLE"}}) # Known source from RefInspector supplied as uppercased utm_source by user
+    "Google"
+    iex> Source.resolve(%{base_request | referrer: "https://en.m.wikipedia.org"}) # Known referrer from custom_sources.json
+    "Wikipedia"
+    iex> Source.resolve(%{base_request | query_params: %{"utm_source" => "wikipedia"}}) # Known source from custom_sources.json supplied as downcased utm_source by user
+    "Wikipedia"
+    iex> Source.resolve(%{base_request | query_params: %{"utm_source" => "ig"}}) # Known utm_source from custom_sources.json
+    "Instagram"
+    iex> Source.resolve(%{base_request | referrer: "https://www.markosaric.com"}) # Unknown source, it is just stored as the domain name
+    "markosaric.com"
+  """
+  def resolve(request) do
+    tagged_source =
+      request.query_params["utm_source"] ||
+        request.query_params["source"] ||
+        request.query_params["ref"]
+
+    source =
+      cond do
+        tagged_source -> tagged_source
+        has_referral?(request) -> parse(request.referrer)
+        true -> nil
+      end
+
+    find_mapping(source)
+  end
+
+  def parse(ref) do
+    case RefInspector.parse(ref).source do
+      :unknown ->
+        uri = URI.parse(String.trim(ref))
+
+        if valid_referrer?(uri) do
+          format_referrer_host(uri)
+        end
+
+      source ->
+        source
+    end
+  end
+
+  def find_mapping(nil), do: nil
+
+  def find_mapping(source) do
+    case :ets.lookup(__MODULE__, String.downcase(source)) do
+      [{_, name}] -> name
+      _ -> source
+    end
+  end
+
+  def format_referrer(nil), do: nil
+
+  def format_referrer(referrer) do
+    referrer_uri = URI.parse(referrer)
+
+    if valid_referrer?(referrer_uri) do
+      path = String.trim_trailing(referrer_uri.path || "", "/")
+      format_referrer_host(referrer_uri) <> path
+    end
+  end
+
+  defp valid_referrer?(%URI{host: host, scheme: scheme})
+       when scheme in ["http", "https", "android-app"] and byte_size(host) > 0,
+       do: true
+
+  defp valid_referrer?(_), do: false
+
+  defp has_referral?(%Request{referrer: nil}), do: nil
+
+  defp has_referral?(%Request{referrer: referrer, uri: uri}) do
+    referrer_uri = URI.parse(referrer)
+
+    Request.sanitize_hostname(referrer_uri.host) !== Request.sanitize_hostname(uri.host) and
+      referrer_uri.host !== "localhost"
+  end
+
+  defp format_referrer_host(uri) do
+    protocol = if uri.scheme == "android-app", do: "android-app://", else: ""
+    host = String.replace_prefix(uri.host, "www.", "")
+
+    protocol <> host
+  end
+end
diff --git a/lib/plausible/plugins/api/token.ex b/lib/plausible/plugins/api/token.ex
index 7fe3c2f2d206..2b8cc2284c3d 100644
--- a/lib/plausible/plugins/api/token.ex
+++ b/lib/plausible/plugins/api/token.ex
@@ -81,7 +81,7 @@ defmodule Plausible.Plugins.API.Token do
     diff =
       if token.last_used_at do
         now = NaiveDateTime.utc_now()
-        Timex.diff(now, token.last_used_at, :minutes)
+        NaiveDateTime.diff(now, token.last_used_at, :minute)
       end
 
     cond do
diff --git a/lib/plausible/plugins/api/tokens.ex b/lib/plausible/plugins/api/tokens.ex
index d32559966718..8eb6d78a483a 100644
--- a/lib/plausible/plugins/api/tokens.ex
+++ b/lib/plausible/plugins/api/tokens.ex
@@ -63,7 +63,7 @@ defmodule Plausible.Plugins.API.Tokens do
     now = NaiveDateTime.truncate(now, :second)
     last_used = token.last_used_at
 
-    if is_nil(last_used) or Timex.diff(now, last_used, :minutes) > 5 do
+    if is_nil(last_used) or NaiveDateTime.diff(now, last_used, :minute) > 5 do
       token
       |> Ecto.Changeset.change(%{last_used_at: now})
       |> Repo.update()
diff --git a/lib/plausible/session/cache_store.ex b/lib/plausible/session/cache_store.ex
index 724e9a5d6d99..bd171d8a0955 100644
--- a/lib/plausible/session/cache_store.ex
+++ b/lib/plausible/session/cache_store.ex
@@ -62,7 +62,7 @@ defmodule Plausible.Session.CacheStore do
         nil
 
       session ->
-        if Timex.diff(event.timestamp, session.timestamp, :minutes) <= 30 do
+        if NaiveDateTime.diff(event.timestamp, session.timestamp, :minute) <= 30 do
           session
         end
     end
@@ -93,7 +93,7 @@ defmodule Plausible.Session.CacheStore do
         exit_page_hostname:
           if(event.name == "pageview", do: event.hostname, else: session.exit_page_hostname),
         is_bounce: false,
-        duration: Timex.diff(event.timestamp, session.start, :second) |> abs,
+        duration: NaiveDateTime.diff(event.timestamp, session.start) |> abs,
         pageviews:
           if(event.name == "pageview", do: session.pageviews + 1, else: session.pageviews),
         events: session.events + 1
@@ -116,6 +116,7 @@ defmodule Plausible.Session.CacheStore do
       events: 1,
       referrer: Map.get(session_attributes, :referrer),
       channel: Map.get(session_attributes, :channel),
+      click_id_param: Map.get(session_attributes, :click_id_param),
       referrer_source: Map.get(session_attributes, :referrer_source),
       utm_medium: Map.get(session_attributes, :utm_medium),
       utm_source: Map.get(session_attributes, :utm_source),
diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex
index 31613e7f4126..95cb5b29b468 100644
--- a/lib/plausible/site.ex
+++ b/lib/plausible/site.ex
@@ -3,6 +3,7 @@ defmodule Plausible.Site do
   Site schema
   """
   use Ecto.Schema
+  use Plausible
   import Ecto.Changeset
   alias Plausible.Auth.User
   alias Plausible.Site.GoogleAuth
@@ -83,9 +84,15 @@ defmodule Plausible.Site do
 
   def new(params), do: changeset(%__MODULE__{}, params)
 
-  @domain_unique_error """
-  This domain cannot be registered. Perhaps one of your colleagues registered it? If that's not the case, please contact support@plausible.io
-  """
+  on_ee do
+    @domain_unique_error """
+    This domain cannot be registered. Perhaps one of your colleagues registered it? If that's not the case, please contact support@plausible.io
+    """
+  else
+    @domain_unique_error """
+    This domain cannot be registered. Perhaps one of your colleagues registered it?
+    """
+  end
 
   def changeset(site, attrs \\ %{}) do
     site
diff --git a/lib/plausible/site/memberships/accept_invitation.ex b/lib/plausible/site/memberships/accept_invitation.ex
index bde5bb9dc833..f5c9d6b39bd6 100644
--- a/lib/plausible/site/memberships/accept_invitation.ex
+++ b/lib/plausible/site/memberships/accept_invitation.ex
@@ -75,22 +75,26 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
     site = Repo.preload(invitation.site, :owner)
 
     with :ok <- Invitations.ensure_can_take_ownership(site, user) do
-      Plausible.Teams.Invitations.accept_transfer_sync(invitation, user)
-
       site
       |> add_and_transfer_ownership(membership, user)
       |> Multi.delete(:invitation, invitation)
+      |> Multi.run(:sync_transfer, fn _repo, _context ->
+        Plausible.Teams.Invitations.accept_transfer_sync(invitation, user)
+        {:ok, nil}
+      end)
       |> finalize_invitation(invitation)
     end
   end
 
   defp do_accept_invitation(invitation, user) do
-    Plausible.Teams.Invitations.accept_invitation_sync(invitation, user)
-
     membership = get_or_create_membership(invitation, user)
 
     invitation
     |> add(membership, user)
+    |> Multi.run(:sync_invitation, fn _repo, _context ->
+      Plausible.Teams.Invitations.accept_invitation_sync(invitation, user)
+      {:ok, nil}
+    end)
     |> finalize_invitation(invitation)
   end
 
diff --git a/lib/plausible/site/memberships/reject_invitation.ex b/lib/plausible/site/memberships/reject_invitation.ex
index 40b1a1e2b8f1..98e9d4c820a0 100644
--- a/lib/plausible/site/memberships/reject_invitation.ex
+++ b/lib/plausible/site/memberships/reject_invitation.ex
@@ -4,13 +4,19 @@ defmodule Plausible.Site.Memberships.RejectInvitation do
   """
 
   alias Plausible.Auth
+  alias Plausible.Repo
   alias Plausible.Site.Memberships.Invitations
+  alias Plausible.Teams
 
   @spec reject_invitation(String.t(), Auth.User.t()) ::
           {:ok, Auth.Invitation.t()} | {:error, :invitation_not_found}
   def reject_invitation(invitation_id, user) do
     with {:ok, invitation} <- Invitations.find_for_user(invitation_id, user) do
-      Invitations.delete_invitation(invitation)
+      Repo.transaction(fn ->
+        Invitations.delete_invitation(invitation)
+        Teams.Invitations.remove_invitation_sync(invitation)
+      end)
+
       notify_invitation_rejected(invitation)
 
       {:ok, invitation}
diff --git a/lib/plausible/site/memberships/remove_invitation.ex b/lib/plausible/site/memberships/remove_invitation.ex
index abb650fe7da0..e4f1baa5b5ef 100644
--- a/lib/plausible/site/memberships/remove_invitation.ex
+++ b/lib/plausible/site/memberships/remove_invitation.ex
@@ -4,13 +4,18 @@ defmodule Plausible.Site.Memberships.RemoveInvitation do
   """
 
   alias Plausible.Auth
+  alias Plausible.Repo
   alias Plausible.Site.Memberships.Invitations
+  alias Plausible.Teams
 
   @spec remove_invitation(String.t(), Plausible.Site.t()) ::
           {:ok, Auth.Invitation.t()} | {:error, :invitation_not_found}
   def remove_invitation(invitation_id, site) do
     with {:ok, invitation} <- Invitations.find_for_site(invitation_id, site) do
-      Invitations.delete_invitation(invitation)
+      Repo.transaction(fn ->
+        Invitations.delete_invitation(invitation)
+        Teams.Invitations.remove_invitation_sync(invitation)
+      end)
 
       {:ok, invitation}
     end
diff --git a/lib/plausible/site/removal.ex b/lib/plausible/site/removal.ex
index af1fd6387f66..8fe878ab13de 100644
--- a/lib/plausible/site/removal.ex
+++ b/lib/plausible/site/removal.ex
@@ -6,9 +6,17 @@ defmodule Plausible.Site.Removal do
 
   import Ecto.Query
 
-  @spec run(String.t()) :: {:ok, map()}
-  def run(domain) do
-    result = Repo.delete_all(from(s in Plausible.Site, where: s.domain == ^domain))
-    {:ok, %{delete_all: result}}
+  @spec run(Plausible.Site.t()) :: {:ok, map()}
+  def run(site) do
+    Repo.transaction(fn ->
+      site = Plausible.Teams.load_for_site(site)
+
+      result = Repo.delete_all(from(s in Plausible.Site, where: s.domain == ^site.domain))
+
+      Plausible.Teams.Memberships.prune_guests(site.team)
+      Plausible.Teams.Invitations.prune_guest_invitations(site.team)
+
+      %{delete_all: result}
+    end)
   end
 end
diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex
index e61b70e688c1..aaf4ee5c69d9 100644
--- a/lib/plausible/sites.ex
+++ b/lib/plausible/sites.ex
@@ -71,8 +71,24 @@ defmodule Plausible.Sites do
     )
   end
 
-  @spec list(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
   def list(user, pagination_params, opts \\ []) do
+    if Plausible.Teams.read_team_schemas?(user) do
+      Plausible.Teams.Sites.list(user, pagination_params, opts)
+    else
+      old_list(user, pagination_params, opts)
+    end
+  end
+
+  def list_with_invitations(user, pagination_params, opts \\ []) do
+    if Plausible.Teams.read_team_schemas?(user) do
+      Plausible.Teams.Sites.list_with_invitations(user, pagination_params, opts)
+    else
+      old_list_with_invitations(user, pagination_params, opts)
+    end
+  end
+
+  @spec old_list(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
+  def old_list(user, pagination_params, opts \\ []) do
     domain_filter = Keyword.get(opts, :filter_by_domain)
 
     from(s in Site,
@@ -104,8 +120,8 @@ defmodule Plausible.Sites do
     |> Repo.paginate(pagination_params)
   end
 
-  @spec list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
-  def list_with_invitations(user, pagination_params, opts \\ []) do
+  @spec old_list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
+  def old_list_with_invitations(user, pagination_params, opts \\ []) do
     domain_filter = Keyword.get(opts, :filter_by_domain)
 
     result =
@@ -206,6 +222,10 @@ defmodule Plausible.Sites do
         Site.Membership.new(site, user)
       end)
       |> maybe_start_trial(user)
+      |> Ecto.Multi.run(:sync_team, fn _repo, %{user: user} ->
+        Plausible.Teams.sync_team(user)
+        {:ok, nil}
+      end)
       |> Repo.transaction()
     end
   end
@@ -218,7 +238,7 @@ defmodule Plausible.Sites do
         end)
 
       _ ->
-        multi
+        Ecto.Multi.put(multi, :user, user)
     end
   end
 
diff --git a/lib/plausible/stats/breakdown.ex b/lib/plausible/stats/breakdown.ex
index ac966fd7182f..32fc86be08a1 100644
--- a/lib/plausible/stats/breakdown.ex
+++ b/lib/plausible/stats/breakdown.ex
@@ -9,7 +9,7 @@ defmodule Plausible.Stats.Breakdown do
   use Plausible.ClickhouseRepo
   use Plausible.Stats.SQL.Fragments
 
-  alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer}
+  alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer, Comparisons}
 
   def breakdown(
         site,
@@ -43,6 +43,27 @@ defmodule Plausible.Stats.Breakdown do
     |> build_breakdown_result(query_with_metrics, metrics)
   end
 
+  def formatted_date_ranges(query) do
+    formatted = %{
+      date_range_label: format_date_range(query)
+    }
+
+    if query.include.comparisons do
+      comparison_date_range_label =
+        query
+        |> Comparisons.get_comparison_query(query.include.comparisons)
+        |> format_date_range()
+
+      Map.put(
+        formatted,
+        :comparison_date_range_label,
+        comparison_date_range_label
+      )
+    else
+      formatted
+    end
+  end
+
   defp build_breakdown_result(query_result, query, metrics) do
     dimension_keys = query.dimensions |> Enum.map(&result_key/1)
 
@@ -136,4 +157,28 @@ defmodule Plausible.Stats.Breakdown do
   end
 
   defp dimension_filters(_), do: []
+
+  defp format_date_range(%Query{} = query) do
+    year = query.now.year
+    %Date.Range{first: first, last: last} = Query.date_range(query, trim_trailing: true)
+
+    cond do
+      first == last ->
+        strfdate(first, first.year != year)
+
+      first.year == last.year ->
+        "#{strfdate(first, false)} - #{strfdate(last, year != last.year)}"
+
+      true ->
+        "#{strfdate(first, true)} - #{strfdate(last, true)}"
+    end
+  end
+
+  defp strfdate(date, true = _include_year) do
+    Calendar.strftime(date, "%-d %b %Y")
+  end
+
+  defp strfdate(date, false = _include_year) do
+    Calendar.strftime(date, "%-d %b")
+  end
 end
diff --git a/lib/plausible/stats/comparisons.ex b/lib/plausible/stats/comparisons.ex
index 4dc7d4d1a64f..647658855bfd 100644
--- a/lib/plausible/stats/comparisons.ex
+++ b/lib/plausible/stats/comparisons.ex
@@ -97,19 +97,19 @@ defmodule Plausible.Stats.Comparisons do
   end
 
   defp get_comparison_date_range(source_query, %{mode: "year_over_year"} = options) do
-    source_date_range = Query.date_range(source_query)
+    source_date_range = Query.date_range(source_query, trim_trailing: true)
 
     start_date = Date.add(source_date_range.first, -365)
-    end_date = earliest(source_date_range.last, source_query.now) |> Date.add(-365)
+    end_date = source_date_range.last |> Date.add(-365)
 
     Date.range(start_date, end_date)
     |> maybe_match_day_of_week(source_date_range, options)
   end
 
   defp get_comparison_date_range(source_query, %{mode: "previous_period"} = options) do
-    source_date_range = Query.date_range(source_query)
+    source_date_range = Query.date_range(source_query, trim_trailing: true)
 
-    last = earliest(source_date_range.last, source_query.now)
+    last = source_date_range.last
     diff_in_days = Date.diff(source_date_range.first, last) - 1
 
     new_first = Date.add(source_date_range.first, diff_in_days)
@@ -123,10 +123,6 @@ defmodule Plausible.Stats.Comparisons do
     DateTimeRange.to_date_range(options.date_range, source_query.timezone)
   end
 
-  defp earliest(a, b) do
-    if Date.compare(a, b) in [:eq, :lt], do: a, else: b
-  end
-
   defp maybe_match_day_of_week(comparison_date_range, source_date_range, options) do
     if options[:match_day_of_week] do
       day_to_match = Date.day_of_week(source_date_range.first)
diff --git a/lib/plausible/stats/filter_suggestions.ex b/lib/plausible/stats/filter_suggestions.ex
index 2a516213a6c9..c20bb6c19c99 100644
--- a/lib/plausible/stats/filter_suggestions.ex
+++ b/lib/plausible/stats/filter_suggestions.ex
@@ -8,7 +8,6 @@ defmodule Plausible.Stats.FilterSuggestions do
 
   alias Plausible.Stats.Query
   alias Plausible.Stats.Imported
-  alias Plausible.Stats.Filters
 
   def filter_suggestions(site, query, "country", filter_search) do
     matches = Location.search_country(filter_search)
@@ -186,42 +185,6 @@ defmodule Plausible.Stats.FilterSuggestions do
     |> wrap_suggestions()
   end
 
-  # Deprecated (buggy) and will be replaced with a new endpoint (see
-  # custom_prop_value_filter_suggestion/4). Will only be kept around
-  # for some time until the dashboards get updated to query the new
-  # endpoint. Tests have already been adjusted to the new endpoint.
-  def filter_suggestions(site, query, "prop_value", filter_search) do
-    filter_query = if filter_search == nil, do: "%", else: "%#{filter_search}%"
-
-    [_op, "event:props:" <> key | _rest] = Filters.get_toplevel_filter(query, "event:props")
-
-    none_q =
-      from(e in base_event_query(site, Query.remove_top_level_filters(query, ["event:props"])),
-        select: "(none)",
-        where: not has_key(e, :meta, ^key),
-        limit: 1
-      )
-
-    search_q =
-      from(e in base_event_query(site, query),
-        select: get_by_key(e, :meta, ^key),
-        where:
-          has_key(e, :meta, ^key) and
-            fragment(
-              "? ilike ?",
-              get_by_key(e, :meta, ^key),
-              ^filter_query
-            ),
-        group_by: get_by_key(e, :meta, ^key),
-        order_by: [desc: fragment("count(*)")],
-        limit: 25
-      )
-
-    ClickhouseRepo.all(none_q)
-    |> Kernel.++(ClickhouseRepo.all(search_q))
-    |> wrap_suggestions()
-  end
-
   def filter_suggestions(site, query, filter_name, filter_search) do
     filter_search = if filter_search == nil, do: "", else: filter_search
 
diff --git a/lib/plausible/stats/interval.ex b/lib/plausible/stats/interval.ex
index 8f053f1e1c83..fe125619cdb6 100644
--- a/lib/plausible/stats/interval.ex
+++ b/lib/plausible/stats/interval.ex
@@ -45,7 +45,7 @@ defmodule Plausible.Stats.Interval do
       Timex.diff(last, first, :months) > 0 ->
         "month"
 
-      Timex.diff(last, first, :days) > 0 ->
+      DateTime.diff(last, first, :day) > 0 ->
         "day"
 
       true ->
diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex
index 1d6694512246..c7219a151b23 100644
--- a/lib/plausible/stats/legacy/legacy_query_builder.ex
+++ b/lib/plausible/stats/legacy/legacy_query_builder.ex
@@ -27,7 +27,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
       |> Query.put_imported_opts(site, params)
 
     on_ee do
-      query = Plausible.Stats.Sampling.put_threshold(query, params)
+      query = Plausible.Stats.Sampling.put_threshold(query, site, params)
     end
 
     query
diff --git a/lib/plausible/stats/query.ex b/lib/plausible/stats/query.ex
index 6a9b5a8baf60..b530517d0435 100644
--- a/lib/plausible/stats/query.ex
+++ b/lib/plausible/stats/query.ex
@@ -38,6 +38,10 @@ defmodule Plausible.Stats.Query do
         |> put_experimental_reduced_joins(site, params)
         |> struct!(v2: true, now: DateTime.utc_now(:second), debug_metadata: debug_metadata)
 
+      on_ee do
+        query = Plausible.Stats.Sampling.put_threshold(query, site, params)
+      end
+
       {:ok, query}
     end
   end
@@ -61,8 +65,21 @@ defmodule Plausible.Stats.Query do
     end
   end
 
-  def date_range(query) do
-    Plausible.Stats.DateTimeRange.to_date_range(query.utc_time_range, query.timezone)
+  def date_range(query, options \\ []) do
+    date_range = Plausible.Stats.DateTimeRange.to_date_range(query.utc_time_range, query.timezone)
+
+    if Keyword.get(options, :trim_trailing) do
+      Date.range(
+        date_range.first,
+        earliest(date_range.last, query.now)
+      )
+    else
+      date_range
+    end
+  end
+
+  defp earliest(a, b) do
+    if Date.compare(a, b) in [:eq, :lt], do: a, else: b
   end
 
   def set(query, keywords) do
diff --git a/lib/plausible/stats/query_optimizer.ex b/lib/plausible/stats/query_optimizer.ex
index 5f0a89a22eb3..0f8abf70b884 100644
--- a/lib/plausible/stats/query_optimizer.ex
+++ b/lib/plausible/stats/query_optimizer.ex
@@ -86,8 +86,8 @@ defmodule Plausible.Stats.QueryOptimizer do
 
   defp resolve_time_dimension(first, last) do
     cond do
-      Timex.diff(last, first, :hours) <= 48 -> "time:hour"
-      Timex.diff(last, first, :days) <= 40 -> "time:day"
+      DateTime.diff(last, first, :hour) <= 48 -> "time:hour"
+      DateTime.diff(last, first, :day) <= 40 -> "time:day"
       Timex.diff(last, first, :weeks) <= 52 -> "time:week"
       true -> "time:month"
     end
diff --git a/lib/plausible/teams.ex b/lib/plausible/teams.ex
index 253fc6e721ca..1b227d1670ba 100644
--- a/lib/plausible/teams.ex
+++ b/lib/plausible/teams.ex
@@ -8,6 +8,10 @@ defmodule Plausible.Teams do
   alias __MODULE__
   alias Plausible.Repo
 
+  def read_team_schemas?(user) do
+    FunWithFlags.enabled?(:read_team_schemas, for: user)
+  end
+
   def with_subscription(team) do
     Repo.preload(team, subscription: last_subscription_query())
   end
diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex
index 8e5eaf7c3851..85ab4660d885 100644
--- a/lib/plausible/teams/invitations.ex
+++ b/lib/plausible/teams/invitations.ex
@@ -44,19 +44,57 @@ defmodule Plausible.Teams.Invitations do
     role = translate_role(site_invitation.role)
 
     if site_invitation.role == :owner do
-      create_site_transfer(
-        site,
-        site_invitation.inviter,
-        site_invitation.email
+      {:ok, site_transfer} =
+        create_site_transfer(
+          site,
+          site_invitation.inviter,
+          site_invitation.email
+        )
+
+      site_transfer
+      |> Ecto.Changeset.change(transfer_id: site_invitation.invitation_id)
+      |> Repo.update!()
+    else
+      {:ok, guest_invitation} =
+        create_invitation(
+          site,
+          site_invitation.email,
+          role,
+          site_invitation.inviter
+        )
+
+      guest_invitation.team_invitation
+      |> Ecto.Changeset.change(invitation_id: site_invitation.invitation_id)
+      |> Repo.update!()
+    end
+  end
+
+  def remove_invitation_sync(site_invitation) do
+    site = Repo.preload(site_invitation, :site).site
+    site = Teams.load_for_site(site)
+
+    if site_invitation.role == :owner do
+      Repo.delete_all(
+        from(
+          st in Teams.SiteTransfer,
+          where: st.email == ^site_invitation.email,
+          where: st.team_id == ^site.team.id
+        )
       )
     else
-      create_invitation(
-        site,
-        site_invitation.email,
-        role,
-        site_invitation.inviter
+      Repo.delete_all(
+        from(
+          gi in Teams.GuestInvitation,
+          inner_join: ti in assoc(gi, :team_invitation),
+          where: ti.email == ^site_invitation.email,
+          where: gi.site_id == ^site.id
+        )
       )
+
+      prune_guest_invitations(site.team)
     end
+
+    :ok
   end
 
   def transfer_site(site, new_owner, now \\ NaiveDateTime.utc_now(:second)) do
@@ -141,10 +179,21 @@ defmodule Plausible.Teams.Invitations do
 
     team_invitation =
       guest_invitation.team_invitation
-      |> Repo.preload([:team, :inviter, guest_invitations: :site])
+      |> Repo.preload([
+        :team,
+        :inviter,
+        guest_invitations: :site
+      ])
 
     {:ok, _} =
-      do_accept(team_invitation, user, NaiveDateTime.utc_now(:second), send_email?: false)
+      result =
+      do_accept(team_invitation, user, NaiveDateTime.utc_now(:second),
+        send_email?: false,
+        guest_invitations: [guest_invitation]
+      )
+
+    prune_guest_invitations(team_invitation.team)
+    result
   end
 
   def accept_transfer_sync(site_invitation, user) do
@@ -195,7 +244,8 @@ defmodule Plausible.Teams.Invitations do
     |> Teams.SiteTransfer.changeset(initiator: initiator, email: invitee_email)
     |> Repo.insert(
       on_conflict: [set: [updated_at: now]],
-      conflict_target: [:email, :site_id]
+      conflict_target: [:email, :site_id],
+      returning: true
     )
   end
 
@@ -214,7 +264,7 @@ defmodule Plausible.Teams.Invitations do
 
   defp do_accept(team_invitation, user, now, opts \\ []) do
     send_email? = Keyword.get(opts, :send_email?, true)
-    guest_invitations = team_invitation.guest_invitations
+    guest_invitations = Keyword.get(opts, :guest_invitations, team_invitation.guest_invitations)
 
     Repo.transaction(fn ->
       with {:ok, team_membership} <-
@@ -301,7 +351,8 @@ defmodule Plausible.Teams.Invitations do
             |> Teams.GuestMembership.changeset(site, old_guest_membership.role)
             |> Repo.insert(
               on_conflict: [set: [updated_at: now, role: old_guest_membership.role]],
-              conflict_target: [:team_membership_id, :site_id]
+              conflict_target: [:team_membership_id, :site_id],
+              returning: true
             )
         end
 
@@ -322,7 +373,8 @@ defmodule Plausible.Teams.Invitations do
         |> Teams.GuestMembership.changeset(site, :editor)
         |> Repo.insert(
           on_conflict: [set: [updated_at: now, role: :editor]],
-          conflict_target: [:team_membership_id, :site_id]
+          conflict_target: [:team_membership_id, :site_id],
+          returning: true
         )
     end
 
@@ -460,7 +512,11 @@ defmodule Plausible.Teams.Invitations do
 
     team
     |> Teams.Invitation.changeset(email: invitee_email, role: :guest, inviter: inviter)
-    |> Repo.insert(on_conflict: [set: [updated_at: now]], conflict_target: [:team_id, :email])
+    |> Repo.insert(
+      on_conflict: [set: [updated_at: now]],
+      conflict_target: [:team_id, :email],
+      returning: true
+    )
   end
 
   defp create_guest_invitation(team_invitation, site, role) do
@@ -470,7 +526,8 @@ defmodule Plausible.Teams.Invitations do
     |> Teams.GuestInvitation.changeset(site, role)
     |> Repo.insert(
       on_conflict: [set: [updated_at: now]],
-      conflict_target: [:team_invitation_id, :site_id]
+      conflict_target: [:team_invitation_id, :site_id],
+      returning: true
     )
   end
 
@@ -501,7 +558,8 @@ defmodule Plausible.Teams.Invitations do
     |> Teams.Membership.changeset(user, role)
     |> Repo.insert(
       on_conflict: [set: [updated_at: now]],
-      conflict_target: [:team_id, :user_id]
+      conflict_target: [:team_id, :user_id],
+      returning: true
     )
   end
 
diff --git a/lib/plausible/teams/sites.ex b/lib/plausible/teams/sites.ex
index 173066ce013d..84c63757a41a 100644
--- a/lib/plausible/teams/sites.ex
+++ b/lib/plausible/teams/sites.ex
@@ -89,8 +89,9 @@ defmodule Plausible.Teams.Sites do
     from(u in subquery(union_query),
       inner_join: s in Plausible.Site,
       on: u.site_id == s.id,
+      as: :site,
       left_join: up in Site.UserPreference,
-      on: up.site_id == s.id,
+      on: up.site_id == s.id and up.user_id == ^user.id,
       select: %{
         s
         | entry_type:
@@ -119,6 +120,8 @@ defmodule Plausible.Teams.Sites do
     |> Repo.paginate(pagination_params)
   end
 
+  @role_type Plausible.Auth.Invitation.__schema__(:type, :role)
+
   @spec list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
   def list_with_invitations(user, pagination_params, opts \\ []) do
     domain_filter = Keyword.get(opts, :filter_by_domain)
@@ -128,40 +131,112 @@ defmodule Plausible.Teams.Sites do
         inner_join: t in assoc(tm, :team),
         inner_join: s in assoc(t, :sites),
         where: tm.user_id == ^user.id and tm.role != :guest,
-        select: %{site_id: s.id, entry_type: "site", invitation_id: 0, invitation_role: ""}
+        select: %{
+          site_id: s.id,
+          entry_type: "site",
+          invitation_id: 0,
+          role: tm.role,
+          transfer_id: 0
+        }
 
     guest_membership_query =
       from(tm in Teams.Membership,
         inner_join: gm in assoc(tm, :guest_memberships),
         inner_join: s in assoc(gm, :site),
         where: tm.user_id == ^user.id and tm.role == :guest,
-        select: %{site_id: s.id, entry_type: "site", invitation_id: 0, invitation_role: ""}
+        select: %{
+          site_id: s.id,
+          entry_type: "site",
+          invitation_id: 0,
+          role:
+            fragment(
+              """
+              CASE
+                WHEN ? = 'editor' THEN 'admin'
+                ELSE ?
+              END
+              """,
+              gm.role,
+              gm.role
+            ),
+          transfer_id: 0
+        }
       )
 
     guest_invitation_query =
       from ti in Teams.Invitation,
+        as: :team_invitation,
         inner_join: gi in assoc(ti, :guest_invitations),
         inner_join: s in assoc(gi, :site),
+        as: :site,
+        where:
+          not exists(
+            from tm in Teams.Membership,
+              inner_join: u in assoc(tm, :user),
+              left_join: gm in assoc(tm, :guest_memberships),
+              on: gm.site_id == parent_as(:site).id,
+              where: tm.team_id == parent_as(:team_invitation).team_id,
+              where: u.email == parent_as(:team_invitation).email,
+              where: not is_nil(gm.id) or tm.role != :guest,
+              select: 1
+          ),
         where: ti.email == ^user.email and ti.role == :guest,
         select: %{
           site_id: s.id,
           entry_type: "invitation",
           invitation_id: ti.id,
-          invitation_role: gi.role
+          role:
+            fragment(
+              """
+              CASE
+                WHEN ? = 'editor' THEN 'admin'
+                ELSE ?
+              END
+              """,
+              gi.role,
+              gi.role
+            ),
+          transfer_id: 0
+        }
+
+    site_transfer_query =
+      from st in Teams.SiteTransfer,
+        as: :site_transfer,
+        inner_join: s in assoc(st, :site),
+        as: :site,
+        where: st.email == ^user.email,
+        where:
+          not exists(
+            from tm in Teams.Membership,
+              inner_join: u in assoc(tm, :user),
+              where: tm.team_id == parent_as(:site).team_id,
+              where: u.email == parent_as(:site_transfer).email,
+              select: 1
+          ),
+        select: %{
+          site_id: s.id,
+          entry_type: "invitation",
+          invitation_id: 0,
+          role: "owner",
+          transfer_id: st.id
         }
 
     union_query =
       from s in team_membership_query,
         union_all: ^guest_membership_query,
-        union_all: ^guest_invitation_query
+        union_all: ^guest_invitation_query,
+        union_all: ^site_transfer_query
 
     from(u in subquery(union_query),
       inner_join: s in Plausible.Site,
       on: u.site_id == s.id,
+      as: :site,
       left_join: up in Site.UserPreference,
-      on: up.site_id == s.id,
+      on: up.site_id == s.id and up.user_id == ^user.id,
       left_join: ti in Teams.Invitation,
       on: ti.id == u.invitation_id,
+      left_join: st in Teams.SiteTransfer,
+      on: st.id == u.transfer_id,
       select: %{
         s
         | entry_type:
@@ -179,11 +254,20 @@ defmodule Plausible.Teams.Sites do
               :entry_type
             ),
           pinned_at: selected_as(up.pinned_at, :pinned_at),
+          memberships: [
+            %Plausible.Site.Membership{
+              role: type(u.role, ^@role_type),
+              site_id: s.id,
+              site: s
+            }
+          ],
           invitations: [
             %Plausible.Auth.Invitation{
-              invitation_id: ti.invitation_id,
-              email: ti.email,
-              role: u.invitation_role
+              invitation_id: coalesce(ti.invitation_id, st.transfer_id),
+              email: coalesce(ti.email, st.email),
+              role: type(u.role, ^@role_type),
+              site_id: s.id,
+              site: s
             }
           ]
       },
@@ -195,11 +279,20 @@ defmodule Plausible.Teams.Sites do
     )
     |> maybe_filter_by_domain(domain_filter)
     |> Repo.paginate(pagination_params)
+    |> Map.update!(:entries, fn entries ->
+      Enum.map(entries, fn
+        %{invitation: [%{invitation_id: nil}]} = entry ->
+          %{entry | invitations: []}
+
+        entry ->
+          entry
+      end)
+    end)
   end
 
   defp maybe_filter_by_domain(query, domain)
        when byte_size(domain) >= 1 and byte_size(domain) <= 64 do
-    where(query, [s], ilike(s.domain, ^"%#{domain}%"))
+    where(query, [site: s], ilike(s.domain, ^"%#{domain}%"))
   end
 
   defp maybe_filter_by_domain(query, _), do: query
diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex
index e1fe3a056d05..357e268eeb3c 100644
--- a/lib/plausible/teams/team.ex
+++ b/lib/plausible/teams/team.ex
@@ -35,7 +35,7 @@ defmodule Plausible.Teams.Team do
     |> put_change(:trial_expiry_date, user.trial_expiry_date)
     |> put_change(:accept_traffic_until, user.accept_traffic_until)
     |> put_change(:allow_next_upgrade_override, user.allow_next_upgrade_override)
-    |> put_embed(:grace_period, user.grace_period)
+    |> put_embed(:grace_period, embed_params(user.grace_period))
     |> put_change(:inserted_at, user.inserted_at)
     |> put_change(:updated_at, user.updated_at)
   end
@@ -63,6 +63,12 @@ defmodule Plausible.Teams.Team do
     )
   end
 
+  defp embed_params(nil), do: nil
+
+  defp embed_params(grace_period) do
+    Map.from_struct(grace_period)
+  end
+
   defp trial_expiry() do
     on_ee do
       Date.utc_today() |> Date.shift(day: 30)
diff --git a/lib/plausible/users.ex b/lib/plausible/users.ex
index 33020ff85b30..ec9d99be57c4 100644
--- a/lib/plausible/users.ex
+++ b/lib/plausible/users.ex
@@ -26,7 +26,7 @@ defmodule Plausible.Users do
 
   @spec trial_days_left(Auth.User.t()) :: integer()
   def trial_days_left(user) do
-    Timex.diff(user.trial_expiry_date, Date.utc_today(), :days)
+    Date.diff(user.trial_expiry_date, Date.utc_today())
   end
 
   @spec update_accept_traffic_until(Auth.User.t()) :: Auth.User.t()
diff --git a/lib/plausible_web/components/billing/notice.ex b/lib/plausible_web/components/billing/notice.ex
index 409d4b4bcf21..eafd5b28e4a3 100644
--- a/lib/plausible_web/components/billing/notice.ex
+++ b/lib/plausible_web/components/billing/notice.ex
@@ -302,17 +302,24 @@ defmodule PlausibleWeb.Components.Billing.Notice do
   defp upgrade_call_to_action(assigns) do
     billable_user = Plausible.Users.with_subscription(assigns.billable_user)
 
-    plan =
-      Plans.get_regular_plan(billable_user.subscription, only_non_expired: true)
-
-    trial? = Plausible.Users.on_trial?(assigns.billable_user)
-    growth? = plan && plan.kind == :growth
+    upgrade_assistance_required? =
+      case Plans.get_subscription_plan(billable_user.subscription) do
+        %Plausible.Billing.Plan{kind: :business} -> true
+        %Plausible.Billing.EnterprisePlan{} -> true
+        _ -> false
+      end
 
     cond do
       assigns.billable_user.id !== assigns.current_user.id ->
         ~H"please reach out to the site owner to upgrade their subscription"
 
-      growth? || trial? ->
+      upgrade_assistance_required? ->
+        ~H"""
+        please contact <a href="mailto:hello@plausible.io" class="underline">hello@plausible.io</a>
+        to upgrade your subscription
+        """
+
+      true ->
         ~H"""
         please
         <.link
@@ -322,12 +329,6 @@ defmodule PlausibleWeb.Components.Billing.Notice do
           upgrade your subscription
         </.link>
         """
-
-      true ->
-        ~H"""
-        please contact <a href="mailto:hello@plausible.io" class="underline">hello@plausible.io</a>
-        to upgrade your subscription
-        """
     end
   end
 
diff --git a/lib/plausible_web/components/billing/plan_box.ex b/lib/plausible_web/components/billing/plan_box.ex
index d853e1e65a77..e1a90414ac3c 100644
--- a/lib/plausible_web/components/billing/plan_box.ex
+++ b/lib/plausible_web/components/billing/plan_box.ex
@@ -274,7 +274,7 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do
 
     trial_active_or_ended_recently? =
       not invited_user? &&
-        Timex.diff(Date.utc_today(), current_user.trial_expiry_date, :days) <= 10
+        Date.diff(Date.utc_today(), current_user.trial_expiry_date) <= 10
 
     limit_checking_opts =
       cond do
diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex
index 5f80f7fe67a6..b3219e730681 100644
--- a/lib/plausible_web/controllers/api/stats_controller.ex
+++ b/lib/plausible_web/controllers/api/stats_controller.ex
@@ -474,6 +474,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: res,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -496,6 +497,7 @@ defmodule PlausibleWeb.Api.StatsController do
 
     json(conn, %{
       results: res,
+      meta: Stats.Breakdown.formatted_date_ranges(query),
       skip_imported_reason: query.skip_imported_reason
     })
   end
@@ -576,6 +578,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: res,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -603,6 +606,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: res,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -630,6 +634,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: res,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -657,6 +662,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: res,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -684,6 +690,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: res,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -711,6 +718,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: res,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -794,6 +802,7 @@ defmodule PlausibleWeb.Api.StatsController do
 
     json(conn, %{
       results: referrers,
+      meta: Stats.Breakdown.formatted_date_ranges(query),
       skip_imported_reason: query.skip_imported_reason
     })
   end
@@ -826,6 +835,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: pages,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -860,6 +870,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: entry_pages,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -895,6 +906,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: exit_pages,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -981,6 +993,7 @@ defmodule PlausibleWeb.Api.StatsController do
 
       json(conn, %{
         results: countries,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1019,6 +1032,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: regions,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1062,6 +1076,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: cities,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1093,6 +1108,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: browsers,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1133,6 +1149,7 @@ defmodule PlausibleWeb.Api.StatsController do
 
       json(conn, %{
         results: results,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1164,6 +1181,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: systems,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1204,6 +1222,7 @@ defmodule PlausibleWeb.Api.StatsController do
 
       json(conn, %{
         results: results,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1235,6 +1254,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: sizes,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1267,6 +1287,7 @@ defmodule PlausibleWeb.Api.StatsController do
     else
       json(conn, %{
         results: conversions,
+        meta: Stats.Breakdown.formatted_date_ranges(query),
         skip_imported_reason: query.skip_imported_reason
       })
     end
@@ -1340,7 +1361,11 @@ defmodule PlausibleWeb.Api.StatsController do
       Stats.breakdown(site, query, metrics, pagination)
       |> transform_keys(%{prop_key => :name})
 
-    %{results: props, skip_imported_reason: query.skip_imported_reason}
+    %{
+      results: props,
+      meta: Stats.Breakdown.formatted_date_ranges(query),
+      skip_imported_reason: query.skip_imported_reason
+    }
   end
 
   def current_visitors(conn, _) do
diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex
index cdfdcf612c8c..970d8983a6dc 100644
--- a/lib/plausible_web/controllers/site_controller.ex
+++ b/lib/plausible_web/controllers/site_controller.ex
@@ -325,7 +325,7 @@ defmodule PlausibleWeb.SiteController do
   def delete_site(conn, _params) do
     site = conn.assigns[:site]
 
-    Plausible.Site.Removal.run(site.domain)
+    Plausible.Site.Removal.run(site)
 
     conn
     |> put_flash(:success, "Your site and page views deletion process has started.")
diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex
index 4acacf455ef0..134976b69a67 100644
--- a/lib/plausible_web/controllers/stats_controller.ex
+++ b/lib/plausible_web/controllers/stats_controller.ex
@@ -365,13 +365,12 @@ defmodule PlausibleWeb.StatsController do
   defp shared_link_cookie_name(slug), do: "shared-link-" <> slug
 
   defp get_flags(user, site),
-    do: %{
-      channels:
-        FunWithFlags.enabled?(:channels, for: user) || FunWithFlags.enabled?(:channels, for: site),
-      breakdown_comparisons_ui:
-        FunWithFlags.enabled?(:breakdown_comparisons_ui, for: user) ||
-          FunWithFlags.enabled?(:breakdown_comparisons_ui, for: site)
-    }
+    do:
+      [:channels, :saved_segments]
+      |> Enum.map(fn flag ->
+        {flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)}
+      end)
+      |> Map.new()
 
   defp is_dbip() do
     on_ee do
diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex
index cf63a5409714..93417f14f356 100644
--- a/lib/plausible_web/live/sites.ex
+++ b/lib/plausible_web/live/sites.ex
@@ -35,7 +35,7 @@ defmodule PlausibleWeb.Live.Sites do
       |> assign_new(:needs_to_upgrade, fn %{current_user: current_user, sites: sites} ->
         user_owns_sites =
           Enum.any?(sites.entries, fn site ->
-            List.first(site.memberships ++ site.invitations).role == :owner
+            length(site.invitations) > 0 && List.first(site.invitations).role == :owner
           end) ||
             Auth.user_owns_sites?(current_user)
 
@@ -325,12 +325,40 @@ defmodule PlausibleWeb.Live.Sites do
 
   attr :change, :integer, required: true
 
+  # Related React component: <ChangeArrow />
   def percentage_change(assigns) do
     ~H"""
     <p class="dark:text-gray-100">
       <span :if={@change == 0} class="font-semibold">〰</span>
-      <span :if={@change > 0} class="font-semibold text-green-500">↑</span>
-      <span :if={@change < 0} class="font-semibold text-red-400">↓</span>
+      <svg
+        :if={@change > 0}
+        xmlns="http://www.w3.org/2000/svg"
+        fill="currentColor"
+        viewBox="0 0 24 24"
+        class="text-green-500 h-3 w-3 inline-block stroke-[1px] stroke-current"
+      >
+        <path
+          fill-rule="evenodd"
+          d="M8.25 3.75H19.5a.75.75 0 01.75.75v11.25a.75.75 0 01-1.5 0V6.31L5.03 20.03a.75.75 0 01-1.06-1.06L17.69 5.25H8.25a.75.75 0 010-1.5z"
+          clip-rule="evenodd"
+        >
+        </path>
+      </svg>
+      <svg
+        :if={@change < 0}
+        xmlns="http://www.w3.org/2000/svg"
+        fill="currentColor"
+        viewBox="0 0 24 24"
+        class="text-red-400 h-3 w-3 inline-block stroke-[1px] stroke-current"
+      >
+        <path
+          fill-rule="evenodd"
+          d="M3.97 3.97a.75.75 0 011.06 0l13.72 13.72V8.25a.75.75 0 011.5 0V19.5a.75.75 0 01-.75.75H8.25a.75.75 0 010-1.5h9.44L3.97 5.03a.75.75 0 010-1.06z"
+          clip-rule="evenodd"
+        >
+        </path>
+      </svg>
+
       <%= abs(@change) %>%
     </p>
     """
diff --git a/lib/plausible_web/plugs/favicon.ex b/lib/plausible_web/plugs/favicon.ex
index e8dc9a5e2044..da676dff0a62 100644
--- a/lib/plausible_web/plugs/favicon.ex
+++ b/lib/plausible_web/plugs/favicon.ex
@@ -31,11 +31,20 @@ defmodule PlausibleWeb.Favicon do
 
   @placeholder_icon_location "priv/placeholder_favicon.ico"
   @placeholder_icon File.read!(@placeholder_icon_location)
+  @custom_icons %{
+    "Brave" => "search.brave.com",
+    "Sogou" => "sogou.com",
+    "Wikipedia" => "en.wikipedia.org",
+    "Discord" => "discord.com",
+    "Perplexity" => "perplexity.ai",
+    "Microsoft Teams" => "microsoft.com"
+  }
 
   def init(_) do
     domains =
       File.read!(Application.app_dir(:plausible, @referer_domains_file))
       |> Jason.decode!()
+      |> Map.merge(@custom_icons)
 
     [favicon_domains: domains]
   end
diff --git a/lib/plausible_web/refinspector.ex b/lib/plausible_web/refinspector.ex
deleted file mode 100644
index 0bfbcb456be3..000000000000
--- a/lib/plausible_web/refinspector.ex
+++ /dev/null
@@ -1,37 +0,0 @@
-defmodule PlausibleWeb.RefInspector do
-  def parse(nil), do: nil
-
-  def parse(ref) do
-    case ref.source do
-      :unknown ->
-        uri = URI.parse(String.trim(ref.referer))
-
-        if right_uri?(uri) do
-          format_referrer_host(uri)
-        end
-
-      source ->
-        source
-    end
-  end
-
-  def format_referrer(uri) do
-    path = String.trim_trailing(uri.path || "", "/")
-    format_referrer_host(uri) <> path
-  end
-
-  def right_uri?(%URI{host: nil}), do: false
-
-  def right_uri?(%URI{host: host, scheme: scheme})
-      when scheme in ["http", "https", "android-app"] and byte_size(host) > 0,
-      do: true
-
-  def right_uri?(_), do: false
-
-  defp format_referrer_host(uri) do
-    protocol = if uri.scheme == "android-app", do: "android-app://", else: ""
-    host = String.replace_prefix(uri.host, "www.", "")
-
-    protocol <> host
-  end
-end
diff --git a/lib/plausible_web/templates/site/settings_funnels.html.heex b/lib/plausible_web/templates/site/settings_funnels.html.heex
index d583c341a4fc..e668b3e21c9d 100644
--- a/lib/plausible_web/templates/site/settings_funnels.html.heex
+++ b/lib/plausible_web/templates/site/settings_funnels.html.heex
@@ -12,12 +12,13 @@
       Compose Goals into Funnels
     </:subtitle>
 
+    <PlausibleWeb.Components.Billing.Notice.premium_feature
+      billable_user={@site.owner}
+      current_user={@current_user}
+      feature_mod={Plausible.Billing.Feature.Funnels}
+    />
+
     <div :if={Plausible.Billing.Feature.Funnels.enabled?(@site)}>
-      <PlausibleWeb.Components.Billing.Notice.premium_feature
-        billable_user={@site.owner}
-        current_user={@current_user}
-        feature_mod={Plausible.Billing.Feature.Funnels}
-      />
       <%= live_render(@conn, PlausibleWeb.Live.FunnelSettings,
         session: %{"site_id" => @site.id, "domain" => @site.domain}
       ) %>
diff --git a/lib/plausible_web/templates/site/settings_props.html.heex b/lib/plausible_web/templates/site/settings_props.html.heex
index 35ffc1a21107..fe39e4cc62df 100644
--- a/lib/plausible_web/templates/site/settings_props.html.heex
+++ b/lib/plausible_web/templates/site/settings_props.html.heex
@@ -13,13 +13,14 @@
       create custom metrics.
     </:subtitle>
 
+    <PlausibleWeb.Components.Billing.Notice.premium_feature
+      billable_user={@site.owner}
+      current_user={@current_user}
+      feature_mod={Plausible.Billing.Feature.Props}
+      grandfathered?
+    />
+
     <div :if={Plausible.Billing.Feature.Props.enabled?(@site)}>
-      <PlausibleWeb.Components.Billing.Notice.premium_feature
-        billable_user={@site.owner}
-        current_user={@current_user}
-        feature_mod={Plausible.Billing.Feature.Props}
-        grandfathered?
-      />
       <%= live_render(@conn, PlausibleWeb.Live.PropsSettings,
         id: "props-form",
         session: %{"site_id" => @site.id, "domain" => @site.domain}
diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex
index 188feeca39db..1842117342f0 100644
--- a/lib/plausible_web/views/layout_view.ex
+++ b/lib/plausible_web/views/layout_view.ex
@@ -107,7 +107,7 @@ defmodule PlausibleWeb.LayoutView do
   end
 
   def grace_period_end(%{grace_period: %{end_date: %Date{} = date}}) do
-    case Timex.diff(date, Date.utc_today(), :days) do
+    case Date.diff(date, Date.utc_today()) do
       0 -> "today"
       1 -> "tomorrow"
       n -> "within #{n} days"
diff --git a/lib/workers/clean_invitations.ex b/lib/workers/clean_invitations.ex
index c822e93a591c..a494a06e19a3 100644
--- a/lib/workers/clean_invitations.ex
+++ b/lib/workers/clean_invitations.ex
@@ -20,6 +20,11 @@ defmodule Plausible.Workers.CleanInvitations do
         where: ti.inserted_at < ^cutoff_time
     )
 
+    Repo.delete_all(
+      from ti in Plausible.Teams.SiteTransfer,
+        where: ti.inserted_at < ^cutoff_time
+    )
+
     :ok
   end
 end
diff --git a/lib/workers/send_site_setup_emails.ex b/lib/workers/send_site_setup_emails.ex
index 0180fbc27e51..b6c8804bec00 100644
--- a/lib/workers/send_site_setup_emails.ex
+++ b/lib/workers/send_site_setup_emails.ex
@@ -44,7 +44,7 @@ defmodule Plausible.Workers.SendSiteSetupEmails do
     for site <- Repo.all(q) do
       owner = Plausible.Users.with_subscription(site.owner)
       setup_completed = Plausible.Sites.has_stats?(site)
-      hours_passed = Timex.diff(DateTime.utc_now(), site.inserted_at, :hours)
+      hours_passed = NaiveDateTime.diff(DateTime.utc_now(), site.inserted_at, :hour)
 
       if !setup_completed && hours_passed > 47 do
         send_setup_help_email(owner, site)
diff --git a/lib/workers/send_trial_notifications.ex b/lib/workers/send_trial_notifications.ex
index 7386697663c9..a702c42313b0 100644
--- a/lib/workers/send_trial_notifications.ex
+++ b/lib/workers/send_trial_notifications.ex
@@ -20,7 +20,7 @@ defmodule Plausible.Workers.SendTrialNotifications do
       )
 
     for user <- users do
-      case Timex.diff(user.trial_expiry_date, Date.utc_today(), :days) do
+      case Date.diff(user.trial_expiry_date, Date.utc_today()) do
         7 ->
           if Plausible.Auth.has_active_sites?(user, [:owner]) do
             send_one_week_reminder(user)
diff --git a/mix.exs b/mix.exs
index ee9adbf52447..5a115b9c6d29 100644
--- a/mix.exs
+++ b/mix.exs
@@ -69,7 +69,7 @@ defmodule Plausible.MixProject do
       {:bamboo_mua, "~> 0.2.0"},
       {:bcrypt_elixir, "~> 3.0"},
       {:bypass, "~> 2.1", only: [:dev, :test, :ce_test]},
-      {:ecto_ch, "~> 0.3.9"},
+      {:ecto_ch, "~> 0.5.0"},
       {:cloak, "~> 1.1"},
       {:cloak_ecto, "~> 1.2"},
       {:combination, "~> 0.0.3"},
diff --git a/mix.lock b/mix.lock
index bc915b942b49..4c011acd8493 100644
--- a/mix.lock
+++ b/mix.lock
@@ -8,9 +8,9 @@
   "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"},
   "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
   "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
-  "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"},
+  "castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"},
   "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
-  "ch": {:hex, :ch, "0.2.7", "29565d4ee8b0ae11df03f308f1cf4dfc04dbebe169a3d565d7c9283c18384af2", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "3a906077411b0f39fb6d19f599b91f746d6a55674333f83272c6bed12055efa5"},
+  "ch": {:hex, :ch, "0.2.9", "8273e27b741f2a31410c0c6291700abfbd262d0d9bd70b51c0deef624d6079b8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "889a12ad2dae69a6136b7109cbc73ba4f0d719f48d1a4c567ad3f95ad752a9c9"},
   "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"},
   "cldr_utils": {:hex, :cldr_utils, "2.27.0", "a75d5cdaaf6b7432eb10f547e6abe635c94746985c5b78e35bbbd08b16473b6c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "516f601e28da10b8f1f3af565321c4e3da3b898a0b50a5e5be425eff76d587e1"},
   "cloak": {:hex, :cloak, "1.1.2", "7e0006c2b0b98d976d4f559080fabefd81f0e0a50a3c4b621f85ceeb563e80bb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "940d5ac4fcd51b252930fd112e319ea5ae6ab540b722f3ca60a85666759b9585"},
@@ -31,10 +31,10 @@
   "digital_token": {:hex, :digital_token, "0.6.0", "13e6de581f0b1f6c686f7c7d12ab11a84a7b22fa79adeb4b50eec1a2d278d258", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "2455d626e7c61a128b02a4a8caddb092548c3eb613ac6f6a85e4cbb6caddc4d1"},
   "double": {:hex, :double, "0.8.2", "8e1cfcccdaef76c18846bc08e555555a2a699b806fa207b6468572a60513cc6a", [:mix], [], "hexpm", "90287642b2ec86125e0457aaba2ab0e80f7d7050cc80a0cef733e59bd70aa67c"},
   "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
-  "ecto": {:hex, :ecto, "3.12.2", "bae2094f038e9664ce5f089e5f3b6132a535d8b018bd280a485c2f33df5c0ce1", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e67c70f3a71c6afe80d946d3ced52ecc57c53c9829791bfff1830ff5a1f0c"},
-  "ecto_ch": {:hex, :ecto_ch, "0.3.9", "220ef4452aaccbc2bcb80f02a31ac24ef4febf095d672f17fcd22ec0c626b63e", [:mix], [{:ch, "~> 0.2.7", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "7ab1909d06462e20eb5fbff9564042942ee72589983bc3789d354e88a2fa6b7c"},
+  "ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"},
+  "ecto_ch": {:hex, :ecto_ch, "0.5.0", "f65dcc3b7b0c85726259471a91c34045852ce8b8817ea29becb3afdb28b3e987", [:mix], [{:ch, "~> 0.2.7", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "c8bb1c75e20983f16b285d7df21825ce2e3ba15d690bab930ea4133568ae2136"},
   "ecto_network": {:hex, :ecto_network, "1.5.0", "a930c910975e7a91237b858ebf0f4ad7b2aae32fa846275aa203cb858459ec73", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "4d614434ae3e6d373a2f693d56aafaa3f3349714668ffd6d24e760caf578aa2f"},
-  "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
+  "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
   "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"},
   "envy": {:hex, :envy, "1.1.1", "0bc9bd654dec24fcdf203f7c5aa1b8f30620f12cfb28c589d5e9c38fe1b07475", [:mix], [], "hexpm", "7061eb1a47415fd757145d8dec10dc0b1e48344960265cb108f194c4252c3a89"},
   "eqrcode": {:hex, :eqrcode, "0.1.10", "6294fece9d68ad64eef1c3c92cf111cfd6469f4fbf230a2d4cc905a682178f3f", [:mix], [], "hexpm", "da30e373c36a0fd37ab6f58664b16029919896d6c45a68a95cc4d713e81076f1"},
@@ -124,7 +124,7 @@
   "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
   "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"},
   "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
-  "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"},
+  "postgrex": {:hex, :postgrex, "0.19.2", "34d6884a332c7bf1e367fc8b9a849d23b43f7da5c6e263def92784d03f9da468", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "618988886ab7ae8561ebed9a3c7469034bf6a88b8995785a3378746a4b9835ec"},
   "prom_ex": {:hex, :prom_ex, "1.9.0", "63e6dda6c05cdeec1f26c48443dcc38ffd2118b3665ae8d2bd0e5b79f2aea03e", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.0.2", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.5.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.4.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.3", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.14.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.12.1", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "01f3d4f69ec93068219e686cc65e58a29c42bea5429a8ff4e2121f19db178ee6"},
   "public_suffix": {:git, "https://github.com/axelson/publicsuffix-elixir", "fa40c243d4b5d8598b90cff268bc4e33f3bb63f1", []},
   "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
diff --git a/priv/custom_sources.json b/priv/custom_sources.json
new file mode 100644
index 000000000000..16bc9f0d4aa7
--- /dev/null
+++ b/priv/custom_sources.json
@@ -0,0 +1,215 @@
+{
+  "android-app://com.reddit.frontpage":"Reddit",
+  "baidu.com":"Baidu",
+  "discord.com":"Discord",
+  "discordapp.com":"Discord",
+  "linktr.ee":"Linktree",
+  "m.sogou.com":"Sogou",
+  "ntp.msn.com":"Bing",
+  "perplexity.ai":"Perplexity",
+  "ptb.discord.com":"Discord",
+  "search.brave.com":"Brave",
+  "sogou.com":"Sogou",
+  "statics.teams.cdn.office.net":"Microsoft Teams",
+  "t.me":"Telegram",
+  "wap.sogou.com":"Sogou",
+  "ya.ru":"Yandex",
+  "yandex.com.tr":"Yandex",
+  "yandex.eu":"Yandex",
+  "yandex.fr":"Yandex",
+  "yandex.kz":"Yandex",
+  "yandex.tm":"Yandex",
+  "yandex.uz":"Yandex",
+  "fb": "Facebook",
+  "fb-ads": "Facebook",
+  "fbads": "Facebook",
+  "fbad": "Facebook",
+  "facebook-ads": "Facebook",
+  "facebook_ads": "Facebook",
+  "fcb": "Facebook",
+  "facebook_ad": "Facebook",
+  "facebook_feed_ad": "Facebook",
+  "ig": "Instagram",
+  "yt": "Youtube",
+  "yt-ads": "Youtube",
+  "reddit-ads": "Reddit",
+  "google_ads": "Google",
+  "google-ads": "Google",
+  "googleads": "Google",
+  "gads": "Google",
+  "google ads": "Google",
+  "adwords": "Google",
+  "twitter-ads": "Twitter",
+  "tiktokads": "TikTok",
+  "tik.tok": "TikTok",
+  "perplexity": "Perplexity",
+  "linktree": "Linktree",
+  "fo.wikipedia.org":"Wikipedia",
+  "ga.wikipedia.org":"Wikipedia",
+  "el.m.wikipedia.org":"Wikipedia",
+  "eo.m.wikipedia.org":"Wikipedia",
+  "ms.m.wikipedia.org":"Wikipedia",
+  "nl.wikipedia.org":"Wikipedia",
+  "dga.m.wikipedia.org":"Wikipedia",
+  "th.wikipedia.org":"Wikipedia",
+  "oc.wikipedia.org":"Wikipedia",
+  "da.wikipedia.org":"Wikipedia",
+  "pt.m.wikipedia.org":"Wikipedia",
+  "szl.m.wikipedia.org":"Wikipedia",
+  "be-tarask.wikipedia.org":"Wikipedia",
+  "ta.m.wikipedia.org":"Wikipedia",
+  "pa.m.wikipedia.org":"Wikipedia",
+  "mn.wikipedia.org":"Wikipedia",
+  "sv.m.wikipedia.org":"Wikipedia",
+  "sk.wikipedia.org":"Wikipedia",
+  "it.wikipedia.org":"Wikipedia",
+  "el.wikipedia.org":"Wikipedia",
+  "olo.wikipedia.org":"Wikipedia",
+  "hi.m.wikipedia.org":"Wikipedia",
+  "bn.m.wikipedia.org":"Wikipedia",
+  "uz.wikipedia.org":"Wikipedia",
+  "fr.m.wikipedia.org":"Wikipedia",
+  "fa.wikipedia.org":"Wikipedia",
+  "fi.wikipedia.org":"Wikipedia",
+  "arz.m.wikipedia.org":"Wikipedia",
+  "si.m.wikipedia.org":"Wikipedia",
+  "bjn.wikipedia.org":"Wikipedia",
+  "kn.wikipedia.org":"Wikipedia",
+  "is.m.wikipedia.org":"Wikipedia",
+  "nostalgia.wikipedia.org":"Wikipedia",
+  "en.wikipedia.org":"Wikipedia",
+  "nl.m.wikipedia.org":"Wikipedia",
+  "nn.m.wikipedia.org":"Wikipedia",
+  "bs.wikipedia.org":"Wikipedia",
+  "sh.m.wikipedia.org":"Wikipedia",
+  "vi.m.wikipedia.org":"Wikipedia",
+  "ru.wikipedia.org":"Wikipedia",
+  "tr.m.wikipedia.org":"Wikipedia",
+  "he.wikipedia.org":"Wikipedia",
+  "ta.wikipedia.org":"Wikipedia",
+  "es.wikipedia.org":"Wikipedia",
+  "si.wikipedia.org":"Wikipedia",
+  "pl.wikipedia.org":"Wikipedia",
+  "hu.wikipedia.org":"Wikipedia",
+  "lij.m.wikipedia.org":"Wikipedia",
+  "nn.wikipedia.org":"Wikipedia",
+  "ko.m.wikipedia.org":"Wikipedia",
+  "da.m.wikipedia.org":"Wikipedia",
+  "zh.m.wikipedia.org":"Wikipedia",
+  "vec.wikipedia.org":"Wikipedia",
+  "ar.wikipedia.org":"Wikipedia",
+  "bcl.m.wikipedia.org":"Wikipedia",
+  "en.m.wikipedia.org":"Wikipedia",
+  "sw.wikipedia.org":"Wikipedia",
+  "la.m.wikipedia.org":"Wikipedia",
+  "ur.m.wikipedia.org":"Wikipedia",
+  "id.m.wikipedia.org":"Wikipedia",
+  "crh.wikipedia.org":"Wikipedia",
+  "sr.wikipedia.org":"Wikipedia",
+  "sw.m.wikipedia.org":"Wikipedia",
+  "ka.m.wikipedia.org":"Wikipedia",
+  "lt.m.wikipedia.org":"Wikipedia",
+  "fy.wikipedia.org":"Wikipedia",
+  "ro.m.wikipedia.org":"Wikipedia",
+  "hr.wikipedia.org":"Wikipedia",
+  "mn.m.wikipedia.org":"Wikipedia",
+  "pt.wikipedia.org":"Wikipedia",
+  "it.m.wikipedia.org":"Wikipedia",
+  "lv.m.wikipedia.org":"Wikipedia",
+  "fa.m.wikipedia.org":"Wikipedia",
+  "ja.wikipedia.org":"Wikipedia",
+  "lv.wikipedia.org":"Wikipedia",
+  "hu.m.wikipedia.org":"Wikipedia",
+  "de.wikipedia.org":"Wikipedia",
+  "uk.wikipedia.org":"Wikipedia",
+  "ml.wikipedia.org":"Wikipedia",
+  "te.m.wikipedia.org":"Wikipedia",
+  "bg.wikipedia.org":"Wikipedia",
+  "eu.wikipedia.org":"Wikipedia",
+  "arz.wikipedia.org":"Wikipedia",
+  "id.wikipedia.org":"Wikipedia",
+  "mg.m.wikipedia.org":"Wikipedia",
+  "sq.m.wikipedia.org":"Wikipedia",
+  "ca.wikipedia.org":"Wikipedia",
+  "sk.m.wikipedia.org":"Wikipedia",
+  "az.wikipedia.org":"Wikipedia",
+  "ru.m.wikipedia.org":"Wikipedia",
+  "uz.m.wikipedia.org":"Wikipedia",
+  "wuu.wikipedia.org":"Wikipedia",
+  "hy.wikipedia.org":"Wikipedia",
+  "la.wikipedia.org":"Wikipedia",
+  "ca.m.wikipedia.org":"Wikipedia",
+  "ckb.m.wikipedia.org":"Wikipedia",
+  "tt.wikipedia.org":"Wikipedia",
+  "gu.m.wikipedia.org":"Wikipedia",
+  "lrc.wikipedia.org":"Wikipedia",
+  "be-tarask.m.wikipedia.org":"Wikipedia",
+  "no.m.wikipedia.org":"Wikipedia",
+  "simple.m.wikipedia.org":"Wikipedia",
+  "eu.m.wikipedia.org":"Wikipedia",
+  "ne.m.wikipedia.org":"Wikipedia",
+  "sr.m.wikipedia.org":"Wikipedia",
+  "vi.wikipedia.org":"Wikipedia",
+  "lt.wikipedia.org":"Wikipedia",
+  "cs.m.wikipedia.org":"Wikipedia",
+  "hy.m.wikipedia.org":"Wikipedia",
+  "mr.wikipedia.org":"Wikipedia",
+  "sv.wikipedia.org":"Wikipedia",
+  "eo.wikipedia.org":"Wikipedia",
+  "as.m.wikipedia.org":"Wikipedia",
+  "is.wikipedia.org":"Wikipedia",
+  "sh.wikipedia.org":"Wikipedia",
+  "zh-classical.wikipedia.org":"Wikipedia",
+  "nds-nl.m.wikipedia.org":"Wikipedia",
+  "tl.m.wikipedia.org":"Wikipedia",
+  "tr.wikipedia.org":"Wikipedia",
+  "cs.wikipedia.org":"Wikipedia",
+  "uk.m.wikipedia.org":"Wikipedia",
+  "sq.wikipedia.org":"Wikipedia",
+  "et.m.wikipedia.org":"Wikipedia",
+  "hr.m.wikipedia.org":"Wikipedia",
+  "bn.wikipedia.org":"Wikipedia",
+  "sl.wikipedia.org":"Wikipedia",
+  "th.m.wikipedia.org":"Wikipedia",
+  "hi.wikipedia.org":"Wikipedia",
+  "he.m.wikipedia.org":"Wikipedia",
+  "bat-smg.wikipedia.org":"Wikipedia",
+  "ml.m.wikipedia.org":"Wikipedia",
+  "zh.wikipedia.org":"Wikipedia",
+  "fi.m.wikipedia.org":"Wikipedia",
+  "de.m.wikipedia.org":"Wikipedia",
+  "be.wikipedia.org":"Wikipedia",
+  "pl.m.wikipedia.org":"Wikipedia",
+  "simple.wikipedia.org":"Wikipedia",
+  "rw.m.wikipedia.org":"Wikipedia",
+  "no.wikipedia.org":"Wikipedia",
+  "ja.m.wikipedia.org":"Wikipedia",
+  "yi.m.wikipedia.org":"Wikipedia",
+  "ga.m.wikipedia.org":"Wikipedia",
+  "ar.m.wikipedia.org":"Wikipedia",
+  "canary.discord.com":"Discord",
+  "sa.m.wikipedia.org":"Wikipedia",
+  "ky.wikipedia.org":"Wikipedia",
+  "es.m.wikipedia.org":"Wikipedia",
+  "new.wikipedia.org":"Wikipedia",
+  "lij.wikipedia.org":"Wikipedia",
+  "zh-yue.wikipedia.org":"Wikipedia",
+  "bg.m.wikipedia.org":"Wikipedia",
+  "bs.m.wikipedia.org":"Wikipedia",
+  "dz.wikipedia.org":"Wikipedia",
+  "kk.m.wikipedia.org":"Wikipedia",
+  "fr.wikipedia.org":"Wikipedia",
+  "qu.wikipedia.org":"Wikipedia",
+  "ka.wikipedia.org":"Wikipedia",
+  "webk.telegram.org":"Telegram",
+  "et.wikipedia.org":"Wikipedia",
+  "ms.wikipedia.org":"Wikipedia",
+  "az.m.wikipedia.org":"Wikipedia",
+  "cy.wikipedia.org":"Wikipedia",
+  "ro.wikipedia.org":"Wikipedia",
+  "mk.wikipedia.org":"Wikipedia",
+  "tl.wikipedia.org":"Wikipedia",
+  "am.wikipedia.org":"Wikipedia",
+  "ko.wikipedia.org":"Wikipedia",
+  "sl.m.wikipedia.org":"Wikipedia"
+}
diff --git a/priv/ingest_repo/migrations/20241020114559_add_click_id_param.exs b/priv/ingest_repo/migrations/20241020114559_add_click_id_param.exs
new file mode 100644
index 000000000000..96930c76e09d
--- /dev/null
+++ b/priv/ingest_repo/migrations/20241020114559_add_click_id_param.exs
@@ -0,0 +1,13 @@
+defmodule Plausible.IngestRepo.Migrations.AddClickIdParam do
+  use Ecto.Migration
+
+  def change do
+    alter table(:events_v2) do
+      add :click_id_param, :"LowCardinality(String)"
+    end
+
+    alter table(:sessions_v2) do
+      add :click_id_param, :"LowCardinality(String)"
+    end
+  end
+end
diff --git a/priv/ingest_repo/migrations/20241029074741_remap_sources.exs b/priv/ingest_repo/migrations/20241029074741_remap_sources.exs
new file mode 100644
index 000000000000..dda22bcafa55
--- /dev/null
+++ b/priv/ingest_repo/migrations/20241029074741_remap_sources.exs
@@ -0,0 +1,245 @@
+defmodule Plausible.IngestRepo.Migrations.RemapSources do
+  use Ecto.Migration
+
+  @mappings %{
+    # UTM sources
+    "fb" => "Facebook",
+    "fb-ads" => "Facebook",
+    "fbads" => "Facebook",
+    "fbad" => "Facebook",
+    "facebook-ads" => "Facebook",
+    "facebook_ads" => "Facebook",
+    "fcb" => "Facebook",
+    "facebook_ad" => "Facebook",
+    "facebook_feed_ad" => "Facebook",
+    "ig" => "Instagram",
+    "yt" => "Youtube",
+    "yt-ads" => "Youtube",
+    "reddit-ads" => "Reddit",
+    "google_ads" => "Google",
+    "google-ads" => "Google",
+    "googleads" => "Google",
+    "gads" => "Google",
+    "google ads" => "Google",
+    "adwords" => "Google",
+    "twitter-ads" => "Twitter",
+    "tiktokads" => "TikTok",
+    "tik.tok" => "TikTok",
+    "perplexity" => "Perplexity",
+    "linktree" => "Linktree",
+
+    # Referrers
+    "android-app://com.reddit.frontpage" => "Reddit",
+    "perplexity.ai" => "Perplexity",
+    "search.brave.com" => "Brave",
+    "yandex.com.tr" => "Yandex",
+    "yandex.kz" => "Yandex",
+    "ya.ru" => "Yandex",
+    "yandex.uz" => "Yandex",
+    "yandex.fr" => "Yandex",
+    "yandex.eu" => "Yandex",
+    "yandex.tm" => "Yandex",
+    "discord.com" => "Discord",
+    "t.me" => "Telegram",
+    "webk.telegram.org" => "Telegram",
+    "sogou.com" => "Sogou",
+    "m.sogou.com" => "Sogou",
+    "wap.sogou.com" => "Sogou",
+    "canary.discord.com" => "Discord",
+    "ptb.discord.com" => "Discord",
+    "discordapp.com" => "Discord",
+    "linktr.ee" => "Linktree",
+    "baidu.com" => "Baidu",
+    "statics.teams.cdn.office.net" => "Microsoft Teams",
+    "ntp.msn.com" => "Bing",
+    "en.wikipedia.org" => "Wikipedia",
+    "en.m.wikipedia.org" => "Wikipedia",
+    "de.wikipedia.org" => "Wikipedia",
+    "de.m.wikipedia.org" => "Wikipedia",
+    "fr.wikipedia.org" => "Wikipedia",
+    "ru.wikipedia.org" => "Wikipedia",
+    "fr.m.wikipedia.org" => "Wikipedia",
+    "es.wikipedia.org" => "Wikipedia",
+    "ja.wikipedia.org" => "Wikipedia",
+    "nl.wikipedia.org" => "Wikipedia",
+    "ru.m.wikipedia.org" => "Wikipedia",
+    "da.m.wikipedia.org" => "Wikipedia",
+    "no.wikipedia.org" => "Wikipedia",
+    "es.m.wikipedia.org" => "Wikipedia",
+    "it.wikipedia.org" => "Wikipedia",
+    "nl.m.wikipedia.org" => "Wikipedia",
+    "da.wikipedia.org" => "Wikipedia",
+    "sv.wikipedia.org" => "Wikipedia",
+    "it.m.wikipedia.org" => "Wikipedia",
+    "zh.wikipedia.org" => "Wikipedia",
+    "sv.m.wikipedia.org" => "Wikipedia",
+    "no.m.wikipedia.org" => "Wikipedia",
+    "fi.m.wikipedia.org" => "Wikipedia",
+    "fi.wikipedia.org" => "Wikipedia",
+    "ca.wikipedia.org" => "Wikipedia",
+    "ja.m.wikipedia.org" => "Wikipedia",
+    "pt.wikipedia.org" => "Wikipedia",
+    "pl.wikipedia.org" => "Wikipedia",
+    "pt.m.wikipedia.org" => "Wikipedia",
+    "cs.wikipedia.org" => "Wikipedia",
+    "zh.m.wikipedia.org" => "Wikipedia",
+    "ca.m.wikipedia.org" => "Wikipedia",
+    "pl.m.wikipedia.org" => "Wikipedia",
+    "cs.m.wikipedia.org" => "Wikipedia",
+    "is.wikipedia.org" => "Wikipedia",
+    "ko.wikipedia.org" => "Wikipedia",
+    "uk.wikipedia.org" => "Wikipedia",
+    "is.m.wikipedia.org" => "Wikipedia",
+    "he.m.wikipedia.org" => "Wikipedia",
+    "tr.wikipedia.org" => "Wikipedia",
+    "he.wikipedia.org" => "Wikipedia",
+    "id.m.wikipedia.org" => "Wikipedia",
+    "tr.m.wikipedia.org" => "Wikipedia",
+    "et.wikipedia.org" => "Wikipedia",
+    "fa.m.wikipedia.org" => "Wikipedia",
+    "uk.m.wikipedia.org" => "Wikipedia",
+    "simple.wikipedia.org" => "Wikipedia",
+    "ko.m.wikipedia.org" => "Wikipedia",
+    "id.wikipedia.org" => "Wikipedia",
+    "hr.m.wikipedia.org" => "Wikipedia",
+    "simple.m.wikipedia.org" => "Wikipedia",
+    "vi.wikipedia.org" => "Wikipedia",
+    "el.wikipedia.org" => "Wikipedia",
+    "hr.wikipedia.org" => "Wikipedia",
+    "sk.wikipedia.org" => "Wikipedia",
+    "hu.wikipedia.org" => "Wikipedia",
+    "hu.m.wikipedia.org" => "Wikipedia",
+    "fa.wikipedia.org" => "Wikipedia",
+    "el.m.wikipedia.org" => "Wikipedia",
+    "arz.m.wikipedia.org" => "Wikipedia",
+    "th.m.wikipedia.org" => "Wikipedia",
+    "ta.m.wikipedia.org" => "Wikipedia",
+    "ga.wikipedia.org" => "Wikipedia",
+    "et.m.wikipedia.org" => "Wikipedia",
+    "vi.m.wikipedia.org" => "Wikipedia",
+    "ro.wikipedia.org" => "Wikipedia",
+    "ro.m.wikipedia.org" => "Wikipedia",
+    "ms.m.wikipedia.org" => "Wikipedia",
+    "bs.m.wikipedia.org" => "Wikipedia",
+    "az.m.wikipedia.org" => "Wikipedia",
+    "bg.m.wikipedia.org" => "Wikipedia",
+    "nn.wikipedia.org" => "Wikipedia",
+    "bg.wikipedia.org" => "Wikipedia",
+    "ml.m.wikipedia.org" => "Wikipedia",
+    "bn.m.wikipedia.org" => "Wikipedia",
+    "sl.wikipedia.org" => "Wikipedia",
+    "nn.m.wikipedia.org" => "Wikipedia",
+    "sk.m.wikipedia.org" => "Wikipedia",
+    "ms.wikipedia.org" => "Wikipedia",
+    "uz.wikipedia.org" => "Wikipedia",
+    "th.wikipedia.org" => "Wikipedia",
+    "sr.m.wikipedia.org" => "Wikipedia",
+    "hi.m.wikipedia.org" => "Wikipedia",
+    "eu.wikipedia.org" => "Wikipedia",
+    "uz.m.wikipedia.org" => "Wikipedia",
+    "sr.wikipedia.org" => "Wikipedia",
+    "lv.wikipedia.org" => "Wikipedia",
+    "la.wikipedia.org" => "Wikipedia",
+    "sl.m.wikipedia.org" => "Wikipedia",
+    "arz.wikipedia.org" => "Wikipedia",
+    "ta.wikipedia.org" => "Wikipedia",
+    "ka.m.wikipedia.org" => "Wikipedia",
+    "ga.m.wikipedia.org" => "Wikipedia",
+    "lt.wikipedia.org" => "Wikipedia",
+    "lv.m.wikipedia.org" => "Wikipedia",
+    "kk.m.wikipedia.org" => "Wikipedia",
+    "lt.m.wikipedia.org" => "Wikipedia",
+    "ar.wikipedia.org" => "Wikipedia",
+    "eo.wikipedia.org" => "Wikipedia",
+    "sw.m.wikipedia.org" => "Wikipedia",
+    "sh.wikipedia.org" => "Wikipedia",
+    "bs.wikipedia.org" => "Wikipedia",
+    "ml.wikipedia.org" => "Wikipedia",
+    "hy.wikipedia.org" => "Wikipedia",
+    "ka.wikipedia.org" => "Wikipedia",
+    "hi.wikipedia.org" => "Wikipedia",
+    "la.m.wikipedia.org" => "Wikipedia",
+    "bn.wikipedia.org" => "Wikipedia",
+    "ur.m.wikipedia.org" => "Wikipedia",
+    "sh.m.wikipedia.org" => "Wikipedia",
+    "az.wikipedia.org" => "Wikipedia",
+    "si.m.wikipedia.org" => "Wikipedia",
+    "sq.wikipedia.org" => "Wikipedia",
+    "zh-yue.wikipedia.org" => "Wikipedia",
+    "ckb.m.wikipedia.org" => "Wikipedia",
+    "kn.wikipedia.org" => "Wikipedia",
+    "lij.wikipedia.org" => "Wikipedia",
+    "fy.wikipedia.org" => "Wikipedia",
+    "lij.m.wikipedia.org" => "Wikipedia",
+    "hy.m.wikipedia.org" => "Wikipedia",
+    "mn.m.wikipedia.org" => "Wikipedia",
+    "ar.m.wikipedia.org" => "Wikipedia",
+    "tl.wikipedia.org" => "Wikipedia",
+    "eu.m.wikipedia.org" => "Wikipedia",
+    "fo.wikipedia.org" => "Wikipedia",
+    "mn.wikipedia.org" => "Wikipedia",
+    "mr.wikipedia.org" => "Wikipedia",
+    "zh-classical.wikipedia.org" => "Wikipedia",
+    "cy.wikipedia.org" => "Wikipedia",
+    "olo.wikipedia.org" => "Wikipedia",
+    "te.m.wikipedia.org" => "Wikipedia",
+    "mk.wikipedia.org" => "Wikipedia",
+    "dz.wikipedia.org" => "Wikipedia",
+    "as.m.wikipedia.org" => "Wikipedia",
+    "szl.m.wikipedia.org" => "Wikipedia",
+    "oc.wikipedia.org" => "Wikipedia",
+    "rw.m.wikipedia.org" => "Wikipedia",
+    "tl.m.wikipedia.org" => "Wikipedia",
+    "si.wikipedia.org" => "Wikipedia",
+    "nostalgia.wikipedia.org" => "Wikipedia",
+    "lrc.wikipedia.org" => "Wikipedia",
+    "eo.m.wikipedia.org" => "Wikipedia",
+    "ky.wikipedia.org" => "Wikipedia",
+    "new.wikipedia.org" => "Wikipedia",
+    "be.wikipedia.org" => "Wikipedia",
+    "bcl.m.wikipedia.org" => "Wikipedia",
+    "sq.m.wikipedia.org" => "Wikipedia",
+    "am.wikipedia.org" => "Wikipedia",
+    "nds-nl.m.wikipedia.org" => "Wikipedia",
+    "gu.m.wikipedia.org" => "Wikipedia",
+    "bjn.wikipedia.org" => "Wikipedia",
+    "pa.m.wikipedia.org" => "Wikipedia",
+    "sa.m.wikipedia.org" => "Wikipedia",
+    "tt.wikipedia.org" => "Wikipedia",
+    "qu.wikipedia.org" => "Wikipedia",
+    "be-tarask.wikipedia.org" => "Wikipedia",
+    "mg.m.wikipedia.org" => "Wikipedia",
+    "dga.m.wikipedia.org" => "Wikipedia",
+    "bat-smg.wikipedia.org" => "Wikipedia",
+    "sw.wikipedia.org" => "Wikipedia",
+    "wuu.wikipedia.org" => "Wikipedia",
+    "ne.m.wikipedia.org" => "Wikipedia",
+    "yi.m.wikipedia.org" => "Wikipedia",
+    "vec.wikipedia.org" => "Wikipedia",
+    "be-tarask.m.wikipedia.org" => "Wikipedia",
+    "crh.wikipedia.org" => "Wikipedia"
+  }
+
+  def up do
+    {keys, values} = Enum.unzip(@mappings)
+
+    events_sql = """
+      ALTER TABLE events_v2
+      UPDATE referrer_source = transform(referrer_source, {$0:Array(String)}, {$1:Array(String)})
+      WHERE referrer_source IN {$0:Array(String)}
+    """
+
+    sessions_sql = """
+      ALTER TABLE sessions_v2
+      UPDATE referrer_source = transform(referrer_source, {$0:Array(String)}, {$1:Array(String)})
+      WHERE referrer_source IN {$0:Array(String)}
+    """
+
+    execute(fn -> repo().query!(events_sql, [keys, values]) end)
+    execute(fn -> repo().query!(sessions_sql, [keys, values]) end)
+  end
+
+  def down do
+    raise "irreversible"
+  end
+end
diff --git a/priv/ingest_repo/migrations/20241104082248_remap_sources_v2.exs b/priv/ingest_repo/migrations/20241104082248_remap_sources_v2.exs
new file mode 100644
index 000000000000..3995af199440
--- /dev/null
+++ b/priv/ingest_repo/migrations/20241104082248_remap_sources_v2.exs
@@ -0,0 +1,245 @@
+defmodule Plausible.IngestRepo.Migrations.RemapSourcesV2 do
+  use Ecto.Migration
+
+  @mappings %{
+    # UTM sources
+    "fb" => "Facebook",
+    "fb-ads" => "Facebook",
+    "fbads" => "Facebook",
+    "fbad" => "Facebook",
+    "facebook-ads" => "Facebook",
+    "facebook_ads" => "Facebook",
+    "fcb" => "Facebook",
+    "facebook_ad" => "Facebook",
+    "facebook_feed_ad" => "Facebook",
+    "ig" => "Instagram",
+    "yt" => "Youtube",
+    "yt-ads" => "Youtube",
+    "reddit-ads" => "Reddit",
+    "google_ads" => "Google",
+    "google-ads" => "Google",
+    "googleads" => "Google",
+    "gads" => "Google",
+    "google ads" => "Google",
+    "adwords" => "Google",
+    "twitter-ads" => "Twitter",
+    "tiktokads" => "TikTok",
+    "tik.tok" => "TikTok",
+    "perplexity" => "Perplexity",
+    "linktree" => "Linktree",
+
+    # Referrers
+    "android-app://com.reddit.frontpage" => "Reddit",
+    "perplexity.ai" => "Perplexity",
+    "search.brave.com" => "Brave",
+    "yandex.com.tr" => "Yandex",
+    "yandex.kz" => "Yandex",
+    "ya.ru" => "Yandex",
+    "yandex.uz" => "Yandex",
+    "yandex.fr" => "Yandex",
+    "yandex.eu" => "Yandex",
+    "yandex.tm" => "Yandex",
+    "discord.com" => "Discord",
+    "t.me" => "Telegram",
+    "webk.telegram.org" => "Telegram",
+    "sogou.com" => "Sogou",
+    "m.sogou.com" => "Sogou",
+    "wap.sogou.com" => "Sogou",
+    "canary.discord.com" => "Discord",
+    "ptb.discord.com" => "Discord",
+    "discordapp.com" => "Discord",
+    "linktr.ee" => "Linktree",
+    "baidu.com" => "Baidu",
+    "statics.teams.cdn.office.net" => "Microsoft Teams",
+    "ntp.msn.com" => "Bing",
+    "en.wikipedia.org" => "Wikipedia",
+    "en.m.wikipedia.org" => "Wikipedia",
+    "de.wikipedia.org" => "Wikipedia",
+    "de.m.wikipedia.org" => "Wikipedia",
+    "fr.wikipedia.org" => "Wikipedia",
+    "ru.wikipedia.org" => "Wikipedia",
+    "fr.m.wikipedia.org" => "Wikipedia",
+    "es.wikipedia.org" => "Wikipedia",
+    "ja.wikipedia.org" => "Wikipedia",
+    "nl.wikipedia.org" => "Wikipedia",
+    "ru.m.wikipedia.org" => "Wikipedia",
+    "da.m.wikipedia.org" => "Wikipedia",
+    "no.wikipedia.org" => "Wikipedia",
+    "es.m.wikipedia.org" => "Wikipedia",
+    "it.wikipedia.org" => "Wikipedia",
+    "nl.m.wikipedia.org" => "Wikipedia",
+    "da.wikipedia.org" => "Wikipedia",
+    "sv.wikipedia.org" => "Wikipedia",
+    "it.m.wikipedia.org" => "Wikipedia",
+    "zh.wikipedia.org" => "Wikipedia",
+    "sv.m.wikipedia.org" => "Wikipedia",
+    "no.m.wikipedia.org" => "Wikipedia",
+    "fi.m.wikipedia.org" => "Wikipedia",
+    "fi.wikipedia.org" => "Wikipedia",
+    "ca.wikipedia.org" => "Wikipedia",
+    "ja.m.wikipedia.org" => "Wikipedia",
+    "pt.wikipedia.org" => "Wikipedia",
+    "pl.wikipedia.org" => "Wikipedia",
+    "pt.m.wikipedia.org" => "Wikipedia",
+    "cs.wikipedia.org" => "Wikipedia",
+    "zh.m.wikipedia.org" => "Wikipedia",
+    "ca.m.wikipedia.org" => "Wikipedia",
+    "pl.m.wikipedia.org" => "Wikipedia",
+    "cs.m.wikipedia.org" => "Wikipedia",
+    "is.wikipedia.org" => "Wikipedia",
+    "ko.wikipedia.org" => "Wikipedia",
+    "uk.wikipedia.org" => "Wikipedia",
+    "is.m.wikipedia.org" => "Wikipedia",
+    "he.m.wikipedia.org" => "Wikipedia",
+    "tr.wikipedia.org" => "Wikipedia",
+    "he.wikipedia.org" => "Wikipedia",
+    "id.m.wikipedia.org" => "Wikipedia",
+    "tr.m.wikipedia.org" => "Wikipedia",
+    "et.wikipedia.org" => "Wikipedia",
+    "fa.m.wikipedia.org" => "Wikipedia",
+    "uk.m.wikipedia.org" => "Wikipedia",
+    "simple.wikipedia.org" => "Wikipedia",
+    "ko.m.wikipedia.org" => "Wikipedia",
+    "id.wikipedia.org" => "Wikipedia",
+    "hr.m.wikipedia.org" => "Wikipedia",
+    "simple.m.wikipedia.org" => "Wikipedia",
+    "vi.wikipedia.org" => "Wikipedia",
+    "el.wikipedia.org" => "Wikipedia",
+    "hr.wikipedia.org" => "Wikipedia",
+    "sk.wikipedia.org" => "Wikipedia",
+    "hu.wikipedia.org" => "Wikipedia",
+    "hu.m.wikipedia.org" => "Wikipedia",
+    "fa.wikipedia.org" => "Wikipedia",
+    "el.m.wikipedia.org" => "Wikipedia",
+    "arz.m.wikipedia.org" => "Wikipedia",
+    "th.m.wikipedia.org" => "Wikipedia",
+    "ta.m.wikipedia.org" => "Wikipedia",
+    "ga.wikipedia.org" => "Wikipedia",
+    "et.m.wikipedia.org" => "Wikipedia",
+    "vi.m.wikipedia.org" => "Wikipedia",
+    "ro.wikipedia.org" => "Wikipedia",
+    "ro.m.wikipedia.org" => "Wikipedia",
+    "ms.m.wikipedia.org" => "Wikipedia",
+    "bs.m.wikipedia.org" => "Wikipedia",
+    "az.m.wikipedia.org" => "Wikipedia",
+    "bg.m.wikipedia.org" => "Wikipedia",
+    "nn.wikipedia.org" => "Wikipedia",
+    "bg.wikipedia.org" => "Wikipedia",
+    "ml.m.wikipedia.org" => "Wikipedia",
+    "bn.m.wikipedia.org" => "Wikipedia",
+    "sl.wikipedia.org" => "Wikipedia",
+    "nn.m.wikipedia.org" => "Wikipedia",
+    "sk.m.wikipedia.org" => "Wikipedia",
+    "ms.wikipedia.org" => "Wikipedia",
+    "uz.wikipedia.org" => "Wikipedia",
+    "th.wikipedia.org" => "Wikipedia",
+    "sr.m.wikipedia.org" => "Wikipedia",
+    "hi.m.wikipedia.org" => "Wikipedia",
+    "eu.wikipedia.org" => "Wikipedia",
+    "uz.m.wikipedia.org" => "Wikipedia",
+    "sr.wikipedia.org" => "Wikipedia",
+    "lv.wikipedia.org" => "Wikipedia",
+    "la.wikipedia.org" => "Wikipedia",
+    "sl.m.wikipedia.org" => "Wikipedia",
+    "arz.wikipedia.org" => "Wikipedia",
+    "ta.wikipedia.org" => "Wikipedia",
+    "ka.m.wikipedia.org" => "Wikipedia",
+    "ga.m.wikipedia.org" => "Wikipedia",
+    "lt.wikipedia.org" => "Wikipedia",
+    "lv.m.wikipedia.org" => "Wikipedia",
+    "kk.m.wikipedia.org" => "Wikipedia",
+    "lt.m.wikipedia.org" => "Wikipedia",
+    "ar.wikipedia.org" => "Wikipedia",
+    "eo.wikipedia.org" => "Wikipedia",
+    "sw.m.wikipedia.org" => "Wikipedia",
+    "sh.wikipedia.org" => "Wikipedia",
+    "bs.wikipedia.org" => "Wikipedia",
+    "ml.wikipedia.org" => "Wikipedia",
+    "hy.wikipedia.org" => "Wikipedia",
+    "ka.wikipedia.org" => "Wikipedia",
+    "hi.wikipedia.org" => "Wikipedia",
+    "la.m.wikipedia.org" => "Wikipedia",
+    "bn.wikipedia.org" => "Wikipedia",
+    "ur.m.wikipedia.org" => "Wikipedia",
+    "sh.m.wikipedia.org" => "Wikipedia",
+    "az.wikipedia.org" => "Wikipedia",
+    "si.m.wikipedia.org" => "Wikipedia",
+    "sq.wikipedia.org" => "Wikipedia",
+    "zh-yue.wikipedia.org" => "Wikipedia",
+    "ckb.m.wikipedia.org" => "Wikipedia",
+    "kn.wikipedia.org" => "Wikipedia",
+    "lij.wikipedia.org" => "Wikipedia",
+    "fy.wikipedia.org" => "Wikipedia",
+    "lij.m.wikipedia.org" => "Wikipedia",
+    "hy.m.wikipedia.org" => "Wikipedia",
+    "mn.m.wikipedia.org" => "Wikipedia",
+    "ar.m.wikipedia.org" => "Wikipedia",
+    "tl.wikipedia.org" => "Wikipedia",
+    "eu.m.wikipedia.org" => "Wikipedia",
+    "fo.wikipedia.org" => "Wikipedia",
+    "mn.wikipedia.org" => "Wikipedia",
+    "mr.wikipedia.org" => "Wikipedia",
+    "zh-classical.wikipedia.org" => "Wikipedia",
+    "cy.wikipedia.org" => "Wikipedia",
+    "olo.wikipedia.org" => "Wikipedia",
+    "te.m.wikipedia.org" => "Wikipedia",
+    "mk.wikipedia.org" => "Wikipedia",
+    "dz.wikipedia.org" => "Wikipedia",
+    "as.m.wikipedia.org" => "Wikipedia",
+    "szl.m.wikipedia.org" => "Wikipedia",
+    "oc.wikipedia.org" => "Wikipedia",
+    "rw.m.wikipedia.org" => "Wikipedia",
+    "tl.m.wikipedia.org" => "Wikipedia",
+    "si.wikipedia.org" => "Wikipedia",
+    "nostalgia.wikipedia.org" => "Wikipedia",
+    "lrc.wikipedia.org" => "Wikipedia",
+    "eo.m.wikipedia.org" => "Wikipedia",
+    "ky.wikipedia.org" => "Wikipedia",
+    "new.wikipedia.org" => "Wikipedia",
+    "be.wikipedia.org" => "Wikipedia",
+    "bcl.m.wikipedia.org" => "Wikipedia",
+    "sq.m.wikipedia.org" => "Wikipedia",
+    "am.wikipedia.org" => "Wikipedia",
+    "nds-nl.m.wikipedia.org" => "Wikipedia",
+    "gu.m.wikipedia.org" => "Wikipedia",
+    "bjn.wikipedia.org" => "Wikipedia",
+    "pa.m.wikipedia.org" => "Wikipedia",
+    "sa.m.wikipedia.org" => "Wikipedia",
+    "tt.wikipedia.org" => "Wikipedia",
+    "qu.wikipedia.org" => "Wikipedia",
+    "be-tarask.wikipedia.org" => "Wikipedia",
+    "mg.m.wikipedia.org" => "Wikipedia",
+    "dga.m.wikipedia.org" => "Wikipedia",
+    "bat-smg.wikipedia.org" => "Wikipedia",
+    "sw.wikipedia.org" => "Wikipedia",
+    "wuu.wikipedia.org" => "Wikipedia",
+    "ne.m.wikipedia.org" => "Wikipedia",
+    "yi.m.wikipedia.org" => "Wikipedia",
+    "vec.wikipedia.org" => "Wikipedia",
+    "be-tarask.m.wikipedia.org" => "Wikipedia",
+    "crh.wikipedia.org" => "Wikipedia"
+  }
+
+  def up do
+    {keys, values} = Enum.unzip(@mappings)
+
+    events_sql = """
+      ALTER TABLE events_v2
+      UPDATE referrer_source = transform(lower(referrer_source), {$0:Array(String)}, {$1:Array(String)})
+      WHERE lower(referrer_source) IN {$0:Array(String)}
+    """
+
+    sessions_sql = """
+      ALTER TABLE sessions_v2
+      UPDATE referrer_source = transform(lower(referrer_source), {$0:Array(String)}, {$1:Array(String)})
+      WHERE lower(referrer_source) IN {$0:Array(String)}
+    """
+
+    execute(fn -> repo().query!(events_sql, [keys, values]) end)
+    execute(fn -> repo().query!(sessions_sql, [keys, values]) end)
+  end
+
+  def down do
+    raise "irreversible"
+  end
+end
diff --git a/priv/referer_favicon_domains.json b/priv/referer_favicon_domains.json
index 0d3786d532be..6430d7f77344 100644
--- a/priv/referer_favicon_domains.json
+++ b/priv/referer_favicon_domains.json
@@ -1 +1 @@
-{"White Pages":"www.whitepages.com.au","QQ Mail":"mail.qq.com","eo":"eo.st","Toolbarhome":"www.toolbarhome.com","YouGoo":"www.yougoo.fr","Walhello":"www.walhello.info","Tixuma":"www.tixuma.de","Hyves":"hyves.nl","PriceRunner":"www.pricerunner.co.uk","Euroseek":"www.euroseek.com","Web.nl":"www.web.nl","360.cn":"so.360.cn","Seznam":"search.seznam.cz","Nigma":"nigma.ru","Wirtualna Polska":"szukaj.wp.pl","Google Product Search":"google.ac/products","Picsearch":"www.picsearch.com","Suchnase":"www.suchnase.de","WebSearch":"www.websearch.com","Lycos":"search.lycos.com","Conduit":"search.conduit.com","StackOverflow":"stackoverflow.com","GitHub":"github.com","FriendFeed":"friendfeed.com","Flickr":"flickr.com","Google+":"url.google.com","APOLL07":"apollo7.de","Douban":"douban.com","Pocket":"getpocket.com","Doubleclick":"ad.doubleclick.net","Exalead":"www.exalead.fr","Sharelook":"www.sharelook.fr","Adam Internet":"webmail.adam.com.au","Friendster":"friendster.com","2degrees":"webmail.2degreesbroadband.co.nz","ZEDO":"zedo.com","Outlook.com":"mail.live.com","1&1":"search.1and1.com","URL.ORGanizier":"www.url.org","Google":"support.google.com","Snapdo":"search.snapdo.com","Bebo":"bebo.com","Biglobe":"cgi.search.biglobe.ne.jp","1und1":"search.1und1.de","BlackPlanet":"blackplanet.com","Findwide":"search.findwide.com","Digg":"digg.com","Eurip":"www.eurip.com","SearchCanvas":"www.searchcanvas.com","Sapo":"pesquisa.sapo.pt","Yieldmo":"yieldmo.com","ABCsøk":"abcsolk.no","Gaia Online":"gaiaonline.com","blekko":"blekko.com","Vodafone":"webmail.vodafone.co.nz","qip":"search.qip.ru","Hooseek.com":"www.hooseek.com","Tumblr":"tumblr.com","GAIS":"gais.cs.ccu.edu.tw","AllTheWeb":"www.alltheweb.com","Terra":"buscador.terra.es","Buzznet":"buzznet.com","Searchy":"www.searchy.co.uk","Commander":"webmail.commander.net.au","Twingly":"www.twingly.com","Yandex":"yandex.ru","Najdi":"www.najdi.si","Sociomantic Labs":"sociomantic.com","Netlog":"netlog.com","Volny":"web.volny.cz","Criteo":"cas.jp.as.criteo.com","dmoz":"dmoz.org","Bing":"bing.com","Altavista":"www.altavista.com","Kataweb":"www.kataweb.it","Charter":"www.charter.net","Startpagina":"startgoogle.startpagina.nl","Search.ch":"www.search.ch","Uludag Sozluk":"uludagsozluk.com","Orkut":"orkut.com","UKR.net":"search.ukr.net","Sonico.com":"sonico.com","Comcast":"serach.comcast.net","Yandex Images":"images.yandex.ru","Vindex":"www.vindex.nl","Zoho":"mail.zoho.com","SteelHouse":"steelhousemedia.com","Geona":"geona.net","Excite":"search.excite.it","1.cz":"1.cz","Vimeo":"vimeo.com","Rubicon Project":"optimized-by.rubiconproject.com","Yasni":"www.yasni.de","Badoo":"badoo.com","Tiscali":"search.tiscali.it","Inbox.com":"inbox.com","Orange Webmail":"orange.fr/webmail","Blogpulse":"www.blogpulse.com","Delfi latvia":"smart.delfi.lv","iPrimus":"webmail.iprimus.com.au","Mail.ru":"my.mail.ru","Google News":"news.google.ac","Delicious":"delicious.com","Sonobi":"sonobi.com","Road Runner Search":"search.rr.com","Fast Browser Search":"www.fastbrowsersearch.com","WAYN":"wayn.com","Weibo":"weibo.com","Windows Live Spaces":"login.live.com","Clix":"pesquisa.clix.pt","T-Online":"suche.t-online.de","Ask Toolbar":"search.tb.ask.com","Flyingbird":"inspsearch.com","Yahoo!":"finance.yahoo.com","Alexa":"alexa.com","Jungle Key":"junglekey.com","Gmail":"mail.google.com","Optus Zoo":"webmail.optuszoo.com.au","Web.de":"suche.web.de","Odnoklassniki":"odnoklassniki.ru","GMX":"suche.gmx.net","Freshweather":"www.fresh-weather.com","Quora":"quora.com","AppNexus":"ib.adnxs.com","Onet":"szukaj.onet.pl","Geni":"geni.com","Naver Images":"image.search.naver.com","Qzone":"qzone.qq.com","Mozbot":"www.mozbot.fr","Adform":"adform.net","Blogdigger":"www.blogdigger.com","Netspace":"webmail.netspace.net.au","Apontador":"apontador.com.br","Jungle Spider":"www.jungle-spider.de","Mozo":"mozo.com.au","Zapmeta":"www.zapmeta.com","MySearch":"www.mysearch.com","X-recherche":"www.x-recherche.com","Lo.st":"lo.st","TrovaRapido":"www.trovarapido.com","Dodo":"webmail.dodo.com.au","Flix":"www.flix.de","Flashtalking":"flashtalking.com","Nasza-klasa.pl":"nk.pl","AOL Mail":"mail.aol.com","Virgilio":"ricerca.virgilio.it","Rambler":"nova.rambler.ru","Atlas":"searchatlas.centrum.cz","Austronaut":"www2.austronaut.at","Xanga":"xanga.com","vKruguDruzei.ru":"vkrugudruzei.ru","Friends Reunited":"friendsreunited.com","Nifty":"search.nifty.com","Plaxo":"plaxo.com","Sizmek":"bs.serving-sys.com","ONE by AOL":"nexage.com","Gomeo":"www.gomeo.com","BidSwitch":"bidswitch.net","Yahoo! Images":"image.yahoo.cn","ITU Sozluk":"itusozluk.com","Instagram":"instagram.com","AOL":"search.aol.com","Compuserve":"websearch.cs.com","Free":"search.free.fr","Reddit":"reddit.com","Metager2":"metager2.de","Tuenti":"tuenti.com","Rakuten":"websearch.rakuten.co.jp","126 Mail":"mail.126.com","Centrum":"serach.centrum.cz","Dalesearch":"www.dalesearch.com","Freecause":"search.freecause.com","Viadeo":"viadeo.com","Bing Images":"bing.com/images/search","Softonic":"search.softonic.com","ICQ":"www.icq.com","Gule Sider":"www.gulesider.no","Winamp":"search.winamp.com","Paperball":"www.paperball.de","Gigablast":"www.gigablast.com","Inci Sozluk":"inci.sozlukspot.com","Outbrain":"paid.outbrain.com","Plista":"farm.plista.com","Neti":"www.neti.ee","LifeStreet":"lfstmedia.com","Finderoo":"www.finderoo.com","Virgin":"webmail.virginbroadband.com.au","Latne":"www.latne.lv","Meinestadt":"www.meinestadt.de","Google Video":"video.google.com","Babylon":"search.babylon.com","Mixi":"mixi.jp","Twitter":"twitter.com","earthlink":"search.earthlink.net","Pinterest":"pinterest.com","Online.no":"online.no","Foursquare":"foursquare.com","Skynet":"www.skynet.be","Amazon":"amazon.com","Crawler":"www.crawler.com","Voila":"search.ke.voila.fr","Orange":"busca.orange.es","Apollo Latvia":"apollo.lv/portal/search/","Zoeken":"www.zoeken.nl","Vinden":"www.vinden.nl","163 Mail":"mail.163.com","Google Images":"google.ac/imgres","Opplysningen 1881":"www.1881.no","Classmates":"classmates.com","Jivox":"jivox.com","Naver Mail":"mail.naver.com","Arianna":"arianna.libero.it","Skyrock":"skyrock.com","Eksi Sozluk":"Sozluk.com","goo":"search.goo.ne.jp","Hacker News":"news.ycombinator.com","Metager":"meta.rrzn.uni-hannover.de","Witch":"www.witch.de","suche.info":"suche.info","SoSoDesk":"sosodesktop.com","Fixsuche":"www.fixsuche.de","Everyclick":"www.everyclick.com","Weborama":"www.weborama.com","Freenet":"webmail.freenet.de","Icerockeet":"blogs.icerocket.com","Vkontakte":"vk.com","Firstfind":"www.firstsfind.com","SourceForge":"sourceforge.net","Donanimhaber":"donanimhaber.com","OpenX":"us-ads.openx.net","Qualigo":"www.qualigo.at","Zoohoo":"zoohoo.cz","Aport":"sm.aport.ru","Tribal Fusion":"cdnx.tribalfusion.com","Ecosia":"ecosia.org","Nate":"search.nate.com","Last.fm":"lastfm.ru","Jyxo":"jyxo.1188.cz","Flixster":"flixster.com","Youtube":"youtube.com","Eniro":"www.eniro.se","Needtofind":"ko.search.need2find.com","Disqus":"redirect.disqus.com","Eyeota":"eyeota.net","PubMatic":"sshowads.pubmatic.com","Holmes":"holmes.ge","Looksmart":"www.looksmart.com","Yatedo":"www.yatedo.com","Telstra":"search.media.telstra.com.au","El Mundo":"ariadna.elmundo.es","Baidu":"www.baidu.com","Trusted-Search":"www.trusted--search.com","WeeWorld":"weeworld.com","MetaCrawler.de":"s1.metacrawler.de","maailm":"www.maailm.com","RPMFind":"rpmfind.net","British Telecommunications":"search.bt.com","WWW":"search.www.ee","AdRoll":"adroll.com","Hit-Parade":"req.-hit-parade.com","Tagged":"login.tagged.com","Paper.li":"paper.li","AudienceScience":"wunderloop.net","Marktplaats":"www.marktplaats.nl","StickyADS.tv":"stickyadstv.com","MyLife":"mylife.ru","Yahoo! Mail":"mail.yahoo.net","Search This":"www.searchthis.com","XING":"xing.com","Monstercrawler":"www.monstercrawler.com","Habbo":"habbo.com","MyHeritage":"myheritage.com","La Toile Du Quebec Via Google":"www.toile.com","Gnadenmeer":"www.gnadenmeer.de","Ask":"ask.com","Yippy":"search.yippy.com","Bigpond":"webmail.bigpond.com","Seznam Mail":"email.seznam.cz","Plazoo":"www.plazoo.com","Goyellow.de":"www.goyellow.de","Fluct":"adingo.jp","LinkedIn":"linkedin.com","PeoplePC":"search.peoplepc.com","I.ua":"search.i.ua","Mixpo":"mixpo.com","Hotbot":"www.hotbot.com","Daum Mail":"mail2.daum.net","Cuil":"www.cuil.com","Francite":"recherche.francite.com","Maxwebsearch":"maxwebsearch.com","Sovrn":"lijit.com","Daum":"search.daum.net","Suchmaschine.com":"www.suchmaschine.com","Myspace":"myspace.com","Instela":"instela.com","Forestle":"forestle.org","Alice Adsl":"rechercher.aliceadsl.fr","Zoek":"www3.zoek.nl","Certified-Toolbar":"search.certified-toolbar.com","MicroAd":"microad.jp","IXquick":"ixquick.com","Trouvez.com":"www.trouvez.com","Globososo":"searches.globososo.com","Startsiden":"www.startsiden.no","LiveJournal":"livejournal.ru","Daemon search":"daemon-search.com","Hocam.com":"hocam.com","Sogou":"www.sougou.com","Renren":"renren.com","Mamma":"www.mamma.com","Fireball":"www.fireball.de","Neustar AdAdvisor":"adadvisor.net","Technorati":"technorati.com","myYearbook":"myyearbook.com","Poisk.ru":"poisk.ru","Mister Wong":"www.mister-wong.com","MoiKrug.ru":"moikrug.ru","Casale Media":"casalemedia.com","Google Blogsearch":"blogsearch.google.ac","The Smart Search":"thesmartsearch.net","all.by":"all.by","Multiply":"multiply.com","Ilse":"www.ilse.nl","DasOertliche":"www.dasoertliche.de","Genieo":"search.genieo.com","Zhongsou":"p.zhongsou.com","Kvasir":"www.kvasir.no","kununu":"kununu.com","StudiVZ":"studivz.net","I-play":"start.iplay.com","iiNet":"webmail.iinet.net.au","DasTelefonbuch":"www1.dastelefonbuch.de","Tut.by":"search.tut.by","Interia":"www.google.interia.pl","Naver":"search.naver.com","Facebook":"facebook.com","Yam":"search.yam.com","Acoon":"www.acoon.de","Searchalot":"searchalot.com","StumbleUpon":"stumbleupon.com","Taboola":"trc.taboola.com","Abacho":"www.abacho.de","Acuity Ads":"acuityplatform.com","Meta":"meta.ua","Cyworld":"global.cyworld.com","canoe.ca":"web.canoe.ca","DuckDuckGo":"duckduckgo.com","Identi.ca":"identi.ca","Bluewin":"search.bluewin.ch","Taringa!":"taringa.net","Teoma":"www.teoma.com","Mynet Mail":"mail.mynet.com","InfoSpace":"infospace.com","arama":"arama.com","Delfi":"otsing.delfi.ee","TalkTalk":"www.talktalk.co.uk","hi5":"hi5.com","HighBeam":"www.highbeam.com","uol.com.br":"busca.uol.com.br","Westnet":"webmail.westnet.com.au","Fotolog":"fotolog.com","Arcor":"www.arcor.de","Search.com":"www.search.com"}
+{"Friends Reunited":"friendsreunited.com","I-play":"start.iplay.com","White Pages":"www.whitepages.com.au","Eniro":"www.eniro.se","Kvasir":"www.kvasir.no","Geona":"geona.net","Neustar AdAdvisor":"adadvisor.net","Wirtualna Polska":"szukaj.wp.pl","Tagged":"login.tagged.com","Liveinternet":"liveinternet.ru","Xanga":"xanga.com","Metager2":"metager2.de","Commander":"webmail.commander.net.au","QIP":"mail.qip.ru","Plazoo":"www.plazoo.com","Globososo":"searches.globososo.com","Zoohoo":"zoohoo.cz","Eurip":"www.eurip.com","Web.de":"suche.web.de","Mailchimp":"com.mailchimp.mailchimp","DuckDuckGo":"duckduckgo.com","Google Blogsearch":"blogsearch.google.ac","Searchy":"www.searchy.co.uk","MySearch":"mysearch.com","AppNexus":"ib.adnxs.com","Outbrain":"paid.outbrain.com","Metager":"meta.rrzn.uni-hannover.de","Search.com":"www.search.com","Westnet":"webmail.westnet.com.au","BlackPlanet":"blackplanet.com","Arcor":"www.arcor.de","Bing":"bing.com","Orange Webmail":"orange.fr/webmail","Nasza-klasa.pl":"nk.pl","URL.ORGanizier":"www.url.org","HighBeam":"www.highbeam.com","Instagram":"instagram.com","Rubicon Project":"optimized-by.rubiconproject.com","Mixi":"mixi.jp","Web.nl":"www.web.nl","X-recherche":"www.x-recherche.com","Freenet":"webmail.freenet.de","Altavista":"www.altavista.com","Teoma":"www.teoma.com","2gis":"2gis.ru","Google Video":"video.google.com","Viadeo":"viadeo.com","DasOertliche":"www.dasoertliche.de","canoe.ca":"web.canoe.ca","Sapo":"pesquisa.sapo.pt","TikTok":"tiktok.com","Voila":"search.ke.voila.fr","Sovrn":"lijit.com","Icerockeet":"blogs.icerocket.com","Babylon":"search.babylon.com","Yandex Images":"images.yandex.ru","Euroseek":"www.euroseek.com","Hacker News":"news.ycombinator.com","Vimeo":"vimeo.com","Shenma":"so.m.sm.cn","AOL":"search.aol.com","Tribal Fusion":"cdnx.tribalfusion.com","Flyingbird":"inspsearch.com","Austronaut":"www2.austronaut.at","Hocam.com":"hocam.com","Yahoo! Mail":"mail.yahoo.net","LinkedIn":"com.linkedin.android","SoSoDesk":"sosodesktop.com","Yippy":"search.yippy.com","PubMatic":"sshowads.pubmatic.com","Fireball":"www.fireball.de","Adition":"adition.com","arama":"arama.com","Hyves":"hyves.nl","Classmates":"classmates.com","Lo.st":"lo.st","Google+":"url.google.com","DasTelefonbuch":"www1.dastelefonbuch.de","E1.ru":"mail.e1.ru","Forestle":"forestle.org","StackOverflow":"stackoverflow.com","AdRoll":"adroll.com","Zoho":"mail.zoho.com","TrovaRapido":"www.trovarapido.com","Finderoo":"www.finderoo.com","Dodo":"webmail.dodo.com.au","Flixster":"flixster.com","GitHub":"github.com","Instela":"instela.com","BidSwitch":"bidswitch.net","ONE by AOL":"nexage.com","Cuil":"www.cuil.com","Donanimhaber":"donanimhaber.com","Threads":"threads.net","Opplysningen 1881":"www.1881.no","Virgin":"webmail.virginbroadband.com.au","Google News":"news.google.ac","Acuity Ads":"acuityplatform.com","myYearbook":"myyearbook.com","SearchCanvas":"www.searchcanvas.com","Goyellow.de":"www.goyellow.de","Taringa!":"taringa.net","Nifty":"search.nifty.com","T-Online":"suche.t-online.de","Startsiden":"www.startsiden.no","Alice Adsl":"rechercher.aliceadsl.fr","MyLife":"mylife.ru","Acoon":"www.acoon.de","Freshweather":"www.fresh-weather.com","Last.fm":"lastfm.ru","Telegram":"web.telegram.org","LiveJournal":"livejournal.ru","Plaxo":"plaxo.com","GAIS":"gais.cs.ccu.edu.tw","Sogou":"www.sougou.com","ZEDO":"zedo.com","WhatsApp":"web.whatsapp.com","Lycos":"search.lycos.com","Paper.li":"paper.li","Criteo":"cas.jp.as.criteo.com","Zhongsou":"p.zhongsou.com","Everyclick":"www.everyclick.com","Adform":"adform.net","Picsearch":"www.picsearch.com","iPrimus":"webmail.iprimus.com.au","SourceForge":"sourceforge.net","Kataweb":"www.kataweb.it","dmoz":"dmoz.org","WWW":"search.www.ee","Sonico.com":"sonico.com","OpenX":"us-ads.openx.net","Vindex":"www.vindex.nl","Alexa":"alexa.com","Arianna":"arianna.libero.it","Fluct":"adingo.jp","AllTheWeb":"www.alltheweb.com","Nigma":"nigma.ru","XING":"xing.com","PeoplePC":"search.peoplepc.com","Online.no":"online.no","Myspace":"myspace.com","Witch":"www.witch.de","Monstercrawler":"www.monstercrawler.com","Meta":"meta.ua","Findwide":"search.findwide.com","Foursquare":"foursquare.com","Cyworld":"global.cyworld.com","British Telecommunications":"search.bt.com","Yahoo!":"finance.yahoo.com","Paperball":"www.paperball.de","AudienceScience":"wunderloop.net","Looksmart":"www.looksmart.com","all.by":"all.by","PriceRunner":"www.pricerunner.co.uk","Mastermail":"mastermail.ru","Excite":"search.excite.it","Blogdigger":"www.blogdigger.com","Apollo Latvia":"apollo.lv/portal/search/","Rakuten":"websearch.rakuten.co.jp","Firstfind":"www.firstsfind.com","Zoek":"www3.zoek.nl","Bebo":"bebo.com","Amazon":"amazon.com","Mail.ru":"e.mail.ru","Quora":"quora.com","Tildes":"tildes.net","Certified-Toolbar":"search.certified-toolbar.com","AdNET":"adnet.de","Hotbot":"www.hotbot.com","Outlook.com":"mail.live.com","Vkontakte":"m.vk.com","Twingly":"www.twingly.com","IXquick":"ixquick.com","WebSearch":"www.websearch.com","Digg":"digg.com","vKruguDruzei.ru":"vkrugudruzei.ru","Identi.ca":"identi.ca","Seznam":"search.seznam.cz","Mixpo":"mixpo.com","Sharelook":"www.sharelook.fr","El Mundo":"ariadna.elmundo.es","Compuserve":"websearch.cs.com","TalkTalk":"www.talktalk.co.uk","Bing Images":"bing.com/images/search","Douban":"douban.com","StumbleUpon":"stumbleupon.com","Poisk.ru":"poisk.ru","Yieldmo":"yieldmo.com","Yandex Maps":"maps.yandex.ru","Beeline":"post.ru","Trouvez.com":"www.trouvez.com","Ukr.net":"mail.ukr.net","Mister Wong":"www.mister-wong.com","Daum":"search.daum.net","Exalead":"www.exalead.fr","Suchmaschine.com":"www.suchmaschine.com","AdSpirit":"adspirit.de","Conduit":"search.conduit.com","ABCsøk":"abcsolk.no","Indeed":"de.indeed.com","WAYN":"wayn.com","ADFOX":"adfox.ru","Search This":"www.searchthis.com","uol.com.br":"busca.uol.com.br","Atlas":"searchatlas.centrum.cz","Biglobe":"cgi.search.biglobe.ne.jp","Seznam Mail":"email.seznam.cz","Uludag Sozluk":"uludagsozluk.com","Optus Zoo":"webmail.optuszoo.com.au","blekko":"blekko.com","Qualigo":"www.qualigo.at","Fast Browser Search":"www.fastbrowsersearch.com","LowerMyBills":"lowermybills.com","Delfi":"otsing.delfi.ee","Weibo":"weibo.com","Gule Sider":"www.gulesider.no","Zoeken":"www.zoeken.nl","Taboola":"trc.taboola.com","Tut.by":"search.tut.by","InfoSpace":"infospace.com","Yam":"search.yam.com","Trusted-Search":"www.trusted--search.com","Needtofind":"ko.search.need2find.com","GMX":"suche.gmx.net","126 Mail":"mail.126.com","ITU Sozluk":"itusozluk.com","Qzone":"qzone.qq.com","MyHeritage":"myheritage.com","Maxwebsearch":"maxwebsearch.com","Torg.Mail.ru":"torg.mail.ru","Apontador":"apontador.com.br","APOLL07":"apollo7.de","Ecosia":"ecosia.org","Dalesearch":"www.dalesearch.com","suche.info":"suche.info","iiNet":"webmail.iinet.net.au","Mamma":"www.mamma.com","Buzznet":"buzznet.com","Road Runner Search":"search.rr.com","WeeWorld":"weeworld.com","Toolbarhome":"www.toolbarhome.com","Odnoklassniki":"odnoklassniki.ru","Inbox.com":"inbox.com","Vodafone":"webmail.vodafone.co.nz","Hooseek.com":"www.hooseek.com","Zapmeta":"www.zapmeta.com","Tuenti":"tuenti.com","Adam Internet":"webmail.adam.com.au","Snapdo":"search.snapdo.com","Startpagina":"startgoogle.startpagina.nl","Aport":"sm.aport.ru","Pocket":"getpocket.com","Jungle Key":"junglekey.com","I.ua":"search.i.ua","Eyeota":"eyeota.net","Eksi Sozluk":"Sozluk.com","Renren":"renren.com","Qwant":"www.qwant.com","Terra":"buscador.terra.es","Naver Mail":"mail.naver.com","Delfi latvia":"smart.delfi.lv","Netlog":"netlog.com","Skynet":"www.skynet.be","Daemon search":"daemon-search.com","Badoo":"badoo.com","StickyADS.tv":"stickyadstv.com","Yandex.Market":"market.yandex.ru","Interia":"www.google.interia.pl","Yasni":"www.yasni.de","Searchalot":"searchalot.com","Hit-Parade":"req.-hit-parade.com","Gnadenmeer":"www.gnadenmeer.de","Rambler":"mail.rambler.ru","Gomeo":"www.gomeo.com","Windows Live Spaces":"login.live.com","Sibmail":"sibmail.com","Disqus":"redirect.disqus.com","Mozo":"mozo.com.au","Snapchat":"com.snapchat.android","Whirlpool":"forums.whirlpool.net.au","AOL Mail":"mail.aol.com","Crawler":"www.crawler.com","Najdi":"www.najdi.si","Jyxo":"jyxo.1188.cz","Orange":"busca.orange.es","Tumblr":"tumblr.com","La Toile Du Quebec Via Google":"www.toile.com","Netspace":"webmail.netspace.net.au","Doubleclick":"ad.doubleclick.net","earthlink":"com.earthlink.myearthlink","Twitter":"twitter.com","Ilse":"www.ilse.nl","Jungle Spider":"www.jungle-spider.de","Softonic":"search.softonic.com","Youtube":"youtube.com","Volny":"web.volny.cz","Habbo":"habbo.com","Coccoc":"coccoc.com","Charter":"www.charter.net","1&1":"search.1and1.com","Gigablast":"www.gigablast.com","Flickr":"flickr.com","Google":"support.google.com","LifeStreet":"lfstmedia.com","Search.ch":"www.search.ch","QQ Mail":"mail.qq.com","Casale Media":"casalemedia.com","Weborama":"www.weborama.com","Neti":"www.neti.ee","Flashtalking":"flashtalking.com","Virgilio":"ricerca.virgilio.it","Yandex":"mail.yandex.ru","163 Mail":"mail.163.com","StepStone":"www.stepstone.de","Onet":"szukaj.onet.pl","Fixsuche":"www.fixsuche.de","Clix":"pesquisa.clix.pt","Baidu":"www.baidu.com","Telstra":"search.media.telstra.com.au","Ask":"ask.com","qip":"search.qip.ru","Vinden":"www.vinden.nl","Plista":"farm.plista.com","SteelHouse":"steelhousemedia.com","Winamp":"search.winamp.com","Sociomantic Labs":"sociomantic.com","Sonobi":"sonobi.com","Jivox":"jivox.com","Mynet Mail":"mail.mynet.com","Inci Sozluk":"inci.sozlukspot.com","YouGoo":"www.yougoo.fr","Centrum":"serach.centrum.cz","Reddit":"reddit.com","Francite":"recherche.francite.com","Google Product Search":"google.ac/products","2degrees":"webmail.2degreesbroadband.co.nz","Ask Toolbar":"search.tb.ask.com","Holmes":"holmes.ge","Gmail":"mail.google.com","eo":"eo.st","Yahoo! Images":"image.yahoo.cn","Fotolog":"fotolog.com","Naver":"search.naver.com","Pinterest":"pinterest.ca","Freecause":"search.freecause.com","Skyrock":"skyrock.com","Delicious":"delicious.com","Nate":"search.nate.com","Tiscali":"search.tiscali.it","Tixuma":"www.tixuma.de","MoiKrug.ru":"moikrug.ru","Sizmek":"bs.serving-sys.com","Multiply":"multiply.com","Orkut":"orkut.com","kununu":"kununu.com","UKR.net":"search.ukr.net","RPMFind":"rpmfind.net","Abacho":"www.abacho.de","Free":"search.free.fr","Friendster":"friendster.com","Meinestadt":"www.meinestadt.de","Lilo":"search.lilo.org","ICQ":"www.icq.com","FriendFeed":"friendfeed.com","Slack":"app.slack.com","Mozbot":"www.mozbot.fr","Genieo":"search.genieo.com","The Smart Search":"thesmartsearch.net","Yandex.Direct":"an.yandex.ru","Daum Mail":"mail2.daum.net","1und1":"search.1und1.de","goo":"search.goo.ne.jp","maailm":"www.maailm.com","1.cz":"1.cz","Suchnase":"www.suchnase.de","Technorati":"technorati.com","Naver Images":"image.search.naver.com","Gaia Online":"gaiaonline.com","MetaCrawler.de":"s1.metacrawler.de","Blogpulse":"www.blogpulse.com","MicroAd":"microad.jp","StudiVZ":"studivz.net","Facebook":"facebook.com","Monster":"www.monster.be","hi5":"hi5.com","Bluewin":"search.bluewin.ch","Yatedo":"www.yatedo.com","Walhello":"www.walhello.info","Flix":"www.flix.de","Google Images":"google.ac/imgres","360.cn":"so.360.cn","Comcast":"serach.comcast.net","Skype":"web.skype.com","Latne":"www.latne.lv","Bigpond":"webmail.bigpond.com","Marktplaats":"www.marktplaats.nl","Geni":"geni.com","Price.ru":"price.ru"}
diff --git a/test/plausible/goals_test.exs b/test/plausible/goals_test.exs
index 672858f8e668..552cef622e14 100644
--- a/test/plausible/goals_test.exs
+++ b/test/plausible/goals_test.exs
@@ -167,7 +167,7 @@ defmodule Plausible.GoalsTest do
     insert(:goal, %{site: site, event_name: " Signup "})
     insert(:goal, %{site: site, page_path: " /Signup "})
 
-    Plausible.Site.Removal.run(site.domain)
+    Plausible.Site.Removal.run(site)
 
     assert [] = Goals.for_site(site)
   end
diff --git a/test/plausible/ingestion/source_test.exs b/test/plausible/ingestion/source_test.exs
new file mode 100644
index 000000000000..2037f16535a6
--- /dev/null
+++ b/test/plausible/ingestion/source_test.exs
@@ -0,0 +1,4 @@
+defmodule Plausible.Ingestion.SourceTest do
+  use ExUnit.Case, async: true
+  doctest Plausible.Ingestion.Source
+end
diff --git a/test/plausible/site/memberships/accept_invitation_test.exs b/test/plausible/site/memberships/accept_invitation_test.exs
index 485f2d82240e..43c2f23bb73e 100644
--- a/test/plausible/site/memberships/accept_invitation_test.exs
+++ b/test/plausible/site/memberships/accept_invitation_test.exs
@@ -318,6 +318,29 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
       )
     end
 
+    @tag :teams
+    test "does not create redundant guest membership when owner team membership exists" do
+      user = insert(:user)
+      {:ok, team} = Plausible.Teams.get_or_create(user)
+      site = insert(:site, team: team, members: [user])
+
+      invitation =
+        insert(:invitation,
+          site_id: site.id,
+          inviter: insert(:user),
+          email: user.email,
+          role: :admin
+        )
+
+      {:ok, team_membership} =
+        Plausible.Teams.Invitations.accept_invitation_sync(invitation, user)
+
+      team_membership = team_membership |> Repo.reload!() |> Repo.preload(:guest_memberships)
+
+      assert team_membership.role == :owner
+      assert team_membership.guest_memberships == []
+    end
+
     @tag :teams
     test "sync newly converted membership with team" do
       inviter = insert(:user)
diff --git a/test/plausible/site/site_removal_test.exs b/test/plausible/site/site_removal_test.exs
index 931cb29dfff5..4a8680fe72bf 100644
--- a/test/plausible/site/site_removal_test.exs
+++ b/test/plausible/site/site_removal_test.exs
@@ -7,13 +7,34 @@ defmodule Plausible.Site.SiteRemovalTest do
 
   test "site from postgres is immediately deleted" do
     site = insert(:site)
-    assert {:ok, context} = Removal.run(site.domain)
+    assert {:ok, context} = Removal.run(site)
     assert context.delete_all == {1, nil}
     refute Sites.get_by_domain(site.domain)
   end
 
-  test "deletion is idempotent" do
-    assert {:ok, context} = Removal.run("some.example.com")
-    assert context.delete_all == {0, nil}
+  @tag :teams
+  test "site deletion prunes team guest memberships" do
+    site = insert(:site) |> Plausible.Teams.load_for_site() |> Repo.preload(:owner)
+
+    team_membership =
+      insert(:team_membership, user: build(:user), team: site.team, role: :guest)
+
+    insert(:guest_membership, team_membership: team_membership, site: site, role: :viewer)
+
+    team_invitation =
+      insert(:team_invitation,
+        email: "sitedeletion@example.test",
+        team: site.team,
+        inviter: site.owner,
+        role: :guest
+      )
+
+    insert(:guest_invitation, team_invitation: team_invitation, site: site, role: :viewer)
+
+    assert {:ok, context} = Removal.run(site)
+    assert context.delete_all == {1, nil}
+
+    refute Repo.reload(team_membership)
+    refute Repo.reload(team_invitation)
   end
 end
diff --git a/test/plausible/site/sites_test.exs b/test/plausible/site/sites_test.exs
index 07fea67c191f..cb5b4e2e7fd0 100644
--- a/test/plausible/site/sites_test.exs
+++ b/test/plausible/site/sites_test.exs
@@ -172,8 +172,8 @@ defmodule Plausible.SitesTest do
 
   describe "list/3 and list_with_invitations/3" do
     test "returns empty when there are no sites" do
-      user = insert(:user)
-      _rogue_site = insert(:site)
+      user = new_user()
+      _rogue_site = new_site()
 
       assert %{
                entries: [],
@@ -209,32 +209,17 @@ defmodule Plausible.SitesTest do
     end
 
     test "pinned site doesn't matter with membership revoked (no active invitations)" do
-      user1 = insert(:user, email: "user1@example.com")
-      user2 = insert(:user, email: "user2@example.com")
-
-      team1 = insert(:team)
-      insert(:site, team: team1, members: [user1], domain: "one.example.com")
-      insert(:team_membership, team: team1, user: user1, role: :owner)
-
-      team2 = insert(:team)
-
-      site2 =
-        insert(:site,
-          team: team2,
-          members: [user2],
-          domain: "two.example.com"
-        )
+      user1 = new_user(email: "user1@example.com")
+      _user2 = new_user(email: "user2@example.com")
 
-      insert(:team_membership, team: team2, user: user2, role: :owner)
+      new_site(owner: user1, domain: "one.example.com")
+      site2 = new_site(domain: "two.example.com")
 
-      membership = insert(:site_membership, user: user1, role: :viewer, site: site2)
-      team_membership = insert(:team_membership, team: team2, user: user1, role: :guest)
-      insert(:guest_membership, team_membership: team_membership, site: site2, role: :viewer)
+      user1 = site2 |> add_guest(user: user1, role: :viewer)
 
       {:ok, _} = Sites.toggle_pin(user1, site2)
 
-      Repo.delete!(membership)
-      Repo.delete!(team_membership)
+      revoke_membership(site2, user1)
 
       assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{})
       assert %{entries: [%{domain: "one.example.com"}]} = Sites.list_with_invitations(user1, %{})
@@ -245,40 +230,16 @@ defmodule Plausible.SitesTest do
                Plausible.Teams.Sites.list_with_invitations(user1, %{})
     end
 
-    test "pinned site doesn't matter with membership revoked (with active invitation)" do
-      user1 = insert(:user, email: "user1@example.com")
-      user2 = insert(:user, email: "user2@example.com")
-
-      team1 = insert(:team)
-      insert(:site, team: team1, members: [user1], domain: "one.example.com")
-      insert(:team_membership, team: team1, user: user1, role: :owner)
-
-      team2 = insert(:team)
-
-      site2 =
-        insert(:site,
-          team: team2,
-          members: [user2],
-          domain: "two.example.com"
-        )
+    test "pinned site with active invitation" do
+      user1 = new_user(email: "user1@example.com")
+      user2 = new_user(email: "user2@example.com")
 
-      insert(:team_membership, team: team2, user: user2, role: :owner)
+      site1 = new_site(domain: "one.example.com", owner: user1)
+      site2 = new_site(domain: "two.example.com")
 
-      membership = insert(:site_membership, user: user1, role: :viewer, site: site2)
-      team_membership = insert(:team_membership, team: team2, user: user1, role: :guest)
-      insert(:guest_membership, team_membership: team_membership, site: site2, role: :viewer)
+      invite_guest(site2, user1, role: :editor, inviter: user2)
 
-      insert(:invitation, email: user1.email, inviter: user2, role: :owner, site: site2)
-
-      team_invitation =
-        insert(:team_invitation, team: team2, email: user1.email, inviter: user2, role: :guest)
-
-      insert(:guest_invitation, team_invitation: team_invitation, site: site2, role: :editor)
-
-      {:ok, _} = Sites.toggle_pin(user1, site2)
-
-      Repo.delete!(membership)
-      Repo.delete!(team_membership)
+      {:ok, _} = Sites.toggle_pin(user1, site1)
 
       assert %{entries: [%{domain: "one.example.com"}]} = Sites.list(user1, %{})
 
@@ -292,374 +253,203 @@ defmodule Plausible.SitesTest do
     end
 
     test "puts invitations first, pinned sites second, sites last" do
-      user = insert(:user, email: "hello@example.com")
-
-      team1 = insert(:team)
-
-      site1 =
-        %{id: site_id1} = insert(:site, team: team1, members: [user], domain: "one.example.com")
-
-      insert(:team_membership, team: team1, user: user, role: :owner)
-      team2 = insert(:team)
-
-      site2 =
-        %{id: site_id2} = insert(:site, team: team2, members: [user], domain: "two.example.com")
-
-      insert(:team_membership, team: team2, user: build(:user), role: :owner)
-      team_membership2 = insert(:team_membership, team: team2, user: user, role: :guest)
-      insert(:guest_membership, team_membership: team_membership2, site: site2, role: :editor)
-
-      team4 = insert(:team)
-
-      site4 =
-        %{id: site_id4} = insert(:site, team: team4, members: [user], domain: "four.example.com")
-
-      insert(:team_membership, team: team4, user: build(:user), role: :owner)
-      team_membership4 = insert(:team_membership, team: team4, user: user, role: :guest)
-      insert(:guest_membership, team_membership: team_membership4, site: site4, role: :viewer)
-
-      _rogue_site = insert(:site, team: build(:team), domain: "rogue.example.com")
-
-      ## Having owner invite on owned site does not make much sense?
-      ## Maybe that was a repro of real-life example?
-      # insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
-
-      # team_invitation1 =
-      #   insert(:team_invitation,
-      #     team: team1,
-      #     email: user.email,
-      #     inviter: build(:user),
-      #     role: :guest
-      #   )
-
-      # insert(:guest_invitation, team_invitation: team_invitation1, site: site1, role: :editor)
-
-      team3 = insert(:team)
+      user1 = new_user()
+      user2 = new_user()
+      user3 = new_user()
 
-      site3 = %{id: site_id3} = insert(:site, team: team3, domain: "three.example.com")
+      site1 = new_site(owner: user1, domain: "one.example.com")
+      site2 = new_site(owner: user2, domain: "two.example.com")
+      site3 = new_site(domain: "three.example.com")
+      site4 = new_site(domain: "four.example.com")
+      site5 = new_site(owner: user3, domain: "five.example.com")
 
-      insert(:invitation, email: user.email, inviter: build(:user), role: :viewer, site: site3)
+      invite_guest(site2, user1, role: :editor, inviter: user2)
+      add_guest(site3, user: user1, role: :viewer)
+      add_guest(site4, user: user1, role: :editor)
 
-      team_invitation2 =
-        insert(:team_invitation,
-          team: team3,
-          email: user.email,
-          inviter: build(:user),
-          role: :guest
-        )
+      invite_transfer(site5, user1, inviter: user3)
 
-      insert(:guest_invitation, team_invitation: team_invitation2, site: site3, role: :viewer)
+      {:ok, _} = Sites.toggle_pin(user1, site3)
+      {:ok, _pin_to_ignore} = Sites.toggle_pin(user2, site2)
 
-      insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
-
-      team_invitation3 =
-        insert(:team_invitation,
-          team: team1,
-          email: "friend@example.com",
-          inviter: user,
-          role: :guest
-        )
-
-      insert(:guest_invitation, team_invitation: team_invitation3, site: site1, role: :viewer)
-
-      insert(:invitation,
-        site: site1,
-        inviter: user,
-        email: "another@example.com"
-      )
-
-      team_invitation4 =
-        insert(:team_invitation,
-          team: team1,
-          email: "another@example.com",
-          inviter: user,
-          role: :guest
-        )
-
-      insert(:guest_invitation, team_invitation: team_invitation4, site: site1, role: :editor)
-
-      {:ok, _} = Sites.toggle_pin(user, site2)
+      site1_id = site1.id
+      site2_id = site2.id
+      site3_id = site3.id
+      site4_id = site4.id
+      site5_id = site5.id
 
       assert %{
                entries: [
-                 %{id: ^site_id2, entry_type: "pinned_site"},
-                 %{id: ^site_id4, entry_type: "site"},
-                 %{id: ^site_id1, entry_type: "site"}
+                 %{id: ^site3_id, entry_type: "pinned_site"},
+                 %{id: ^site4_id, entry_type: "site"},
+                 %{id: ^site1_id, entry_type: "site"}
                ]
-             } = Sites.list(user, %{})
+             } = Sites.list(user1, %{})
 
       assert %{
                entries: [
-                 %{id: ^site_id3, entry_type: "invitation"},
-                 %{id: ^site_id2, entry_type: "pinned_site"},
-                 %{id: ^site_id4, entry_type: "site"},
-                 %{id: ^site_id1, entry_type: "site"}
+                 %{id: ^site5_id, entry_type: "invitation"},
+                 %{id: ^site2_id, entry_type: "invitation"},
+                 %{id: ^site3_id, entry_type: "pinned_site"},
+                 %{id: ^site4_id, entry_type: "site"},
+                 %{id: ^site1_id, entry_type: "site"}
                ]
-             } = Sites.list_with_invitations(user, %{})
-
-      assert %{
-               entries: [
-                 %{id: ^site_id2, entry_type: "pinned_site"},
-                 %{id: ^site_id4, entry_type: "site"},
-                 %{id: ^site_id1, entry_type: "site"}
-               ]
-             } = Plausible.Teams.Sites.list(user, %{})
-
-      assert %{
-               entries: [
-                 %{id: ^site_id3, entry_type: "invitation"},
-                 %{id: ^site_id2, entry_type: "pinned_site"},
-                 %{id: ^site_id4, entry_type: "site"},
-                 %{id: ^site_id1, entry_type: "site"}
-               ]
-             } = Plausible.Teams.Sites.list_with_invitations(user, %{})
+             } = Sites.list_with_invitations(user1, %{})
     end
 
     test "pinned sites are ordered according to the time they were pinned at" do
-      user = insert(:user, email: "hello@example.com")
-
-      site1 = %{id: site_id1} = insert(:site, members: [user], domain: "one.example.com")
-      site2 = %{id: site_id2} = insert(:site, members: [user], domain: "two.example.com")
-      site4 = %{id: site_id4} = insert(:site, members: [user], domain: "four.example.com")
+      user1 = new_user()
+      user2 = new_user()
+      user3 = new_user()
 
-      _rogue_site = insert(:site, domain: "rogue.example.com")
+      site1 = new_site(owner: user1, domain: "one.example.com")
+      site2 = new_site(owner: user2, domain: "two.example.com")
+      site3 = new_site(domain: "three.example.com")
+      site4 = new_site(domain: "four.example.com")
+      site5 = new_site(owner: user3, domain: "five.example.com")
 
-      insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
+      invite_guest(site2, user1, role: :editor, inviter: user2)
+      add_guest(site3, user: user1, role: :viewer)
+      add_guest(site4, user: user1, role: :editor)
 
-      %{id: site_id3} =
-        insert(:site,
-          domain: "three.example.com",
-          invitations: [
-            build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-          ]
-        )
+      invite_transfer(site5, user1, inviter: user3)
 
-      insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
+      {:ok, _} = Sites.toggle_pin(user1, site3)
 
-      insert(:invitation,
-        site: site1,
-        inviter: user,
-        email: "another@example.com"
-      )
+      site1_id = site1.id
+      site2_id = site2.id
+      site3_id = site3.id
+      site4_id = site4.id
+      site5_id = site5.id
 
-      Sites.set_option(user, site2, :pinned_at, ~N[2023-10-22 12:00:00])
-      {:ok, _} = Sites.toggle_pin(user, site4)
+      Sites.set_option(user1, site1, :pinned_at, ~N[2023-10-22 12:00:00])
+      {:ok, _} = Sites.toggle_pin(user1, site3)
 
       assert %{
                entries: [
-                 %{id: ^site_id4, entry_type: "pinned_site"},
-                 %{id: ^site_id2, entry_type: "pinned_site"},
-                 %{id: ^site_id1, entry_type: "site"}
+                 %{id: ^site3_id, entry_type: "pinned_site"},
+                 %{id: ^site1_id, entry_type: "pinned_site"},
+                 %{id: ^site4_id, entry_type: "site"}
                ]
-             } = Sites.list(user, %{})
+             } = Sites.list(user1, %{})
 
       assert %{
                entries: [
-                 %{id: ^site_id1, entry_type: "invitation"},
-                 %{id: ^site_id3, entry_type: "invitation"},
-                 %{id: ^site_id4, entry_type: "pinned_site"},
-                 %{id: ^site_id2, entry_type: "pinned_site"}
+                 %{id: ^site5_id, entry_type: "invitation"},
+                 %{id: ^site2_id, entry_type: "invitation"},
+                 %{id: ^site3_id, entry_type: "pinned_site"},
+                 %{id: ^site1_id, entry_type: "pinned_site"},
+                 %{id: ^site4_id, entry_type: "site"}
                ]
-             } = Sites.list_with_invitations(user, %{})
+             } = Sites.list_with_invitations(user1, %{})
     end
 
     test "filters by domain" do
-      user = insert(:user)
-      %{id: site_id1} = insert(:site, domain: "first.example.com", members: [user])
-      %{id: _site_id2} = insert(:site, domain: "second.example.com", members: [user])
-      _rogue_site = insert(:site)
-
-      %{id: site_id3} =
-        insert(:site,
-          domain: "first-another.example.com",
-          invitations: [
-            build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-          ]
-        )
+      user1 = new_user()
+      user2 = new_user()
+      user3 = new_user()
 
-      assert %{
-               entries: [
-                 %{id: ^site_id1}
-               ]
-             } = Sites.list(user, %{}, filter_by_domain: "first")
+      site1 = new_site(owner: user1, domain: "first.example.com")
+      site2 = new_site(owner: user2, domain: "first-transfer.example.com")
+      site3 = new_site(owner: user3, domain: "first-invitation.example.com")
+      _site4 = new_site(owner: user1, domain: "another.example.com")
+
+      invite_guest(site3, user1, role: :viewer, inviter: user3)
+      invite_transfer(site2, user1, inviter: user2)
+
+      site1_id = site1.id
+      site2_id = site2.id
+      site3_id = site3.id
 
       assert %{
                entries: [
-                 %{id: ^site_id3},
-                 %{id: ^site_id1}
+                 %{id: ^site1_id}
                ]
-             } = Sites.list_with_invitations(user, %{}, filter_by_domain: "first")
-    end
-  end
-
-  describe "list/3" do
-    test "returns sites only, no invitations" do
-      user = insert(:user, email: "hello@example.com")
-
-      site1 = %{id: site_id1} = insert(:site, members: [user], domain: "one.example.com")
-      %{id: site_id2} = insert(:site, members: [user], domain: "two.example.com")
-      %{id: site_id4} = insert(:site, members: [user], domain: "four.example.com")
-
-      _rogue_site = insert(:site, domain: "rogue.example.com")
-
-      insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
-
-      insert(:site,
-        domain: "three.example.com",
-        invitations: [
-          build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-        ]
-      )
-
-      insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
-
-      insert(:invitation,
-        site: site1,
-        inviter: user,
-        email: "another@example.com"
-      )
+             } = Sites.list(user1, %{}, filter_by_domain: "first")
 
       assert %{
                entries: [
-                 %{id: ^site_id4, entry_type: "site"},
-                 %{id: ^site_id1, entry_type: "site"},
-                 %{id: ^site_id2, entry_type: "site"}
+                 %{id: ^site3_id},
+                 %{id: ^site2_id},
+                 %{id: ^site1_id}
                ]
-             } = Sites.list(user, %{})
+             } = Sites.list_with_invitations(user1, %{}, filter_by_domain: "first")
     end
 
     test "handles pagination correctly" do
-      user = insert(:user)
-      %{id: site_id1} = insert(:site, members: [user])
-      %{id: site_id2} = insert(:site, members: [user])
-      _rogue_site = insert(:site)
+      user1 = new_user()
+      user2 = new_user()
+      user3 = new_user()
 
-      insert(:site,
-        invitations: [
-          build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-        ]
-      )
+      site1 = new_site(owner: user1, domain: "one.example.com")
+      site2 = new_site(owner: user2, domain: "two.example.com")
+      site3 = new_site(domain: "three.example.com")
+      site4 = new_site(domain: "four.example.com")
+      site5 = new_site(owner: user3, domain: "five.example.com")
 
-      site4 = %{id: site_id4} = insert(:site, members: [user])
+      invite_guest(site2, user1, role: :editor, inviter: user2)
+      add_guest(site3, user: user1, role: :viewer)
+      add_guest(site4, user: user1, role: :editor)
 
-      {:ok, _} = Sites.toggle_pin(user, site4)
+      invite_transfer(site5, user1, inviter: user3)
+
+      {:ok, _} = Sites.toggle_pin(user1, site3)
+
+      site1_id = site1.id
+      site2_id = site2.id
+      site3_id = site3.id
+      site4_id = site4.id
+      site5_id = site5.id
 
       assert %{
-               entries: [
-                 %{id: ^site_id4},
-                 %{id: ^site_id1}
-               ],
+               entries: [%{id: ^site3_id}, %{id: ^site4_id}],
                page_number: 1,
                page_size: 2,
                total_entries: 3,
                total_pages: 2
-             } = Sites.list(user, %{"page_size" => 2})
+             } = Sites.list(user1, %{"page_size" => 2})
 
       assert %{
-               entries: [
-                 %{id: ^site_id2}
-               ],
+               entries: [%{id: ^site1_id}],
                page_number: 2,
                page_size: 2,
                total_entries: 3,
                total_pages: 2
-             } = Sites.list(user, %{"page" => 2, "page_size" => 2})
+             } = Sites.list(user1, %{"page_size" => 2, "page" => 2})
 
       assert %{
-               entries: [
-                 %{id: ^site_id4},
-                 %{id: ^site_id1}
-               ],
+               entries: [%{id: ^site3_id}, %{id: ^site4_id}, %{id: ^site1_id}],
                page_number: 1,
-               page_size: 2,
+               page_size: 3,
                total_entries: 3,
-               total_pages: 2
-             } = Sites.list(user, %{"page" => 1, "page_size" => 2})
-    end
-  end
-
-  describe "list_with_invitations/3" do
-    test "returns invitations and sites" do
-      user = insert(:user, email: "hello@example.com")
-
-      site1 = %{id: site_id1} = insert(:site, members: [user], domain: "one.example.com")
-      %{id: site_id2} = insert(:site, members: [user], domain: "two.example.com")
-      %{id: site_id4} = insert(:site, members: [user], domain: "four.example.com")
-
-      _rogue_site = insert(:site, domain: "rogue.example.com")
-
-      insert(:invitation, email: user.email, inviter: build(:user), role: :owner, site: site1)
-
-      %{id: site_id3} =
-        insert(:site,
-          domain: "three.example.com",
-          invitations: [
-            build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-          ]
-        )
-
-      insert(:invitation, email: "friend@example.com", inviter: user, role: :viewer, site: site1)
-
-      insert(:invitation,
-        site: site1,
-        inviter: user,
-        email: "another@example.com"
-      )
-
-      assert %{
-               entries: [
-                 %{id: ^site_id1, entry_type: "invitation"},
-                 %{id: ^site_id3, entry_type: "invitation"},
-                 %{id: ^site_id4, entry_type: "site"},
-                 %{id: ^site_id2, entry_type: "site"}
-               ]
-             } = Sites.list_with_invitations(user, %{})
-    end
-
-    test "handles pagination correctly" do
-      user = insert(:user)
-      %{id: site_id1} = insert(:site, members: [user])
-      %{id: site_id2} = insert(:site, members: [user])
-      _rogue_site = insert(:site)
-
-      %{id: site_id3} =
-        insert(:site,
-          invitations: [
-            build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-          ]
-        )
+               total_pages: 1
+             } = Sites.list(user1, %{"page_size" => 3})
 
+      # list_with_invitations
+      #
       assert %{
-               entries: [
-                 %{id: ^site_id3},
-                 %{id: ^site_id1}
-               ],
+               entries: [%{id: ^site5_id}, %{id: ^site2_id}],
                page_number: 1,
                page_size: 2,
-               total_entries: 3,
-               total_pages: 2
-             } = Sites.list_with_invitations(user, %{"page_size" => 2})
+               total_entries: 5,
+               total_pages: 3
+             } = Sites.list_with_invitations(user1, %{"page_size" => 2})
 
       assert %{
-               entries: [
-                 %{id: ^site_id2}
-               ],
+               entries: [%{id: ^site3_id}, %{id: ^site4_id}],
                page_number: 2,
                page_size: 2,
-               total_entries: 3,
-               total_pages: 2
-             } = Sites.list_with_invitations(user, %{"page" => 2, "page_size" => 2})
+               total_entries: 5,
+               total_pages: 3
+             } = Sites.list_with_invitations(user1, %{"page_size" => 2, "page" => 2})
 
       assert %{
-               entries: [
-                 %{id: ^site_id3},
-                 %{id: ^site_id1}
-               ],
-               page_number: 1,
+               entries: [%{id: ^site1_id}],
+               page_number: 3,
                page_size: 2,
-               total_entries: 3,
-               total_pages: 2
-             } = Sites.list_with_invitations(user, %{"page" => 1, "page_size" => 2})
+               total_entries: 5,
+               total_pages: 3
+             } = Sites.list_with_invitations(user1, %{"page_size" => 2, "page" => 3})
     end
   end
 
diff --git a/test/plausible_web/controllers/api/external_controller_test.exs b/test/plausible_web/controllers/api/external_controller_test.exs
index 0e33cbc4ee01..7578b5681281 100644
--- a/test/plausible_web/controllers/api/external_controller_test.exs
+++ b/test/plausible_web/controllers/api/external_controller_test.exs
@@ -256,6 +256,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
 
       assert response(conn, 202) == "ok"
       assert session.referrer_source == "Facebook"
+      assert session.click_id_param == ""
     end
 
     test "strips trailing slash from referrer", %{conn: conn, site: site} do
@@ -1340,6 +1341,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
 
       assert response(conn, 202) == "ok"
       assert session.channel == "Paid Search"
+      assert session.click_id_param == "gclid"
     end
 
     test "is not paid search when gclid is present on non-google referrer", %{
@@ -1362,6 +1364,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
 
       assert response(conn, 202) == "ok"
       assert session.channel == "Organic Search"
+      assert session.click_id_param == "gclid"
     end
 
     test "parses paid search channel based on msclkid", %{conn: conn, site: site} do
@@ -1381,6 +1384,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
 
       assert response(conn, 202) == "ok"
       assert session.channel == "Paid Search"
+      assert session.click_id_param == "msclkid"
     end
 
     test "is not paid search when msclkid is present on non-bing referrer", %{
@@ -1403,6 +1407,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
 
       assert response(conn, 202) == "ok"
       assert session.channel == "Organic Search"
+      assert session.click_id_param == "msclkid"
     end
 
     test "parses paid search channel based on utm_source and medium", %{conn: conn, site: site} do
@@ -1421,6 +1426,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
 
       assert response(conn, 202) == "ok"
       assert session.channel == "Paid Search"
+      assert session.click_id_param == ""
     end
 
     test "parses paid social channel based on referrer and medium", %{conn: conn, site: site} do
@@ -1944,6 +1950,1210 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
     end
   end
 
+  describe "custom source parsing rules" do
+    setup do
+      site = insert(:site)
+      {:ok, site: site}
+    end
+
+    test "threads is Threads", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=threads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Threads"
+      assert session.utm_source == "threads"
+      assert session.channel == "Organic Social"
+    end
+
+    test "ig is Instagram", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=ig",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Instagram"
+      assert session.utm_source == "ig"
+      assert session.channel == "Organic Social"
+    end
+
+    test "yt is Youtube", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=yt",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Youtube"
+      assert session.utm_source == "yt"
+      assert session.channel == "Organic Video"
+    end
+
+    test "yt-ads is Youtube paid", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=yt-ads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Youtube"
+      assert session.utm_source == "yt-ads"
+      assert session.channel == "Paid Video"
+    end
+
+    test "fb is Facebook", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=fb",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Facebook"
+      assert session.utm_source == "fb"
+      assert session.channel == "Organic Social"
+    end
+
+    test "fb-ads is Facebook", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=fb-ads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Facebook"
+      assert session.utm_source == "fb-ads"
+      assert session.channel == "Paid Social"
+    end
+
+    test "fbad is Facebook", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=fbad",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Facebook"
+      assert session.utm_source == "fbad"
+      assert session.channel == "Paid Social"
+    end
+
+    test "facebook-ads is Facebook", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=facebook-ads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Facebook"
+      assert session.utm_source == "facebook-ads"
+      assert session.channel == "Paid Social"
+    end
+
+    test "Reddit-ads is Reddit", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=Reddit-ads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Reddit"
+      assert session.utm_source == "Reddit-ads"
+      assert session.channel == "Paid Social"
+    end
+
+    test "google_ads is Google", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=google_ads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Google"
+      assert session.utm_source == "google_ads"
+      assert session.channel == "Paid Search"
+    end
+
+    test "Google-ads is Google", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=Google-ads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Google"
+      assert session.utm_source == "Google-ads"
+      assert session.channel == "Paid Search"
+    end
+
+    test "utm_source=Adwords is Google paid search", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=Adwords",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Google"
+      assert session.utm_source == "Adwords"
+      assert session.channel == "Paid Search"
+    end
+
+    test "twitter-ads is Twitter", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=twitter-ads",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Twitter"
+      assert session.utm_source == "twitter-ads"
+      assert session.channel == "Paid Social"
+    end
+
+    test "android-app://com.reddit.frontpage is Reddit", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "android-app://com.reddit.frontpage",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Reddit"
+      assert session.channel == "Organic Social"
+    end
+
+    test "perplexity.ai is Perplexity", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://perplexity.ai",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Perplexity"
+      assert session.channel == "Organic Search"
+    end
+
+    test "utm_source=perplexity is Perplexity", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=perplexity",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Perplexity"
+      assert session.channel == "Organic Search"
+    end
+
+    test "statics.teams.cdn.office.net is Microsoft Teams", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://statics.teams.cdn.office.net",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Microsoft Teams"
+      assert session.channel == "Organic Social"
+    end
+
+    test "wikipedia domain is resolved as Wikipedia", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://en.wikipedia.org",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Wikipedia"
+      assert session.channel == "Referral"
+    end
+
+    test "ntp.msn.com is Bing", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://ntp.msn.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Bing"
+      assert session.channel == "Organic Search"
+    end
+
+    test "search.brave.com is Brave", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://search.brave.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Brave"
+      assert session.channel == "Organic Search"
+    end
+
+    test "yandex.com.tr is Yandex", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://yandex.com.tr",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yandex"
+      assert session.channel == "Organic Search"
+    end
+
+    test "yandex.kz is Yandex", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://yandex.kz",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yandex"
+      assert session.channel == "Organic Search"
+    end
+
+    test "ya.ru is Yandex", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://ya.ru",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yandex"
+      assert session.channel == "Organic Search"
+    end
+
+    test "yandex.uz is Yandex", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://yandex.uz",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yandex"
+      assert session.channel == "Organic Search"
+    end
+
+    test "yandex.fr is Yandex", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://yandex.fr",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yandex"
+      assert session.channel == "Organic Search"
+    end
+
+    test "yandex.eu is Yandex", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://yandex.eu",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yandex"
+      assert session.channel == "Organic Search"
+    end
+
+    test "yandex.tm is Yandex", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://yandex.tm",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yandex"
+      assert session.channel == "Organic Search"
+    end
+
+    test "discord.com is Discord", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://discord.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Discord"
+      assert session.channel == "Organic Social"
+    end
+
+    test "discordapp.com is Discord", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://discordapp.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Discord"
+      assert session.channel == "Organic Social"
+    end
+
+    test "canary.discord.com is Discord", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://canary.discord.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Discord"
+      assert session.channel == "Organic Social"
+    end
+
+    test "ptb.discord.com is Discord", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://ptb.discord.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Discord"
+      assert session.channel == "Organic Social"
+    end
+
+    test "www.baidu.com is Baidu", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://baidu.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Baidu"
+      assert session.channel == "Organic Search"
+    end
+
+    test "t.me is Telegram", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://t.me",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Telegram"
+      assert session.channel == "Organic Social"
+    end
+
+    test "webk.telegram.org is Telegram", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://webk.telegram.org",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Telegram"
+      assert session.channel == "Organic Social"
+    end
+
+    test "sogou.com is Sogou", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://sogou.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Sogou"
+      assert session.channel == "Organic Search"
+    end
+
+    test "m.sogou.com is Sogou", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://m.sogou.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Sogou"
+      assert session.channel == "Organic Search"
+    end
+
+    test "wap.sogou.com is Sogou", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://wap.sogou.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Sogou"
+      assert session.channel == "Organic Search"
+    end
+
+    test "linktr.ee is Linktree", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://linktr.ee",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Linktree"
+      assert session.channel == "Referral"
+    end
+
+    test "linktree is Linktree", %{conn: conn, site: site} do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=linktree",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Linktree"
+      assert session.channel == "Referral"
+    end
+  end
+
+  describe "custom channel parsing rules" do
+    setup do
+      site = insert(:site)
+      {:ok, site: site}
+    end
+
+    test "hacker news is social channel", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://news.ycombinator.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Hacker News"
+      assert session.channel == "Organic Social"
+    end
+
+    test "yahoo is organic search", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://search.yahoo.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Yahoo!"
+      assert session.channel == "Organic Search"
+    end
+
+    test "gmail is email channel", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://mail.google.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Gmail"
+      assert session.channel == "Email"
+    end
+
+    test "utm_source=newsletter is email channel", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=Newsletter-UK",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Newsletter-UK"
+      assert session.channel == "Email"
+    end
+
+    test "temu.com is shopping channel", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://temu.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "temu.com"
+      assert session.channel == "Organic Shopping"
+    end
+
+    test "utm_source=Telegram is social channel", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?utm_source=Telegram",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Telegram"
+      assert session.channel == "Organic Social"
+    end
+
+    test "chatgpt.com is search channel", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://chatgpt.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "chatgpt.com"
+      assert session.channel == "Organic Search"
+    end
+
+    test "Slack is social channel", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://app.slack.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Slack"
+      assert session.channel == "Organic Social"
+    end
+
+    test "producthunt is social", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com?ref=producthunt",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "producthunt"
+      assert session.channel == "Organic Social"
+    end
+
+    test "github is social", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://github.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "GitHub"
+      assert session.channel == "Organic Social"
+    end
+
+    test "steamcommunity.com is social", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://steamcommunity.com",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "steamcommunity.com"
+      assert session.channel == "Organic Social"
+    end
+
+    test "Vkontakte is social", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://vkontakte.ru",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Vkontakte"
+      assert session.channel == "Organic Social"
+    end
+
+    test "Threads is social", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://threads.net",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Threads"
+      assert session.channel == "Organic Social"
+    end
+
+    test "Ecosia is search", %{
+      conn: conn,
+      site: site
+    } do
+      params = %{
+        name: "pageview",
+        url: "http://example.com",
+        referrer: "https://ecosia.org",
+        domain: site.domain
+      }
+
+      conn =
+        conn
+        |> put_req_header("user-agent", @user_agent)
+        |> post("/api/event", params)
+
+      session = get_created_session(site)
+
+      assert response(conn, 202) == "ok"
+      assert session.referrer_source == "Ecosia"
+      assert session.channel == "Organic Search"
+    end
+  end
+
   describe "user_id generation" do
     setup do
       site = insert(:site)
diff --git a/test/plausible_web/controllers/api/internal_controller_test.exs b/test/plausible_web/controllers/api/internal_controller_test.exs
index c156f22f3c28..579c926877b4 100644
--- a/test/plausible_web/controllers/api/internal_controller_test.exs
+++ b/test/plausible_web/controllers/api/internal_controller_test.exs
@@ -1,13 +1,14 @@
 defmodule PlausibleWeb.Api.InternalControllerTest do
   use PlausibleWeb.ConnCase, async: true
   use Plausible.Repo
+  use Plausible.Teams.Test
 
   describe "GET /api/sites" do
     setup [:create_user, :log_in]
 
     test "returns a list of site domains for the current user", %{conn: conn, user: user} do
-      site = insert(:site, members: [user])
-      site2 = insert(:site, members: [user])
+      site = new_site(owner: user)
+      site2 = new_site(owner: user)
       conn = get(conn, "/api/sites")
 
       %{"data" => sites} = json_response(conn, 200)
@@ -23,28 +24,15 @@ defmodule PlausibleWeb.Api.InternalControllerTest do
       inserted =
         for i <- 1..10 do
           i = to_string(i)
-
-          insert(:site,
-            members: [user],
-            domain: "site#{String.pad_leading(i, 2, "0")}.example.com"
-          )
+          new_site(owner: user, domain: "site#{String.pad_leading(i, 2, "0")}.example.com")
         end
 
-      _rogue = insert(:site, domain: "site00.example.com")
-
-      insert(:site,
-        domain: "friend.example.com",
-        invitations: [
-          build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-        ]
-      )
-
-      insert(:invitation,
-        email: "friend@example.com",
-        inviter: user,
-        role: :viewer,
-        site: hd(inserted)
-      )
+      _rogue = new_site(domain: "site00.example.com")
+
+      inviter = new_user()
+      site = new_site(owner: inviter, domain: "friend.example.com")
+      invite_guest(site, user, inviter: inviter, role: :viewer)
+      invite_guest(List.first(inserted), user, inviter: inviter, role: :viewer)
 
       {:ok, _} =
         Plausible.Sites.toggle_pin(user, Plausible.Sites.get_by_domain!("site07.example.com"))
diff --git a/test/plausible_web/controllers/api/stats_controller/browsers_test.exs b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs
index 0bd3d14cc573..def994fea0ea 100644
--- a/test/plausible_web/controllers/api/stats_controller/browsers_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs
@@ -229,6 +229,11 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
                  }
                }
              ]
+
+      assert json_response(conn, 200)["meta"] == %{
+               "date_range_label" => "7 Jan - 13 Jan 2021",
+               "comparison_date_range_label" => "31 Dec 2020 - 6 Jan 2021"
+             }
     end
 
     test "returns comparisons with limit", %{conn: conn, site: site} do
@@ -260,6 +265,11 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
                  }
                }
              ]
+
+      assert json_response(conn, 200)["meta"] == %{
+               "date_range_label" => "7 Jan - 13 Jan 2021",
+               "comparison_date_range_label" => "31 Dec 2020 - 6 Jan 2021"
+             }
     end
   end
 
diff --git a/test/plausible_web/controllers/api/stats_controller/sources_test.exs b/test/plausible_web/controllers/api/stats_controller/sources_test.exs
index 3071962eb603..10f4f8e8fba2 100644
--- a/test/plausible_web/controllers/api/stats_controller/sources_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/sources_test.exs
@@ -87,6 +87,10 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
                %{"name" => "Google", "visitors" => 2},
                %{"name" => "DuckDuckGo", "visitors" => 1}
              ]
+
+      assert json_response(conn, 200)["meta"] == %{
+               "date_range_label" => "1 Jan 2021"
+             }
     end
 
     test "returns top sources with :is_not filter on custom pageview props", %{
@@ -663,6 +667,11 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
                  }
                }
              ]
+
+      assert json_response(conn, 200)["meta"] == %{
+               "date_range_label" => "2 Jan 2021",
+               "comparison_date_range_label" => "1 Jan 2021"
+             }
     end
   end
 
diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs
index b67843715790..8bfb495180be 100644
--- a/test/plausible_web/controllers/auth_controller_test.exs
+++ b/test/plausible_web/controllers/auth_controller_test.exs
@@ -591,11 +591,14 @@ defmodule PlausibleWeb.AuthControllerTest do
         site_limit: 1
       )
 
+      {:ok, _team} = Plausible.Teams.get_or_create(user)
+
       conn = delete(conn, "/me")
       assert redirected_to(conn) == "/"
       assert Repo.reload(site) == nil
       assert Repo.reload(user) == nil
       assert Repo.all(Plausible.Billing.Subscription) == []
+      assert Repo.all(Plausible.Teams.Team) == []
     end
 
     test "deletes sites that the user owns", %{conn: conn, user: user, site: owner_site} do
diff --git a/test/plausible_web/controllers/invitation_controller_test.exs b/test/plausible_web/controllers/invitation_controller_test.exs
index 33ce92f9c7b8..a801882d671b 100644
--- a/test/plausible_web/controllers/invitation_controller_test.exs
+++ b/test/plausible_web/controllers/invitation_controller_test.exs
@@ -30,6 +30,56 @@ defmodule PlausibleWeb.Site.InvitationControllerTest do
       assert membership.role == :admin
     end
 
+    @tag :team
+    test "multiple invites per same team sync regression", %{conn: conn, user: user} do
+      inviter = insert(:user)
+      {:ok, team} = Plausible.Teams.get_or_create(inviter)
+      site1 = insert(:site, team: team, members: [inviter])
+      site2 = insert(:site, team: team, members: [inviter])
+
+      invitation1 =
+        insert(:invitation,
+          site_id: site1.id,
+          inviter: inviter,
+          email: user.email,
+          role: :viewer
+        )
+
+      invitation2 =
+        insert(:invitation,
+          site_id: site2.id,
+          inviter: inviter,
+          email: user.email,
+          role: :viewer
+        )
+
+      Plausible.Teams.Invitations.invite_sync(site1, invitation1)
+      Plausible.Teams.Invitations.invite_sync(site2, invitation2)
+
+      resp = post(conn, "/sites/invitations/#{invitation1.invitation_id}/accept")
+      assert redirected_to(resp, 302)
+
+      assert Repo.get_by(Plausible.Site.Membership, site_id: site1.id, user_id: user.id)
+      refute Repo.get_by(Plausible.Site.Membership, site_id: site2.id, user_id: user.id)
+
+      assert tm =
+               Repo.get_by(Plausible.Teams.Membership,
+                 team_id: team.id,
+                 user_id: user.id,
+                 role: :guest
+               )
+
+      assert Repo.get_by(Plausible.Teams.GuestMembership,
+               team_membership_id: tm.id,
+               site_id: site1.id
+             )
+
+      refute Repo.get_by(Plausible.Teams.GuestMembership,
+               team_membership_id: tm.id,
+               site_id: site2.id
+             )
+    end
+
     test "does not crash if clicked for the 2nd time in another tab", %{conn: conn, user: user} do
       site = insert(:site)
 
@@ -154,7 +204,11 @@ defmodule PlausibleWeb.Site.InvitationControllerTest do
 
   describe "DELETE /sites/:domain/invitations/:invitation_id" do
     test "removes the invitation", %{conn: conn, user: user} do
-      site = insert(:site, memberships: [build(:site_membership, user: user, role: :admin)])
+      site =
+        insert(:site,
+          members: [build(:user)],
+          memberships: [build(:site_membership, user: user, role: :admin)]
+        )
 
       invitation =
         insert(:invitation,
diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs
index e01e73a95243..d2b2b96a8a0d 100644
--- a/test/plausible_web/controllers/site_controller_test.exs
+++ b/test/plausible_web/controllers/site_controller_test.exs
@@ -3,6 +3,7 @@ defmodule PlausibleWeb.SiteControllerTest do
   use Plausible.Repo
   use Bamboo.Test
   use Oban.Testing, repo: Plausible.Repo
+  use Plausible.Teams.Test
 
   import ExUnit.CaptureLog
   import Mox
@@ -65,7 +66,7 @@ defmodule PlausibleWeb.SiteControllerTest do
       conn: conn,
       user: user
     } do
-      site = insert(:site, members: [user])
+      site = new_site(owner: user)
 
       # will be skipped
       populate_stats(site, [build(:pageview)])
@@ -80,21 +81,18 @@ defmodule PlausibleWeb.SiteControllerTest do
     end
 
     test "shows invitations for user by email address", %{conn: conn, user: user} do
-      site = insert(:site)
-      insert(:invitation, email: user.email, site_id: site.id, inviter: build(:user))
+      inviter = new_user()
+      site = new_site(owner: inviter)
+      invite_guest(site, user, inviter: inviter, role: :editor)
       conn = get(conn, "/sites")
 
       assert html_response(conn, 200) =~ site.domain
     end
 
     test "invitations are case insensitive", %{conn: conn, user: user} do
-      site = insert(:site)
-
-      insert(:invitation,
-        email: String.upcase(user.email),
-        site_id: site.id,
-        inviter: build(:user)
-      )
+      inviter = new_user()
+      site = new_site(owner: inviter)
+      invite_guest(site, String.upcase(user.email), inviter: inviter, role: :editor)
 
       conn = get(conn, "/sites")
 
@@ -103,8 +101,8 @@ defmodule PlausibleWeb.SiteControllerTest do
 
     test "paginates sites", %{conn: initial_conn, user: user} do
       for i <- 1..25 do
-        insert(:site,
-          members: [user],
+        new_site(
+          owner: user,
           domain: "paginated-site#{String.pad_leading("#{i}", 2, "0")}.example.com"
         )
       end
@@ -147,7 +145,7 @@ defmodule PlausibleWeb.SiteControllerTest do
       conn: initial_conn,
       user: user
     } do
-      insert(:site, members: [user])
+      new_site(owner: user)
 
       conn = get(initial_conn, "/sites")
       resp = html_response(conn, 200)
@@ -168,17 +166,14 @@ defmodule PlausibleWeb.SiteControllerTest do
     end
 
     test "filters by domain", %{conn: conn, user: user} do
-      _site1 = insert(:site, domain: "first.example.com", members: [user])
-      _site2 = insert(:site, domain: "second.example.com", members: [user])
-      _rogue_site = insert(:site)
-
-      _site3 =
-        insert(:site,
-          domain: "first-another.example.com",
-          invitations: [
-            build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
-          ]
-        )
+      _site1 = new_site(domain: "first.example.com", owner: user)
+      _site2 = new_site(domain: "second.example.com", owner: user)
+      _rogue_site = new_site()
+
+      inviter = new_user()
+
+      new_site(owner: inviter, domain: "first-another.example.com")
+      |> invite_guest(user, inviter: inviter, role: :viewer)
 
       conn = get(conn, "/sites", filter_text: "first")
       resp = html_response(conn, 200)
@@ -192,7 +187,7 @@ defmodule PlausibleWeb.SiteControllerTest do
       conn: conn,
       user: user
     } do
-      _site1 = insert(:site, domain: "example.com", members: [user])
+      _site1 = new_site(domain: "example.com", owner: user)
 
       conn = get(conn, "/sites", filter_text: "none")
       resp = html_response(conn, 200)
@@ -206,7 +201,7 @@ defmodule PlausibleWeb.SiteControllerTest do
       conn: conn,
       user: user
     } do
-      site = insert(:site, domain: "example.com", members: [user])
+      site = new_site(domain: "example.com", owner: user)
       conn = get(conn, "/sites")
       resp = html_response(conn, 200)
 
@@ -217,11 +212,8 @@ defmodule PlausibleWeb.SiteControllerTest do
       conn: conn,
       user: user
     } do
-      site =
-        insert(:site,
-          domain: "example.com",
-          memberships: [build(:site_membership, user: user, role: :viewer)]
-        )
+      site = new_site(domain: "example.com")
+      add_guest(site, user: user, role: :viewer)
 
       conn = get(conn, "/sites")
       resp = html_response(conn, 200)
@@ -416,6 +408,12 @@ defmodule PlausibleWeb.SiteControllerTest do
 
       assert html_response(conn, 200) =~
                "This domain cannot be registered. Perhaps one of your colleagues registered it?"
+
+      if Plausible.ee?() do
+        assert html_response(conn, 200) =~ "support@plausible.io"
+      else
+        refute html_response(conn, 200) =~ "support@plausible.io"
+      end
     end
 
     test "renders form again when domain was changed from elsewhere", %{conn: conn} do
@@ -433,6 +431,12 @@ defmodule PlausibleWeb.SiteControllerTest do
 
       assert html_response(conn, 200) =~
                "This domain cannot be registered. Perhaps one of your colleagues registered it?"
+
+      if Plausible.ee?() do
+        assert html_response(conn, 200) =~ "support@plausible.io"
+      else
+        refute html_response(conn, 200) =~ "support@plausible.io"
+      end
     end
 
     test "allows creating the site if domain was changed by the owner", %{
diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs
index f46c714de9a2..245cae47525e 100644
--- a/test/plausible_web/live/funnel_settings_test.exs
+++ b/test/plausible_web/live/funnel_settings_test.exs
@@ -10,6 +10,18 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
     describe "GET /:domain/settings/funnels" do
       setup [:create_user, :log_in, :create_site]
 
+      @tag :ee_only
+      test "premium feature notice renders", %{conn: conn, site: site, user: user} do
+        user
+        |> Plausible.Auth.User.end_trial()
+        |> Plausible.Repo.update!()
+
+        conn = get(conn, "/#{site.domain}/settings/funnels")
+        resp = conn |> html_response(200) |> text()
+
+        assert resp =~ "please upgrade your subscription"
+      end
+
       test "lists funnels for the site and renders help link", %{conn: conn, site: site} do
         {:ok, _} = setup_funnels(site)
         conn = get(conn, "/#{site.domain}/settings/funnels")
@@ -18,6 +30,8 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
         assert resp =~ "Compose Goals into Funnels"
         assert resp =~ "From blog to signup"
         assert resp =~ "From signup to blog"
+        refute resp =~ "Your account does not have access"
+        refute resp =~ "please upgrade your subscription"
         assert element_exists?(resp, "a[href=\"https://plausible.io/docs/funnel-analysis\"]")
       end
 
diff --git a/test/plausible_web/live/props_settings_test.exs b/test/plausible_web/live/props_settings_test.exs
index 79b578d875c3..8154e38a7b6b 100644
--- a/test/plausible_web/live/props_settings_test.exs
+++ b/test/plausible_web/live/props_settings_test.exs
@@ -6,6 +6,18 @@ defmodule PlausibleWeb.Live.PropsSettingsTest do
   describe "GET /:domain/settings/properties" do
     setup [:create_user, :log_in, :create_site]
 
+    @tag :ee_only
+    test "premium feature notice renders", %{conn: conn, site: site, user: user} do
+      user
+      |> Plausible.Auth.User.end_trial()
+      |> Plausible.Repo.update!()
+
+      conn = get(conn, "/#{site.domain}/settings/properties")
+      resp = conn |> html_response(200) |> text()
+
+      assert resp =~ "please upgrade your subscription"
+    end
+
     test "lists props for the site and renders links", %{conn: conn, site: site} do
       {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"])
       conn = get(conn, "/#{site.domain}/settings/properties")
@@ -21,6 +33,7 @@ defmodule PlausibleWeb.Live.PropsSettingsTest do
       assert resp =~ "amount"
       assert resp =~ "logged_in"
       assert resp =~ "is_customer"
+      refute resp =~ "please upgrade your subscription"
     end
 
     test "lists props with disallow actions", %{conn: conn, site: site} do
diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs
index 47bae8e44c4e..61c1af04434c 100644
--- a/test/plausible_web/live/sites_test.exs
+++ b/test/plausible_web/live/sites_test.exs
@@ -1,5 +1,6 @@
 defmodule PlausibleWeb.Live.SitesTest do
   use PlausibleWeb.ConnCase, async: true
+  use Plausible.Teams.Test
 
   import Phoenix.LiveViewTest
   import Plausible.Test.Support.HTML
@@ -15,20 +16,31 @@ defmodule PlausibleWeb.Live.SitesTest do
       assert text(html) =~ "You don't have any sites yet"
     end
 
+    test "renders metadata for invitation", %{
+      conn: conn,
+      user: user
+    } do
+      inviter = new_user()
+      site = new_site(owner: inviter)
+
+      invitation = invite_guest(site, user, inviter: inviter, role: :viewer)
+
+      {:ok, _lv, html} = live(conn, "/sites")
+
+      invitation_data = get_invitation_data(html)
+
+      assert get_in(invitation_data, ["invitations", invitation.invitation_id, "invitation"])
+    end
+
     @tag :ee_only
     test "renders ownership transfer invitation for a case with no plan", %{
       conn: conn,
       user: user
     } do
-      site = insert(:site)
+      inviter = new_user()
+      site = new_site(owner: inviter)
 
-      invitation =
-        insert(:invitation,
-          site_id: site.id,
-          inviter: build(:user),
-          email: user.email,
-          role: :owner
-        )
+      invitation = invite_transfer(site, user, inviter: inviter)
 
       {:ok, _lv, html} = live(conn, "/sites")
 
@@ -42,20 +54,14 @@ defmodule PlausibleWeb.Live.SitesTest do
       conn: conn,
       user: user
     } do
-      site = insert(:site)
+      inviter = new_user()
+      site = new_site(owner: inviter)
 
-      insert(:growth_subscription, user: user)
+      invitation = invite_transfer(site, user, inviter: inviter)
 
       # fill site quota
-      insert_list(10, :site, members: [user])
-
-      invitation =
-        insert(:invitation,
-          site_id: site.id,
-          inviter: build(:user),
-          email: user.email,
-          role: :owner
-        )
+      insert(:growth_subscription, user: user)
+      for _ <- 1..10, do: new_site(owner: user)
 
       {:ok, _lv, html} = live(conn, "/sites")
 
@@ -70,18 +76,12 @@ defmodule PlausibleWeb.Live.SitesTest do
       conn: conn,
       user: user
     } do
-      site = insert(:site, allowed_event_props: ["dummy"])
+      inviter = new_user()
+      site = new_site(owner: inviter, allowed_event_props: ["dummy"])
 
+      invitation = invite_transfer(site, user, inviter: inviter)
       insert(:growth_subscription, user: user)
 
-      invitation =
-        insert(:invitation,
-          site_id: site.id,
-          inviter: build(:user),
-          email: user.email,
-          role: :owner
-        )
-
       {:ok, _lv, html} = live(conn, "/sites")
 
       invitation_data = get_invitation_data(html)
@@ -91,7 +91,7 @@ defmodule PlausibleWeb.Live.SitesTest do
     end
 
     test "renders 24h visitors correctly", %{conn: conn, user: user} do
-      site = insert(:site, members: [user])
+      site = new_site(owner: user)
 
       populate_stats(site, [build(:pageview), build(:pageview), build(:pageview)])
 
@@ -103,9 +103,9 @@ defmodule PlausibleWeb.Live.SitesTest do
     end
 
     test "filters by domain", %{conn: conn, user: user} do
-      _site1 = insert(:site, domain: "first.example.com", members: [user])
-      _site2 = insert(:site, domain: "second.example.com", members: [user])
-      _site3 = insert(:site, domain: "first-another.example.com", members: [user])
+      _site1 = new_site(domain: "first.example.com", owner: user)
+      _site2 = new_site(domain: "second.example.com", owner: user)
+      _site3 = new_site(domain: "first-another.example.com", owner: user)
 
       {:ok, lv, _html} = live(conn, "/sites")
 
@@ -118,9 +118,9 @@ defmodule PlausibleWeb.Live.SitesTest do
     end
 
     test "filtering plays well with pagination", %{conn: conn, user: user} do
-      _site1 = insert(:site, domain: "first.another.example.com", members: [user])
-      _site2 = insert(:site, domain: "second.example.com", members: [user])
-      _site3 = insert(:site, domain: "third.another.example.com", members: [user])
+      _site1 = new_site(domain: "first.another.example.com", owner: user)
+      _site2 = new_site(domain: "second.example.com", owner: user)
+      _site3 = new_site(domain: "third.another.example.com", owner: user)
 
       {:ok, lv, html} = live(conn, "/sites?page_size=2")
 
@@ -143,7 +143,7 @@ defmodule PlausibleWeb.Live.SitesTest do
 
   describe "pinning" do
     test "renders pin site option when site not pinned", %{conn: conn, user: user} do
-      site = insert(:site, members: [user])
+      site = new_site(owner: user)
 
       {:ok, _lv, html} = live(conn, "/sites")
 
@@ -154,7 +154,7 @@ defmodule PlausibleWeb.Live.SitesTest do
     end
 
     test "site state changes when pin toggled", %{conn: conn, user: user} do
-      site = insert(:site, members: [user])
+      site = new_site(owner: user)
 
       {:ok, lv, _html} = live(conn, "/sites")
 
@@ -181,11 +181,11 @@ defmodule PlausibleWeb.Live.SitesTest do
 
     test "shows error when pins limit hit", %{conn: conn, user: user} do
       for _ <- 1..9 do
-        site = insert(:site, members: [user])
+        site = new_site(owner: user)
         assert {:ok, _} = Plausible.Sites.toggle_pin(user, site)
       end
 
-      site = insert(:site, members: [user])
+      site = new_site(owner: user)
 
       {:ok, lv, _html} = live(conn, "/sites")
 
@@ -200,7 +200,7 @@ defmodule PlausibleWeb.Live.SitesTest do
     end
 
     test "does not allow pinning site user doesn't have access to", %{conn: conn, user: user} do
-      site = insert(:site)
+      site = new_site()
 
       {:ok, lv, _html} = live(conn, "/sites")
 
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 5cc2de06cba9..15b986e2307a 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -38,6 +38,13 @@ defmodule Plausible.Factory do
     }
   end
 
+  def site_transfer_factory do
+    %Plausible.Teams.SiteTransfer{
+      transfer_id: Nanoid.generate(),
+      email: sequence(:email, &"email-#{&1}@example.com")
+    }
+  end
+
   def user_factory(attrs) do
     pw = Map.get(attrs, :password, "password")
 
diff --git a/test/support/teams/test.ex b/test/support/teams/test.ex
index 983ea40dfd1e..f1f2b8fa7abc 100644
--- a/test/support/teams/test.ex
+++ b/test/support/teams/test.ex
@@ -3,9 +3,124 @@ defmodule Plausible.Teams.Test do
   Convenience assertions for teams schema transition
   """
   alias Plausible.Repo
+  alias Plausible.Teams
+
+  import Ecto.Query
 
   use ExUnit.CaseTemplate
 
+  import Plausible.Factory
+
+  def new_site(args \\ []) do
+    args =
+      if user = args[:owner] do
+        {:ok, team} = Teams.get_or_create(user)
+
+        args
+        |> Keyword.put(:team, team)
+        |> Keyword.put(:members, [user])
+      else
+        user = new_user()
+        {:ok, team} = Teams.get_or_create(user)
+
+        args
+        |> Keyword.put(:team, team)
+        |> Keyword.put(:members, [user])
+      end
+
+    :site
+    |> insert(args)
+    |> Repo.preload(:memberships)
+  end
+
+  def new_team() do
+    new_user()
+    |> Map.fetch!(:team_memberships)
+    |> List.first()
+  end
+
+  def new_user(args \\ []) do
+    user = insert(:user, args)
+    {:ok, _team} = Teams.get_or_create(user)
+    Repo.preload(user, :team_memberships)
+  end
+
+  def add_guest(site, args \\ []) do
+    user = Keyword.get(args, :user, new_user())
+    role = Keyword.fetch!(args, :role)
+    team = Repo.preload(site, :team).team
+
+    insert(:site_membership, user: user, role: translate_role_to_old_model(role), site: site)
+
+    team_membership = insert(:team_membership, team: team, user: user, role: :guest)
+    insert(:guest_membership, team_membership: team_membership, site: site, role: role)
+
+    user |> Repo.preload([:site_memberships, :team_memberships])
+  end
+
+  def invite_guest(site, invitee_or_email, args \\ []) when not is_nil(invitee_or_email) do
+    role = Keyword.fetch!(args, :role)
+    inviter = Keyword.fetch!(args, :inviter)
+    team = Repo.preload(site, :team).team
+
+    email =
+      case invitee_or_email do
+        %{email: email} -> email
+        email when is_binary(email) -> email
+      end
+
+    old_model_invitation =
+      insert(:invitation,
+        email: email,
+        inviter: inviter,
+        role: translate_role_to_old_model(role),
+        site: site
+      )
+
+    team_invitation =
+      insert(:team_invitation,
+        invitation_id: old_model_invitation.invitation_id,
+        team: team,
+        email: email,
+        inviter: inviter,
+        role: :guest
+      )
+
+    insert(:guest_invitation, team_invitation: team_invitation, site: site, role: role)
+
+    old_model_invitation
+  end
+
+  def invite_transfer(site, invitee, args \\ []) do
+    inviter = Keyword.fetch!(args, :inviter)
+
+    old_model_invitation =
+      insert(:invitation, email: invitee.email, inviter: inviter, role: :owner, site: site)
+
+    insert(:site_transfer,
+      transfer_id: old_model_invitation.invitation_id,
+      email: invitee.email,
+      site: site,
+      initiator: inviter
+    )
+
+    old_model_invitation
+  end
+
+  def revoke_membership(site, user) do
+    Repo.delete_all(
+      from sm in Plausible.Site.Membership,
+        where: sm.user_id == ^user.id and sm.site_id == ^site.id
+    )
+
+    Repo.delete_all(
+      from tm in Plausible.Teams.Membership,
+        where: tm.user_id == ^user.id and tm.team_id == ^site.team.id
+    )
+
+    user |> Repo.preload([:site_memberships, :team_memberships])
+  end
+
   defmacro __using__(_) do
     quote do
       import Plausible.Teams.Test
@@ -85,4 +200,7 @@ defmodule Plausible.Teams.Test do
              role: role
            )
   end
+
+  defp translate_role_to_old_model(:editor), do: :admin
+  defp translate_role_to_old_model(role), do: role
 end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 3906208d9329..d405644c564e 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -6,10 +6,13 @@ end
 Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
 Application.ensure_all_started(:double)
 
-# Temporary flag to test `experimental_reduced_joins` flag on all tests.
-if System.get_env("TEST_EXPERIMENTAL_REDUCED_JOINS") == "1" do
+# Temporary flag to test `read_team_schemas` and `experimental_reduced_joins`
+# flags on all tests.
+if System.get_env("TEST_READ_TEAM_SCHEMAS_AND_EXPERIMENTAL_REDUCED_JOINS") == "1" do
+  FunWithFlags.enable(:read_team_schemas)
   FunWithFlags.enable(:experimental_reduced_joins)
 else
+  FunWithFlags.disable(:read_team_schemas)
   FunWithFlags.disable(:experimental_reduced_joins)
 end
 
diff --git a/test/workers/clean_invitations_test.exs b/test/workers/clean_invitations_test.exs
index d354b98af3ff..ff0ee03cac87 100644
--- a/test/workers/clean_invitations_test.exs
+++ b/test/workers/clean_invitations_test.exs
@@ -2,7 +2,7 @@ defmodule Plausible.Workers.CleanInvitationsTest do
   use Plausible.DataCase
   alias Plausible.Workers.CleanInvitations
 
-  test "cleans invitation that is more than 48h old" do
+  test "cleans invitations and transfers that are more than 48h old" do
     now = NaiveDateTime.utc_now(:second)
 
     insert(:invitation,
@@ -11,12 +11,38 @@ defmodule Plausible.Workers.CleanInvitationsTest do
       inviter: build(:user)
     )
 
+    site = insert(:site, team: build(:team))
+
+    team_invitation =
+      insert(:team_invitation,
+        inserted_at: NaiveDateTime.shift(now, hour: -49),
+        team: site.team,
+        inviter: build(:user),
+        role: :guest
+      )
+
+    insert(:guest_invitation,
+      inserted_at: NaiveDateTime.shift(now, hour: -49),
+      team_invitation: team_invitation,
+      site: site,
+      role: :viewer
+    )
+
+    insert(:site_transfer,
+      inserted_at: NaiveDateTime.shift(now, hour: -49),
+      site: site,
+      initiator: build(:user)
+    )
+
     CleanInvitations.perform(nil)
 
     refute Repo.exists?(Plausible.Auth.Invitation)
+    refute Repo.exists?(Plausible.Teams.Invitation)
+    refute Repo.exists?(Plausible.Teams.GuestInvitation)
+    refute Repo.exists?(Plausible.Teams.SiteTransfer)
   end
 
-  test "does not clean invitation that is less than 48h old" do
+  test "does not clean invitations and transfers that are less than 48h old" do
     now = NaiveDateTime.utc_now(:second)
 
     insert(:invitation,
@@ -25,6 +51,29 @@ defmodule Plausible.Workers.CleanInvitationsTest do
       inviter: build(:user)
     )
 
+    site = insert(:site, team: build(:team))
+
+    team_invitation =
+      insert(:team_invitation,
+        inserted_at: NaiveDateTime.shift(now, hour: -47),
+        team: site.team,
+        inviter: build(:user),
+        role: :guest
+      )
+
+    insert(:guest_invitation,
+      inserted_at: NaiveDateTime.shift(now, hour: -47),
+      team_invitation: team_invitation,
+      site: site,
+      role: :viewer
+    )
+
+    insert(:site_transfer,
+      inserted_at: NaiveDateTime.shift(now, hour: -47),
+      site: site,
+      initiator: build(:user)
+    )
+
     CleanInvitations.perform(nil)
 
     assert Repo.exists?(Plausible.Auth.Invitation)
diff --git a/tracker/compile.js b/tracker/compile.js
index c4c5add65757..b707b3a976b7 100644
--- a/tracker/compile.js
+++ b/tracker/compile.js
@@ -3,6 +3,12 @@ const fs = require('fs')
 const path = require('path')
 const Handlebars = require("handlebars");
 const g = require("generatorics");
+const { canSkipCompile } = require("./dev-compile/can-skip-compile");
+
+if (process.env.NODE_ENV === 'dev' && canSkipCompile()) {
+  console.info('COMPILATION SKIPPED: No changes detected in tracker dependencies')
+  process.exit(0)
+}
 
 Handlebars.registerHelper('any', function (...args) {
   return args.slice(0, -1).some(Boolean)
@@ -35,4 +41,4 @@ compilefile(relPath('src/p.js'), relPath('../priv/tracker/js/p.js'))
 variants.map(variant => {
   const options = variant.map(variant => variant.replace('-', '_')).reduce((acc, curr) => (acc[curr] = true, acc), {})
   compilefile(relPath('src/plausible.js'), relPath(`../priv/tracker/js/plausible.${variant.join('.')}.js`), options)
-})
+})
\ No newline at end of file
diff --git a/tracker/dev-compile/can-skip-compile.js b/tracker/dev-compile/can-skip-compile.js
new file mode 100644
index 000000000000..9a955087d649
--- /dev/null
+++ b/tracker/dev-compile/can-skip-compile.js
@@ -0,0 +1,52 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const LAST_HASH_FILEPATH = path.join(__dirname, './last-hash.txt')
+
+// Re-compilation is only required if any of these files have been changed. 
+const COMPILE_DEPENDENCIES = [
+  path.join(__dirname, '../compile.js'),
+  path.join(__dirname, '../src/plausible.js'),
+  path.join(__dirname, '../src/customEvents.js')
+]
+
+function currentHash() {
+  const combinedHash = crypto.createHash('sha256');
+
+  for (const filePath of COMPILE_DEPENDENCIES) {
+    try {
+      const fileContent = fs.readFileSync(filePath);
+      const fileHash = crypto.createHash('sha256').update(fileContent).digest();
+      combinedHash.update(fileHash);
+    } catch (error) {
+      throw new Error(`Failed to read or hash ${filePath}: ${error.message}`);
+    }
+  }
+
+  return combinedHash.digest('hex');
+}
+
+function lastHash() {
+  if (fs.existsSync(LAST_HASH_FILEPATH)) {
+    return fs.readFileSync(LAST_HASH_FILEPATH).toString()
+  }
+}
+
+/**
+ * Returns a boolean indicating whether the tracker compilation can be skipped.
+ * Every time this function gets executed, the hash of the tracker dependencies
+ * will be updated. Compilation can be skipped if the hash hasn't changed since
+ * the last execution.
+ */
+exports.canSkipCompile = function() {
+  const current = currentHash()
+  const last = lastHash()
+
+  if (current === last) {
+    return true
+  } else {
+    fs.writeFileSync(LAST_HASH_FILEPATH, current)
+    return false
+  }
+}
\ No newline at end of file
diff --git a/tracker/package-lock.json b/tracker/package-lock.json
index 94e432413b5d..975fe9bb734c 100644
--- a/tracker/package-lock.json
+++ b/tracker/package-lock.json
@@ -6,13 +6,14 @@
     "": {
       "license": "MIT",
       "dependencies": {
-        "@playwright/test": "^1.41.1",
         "express": "^4.18.1",
         "generatorics": "^1.1.0",
         "handlebars": "^4.7.8",
         "uglify-js": "^3.9.4"
       },
       "devDependencies": {
+        "@playwright/test": "^1.48.1",
+        "@types/node": "^22.7.7",
         "eslint": "^8.56.0",
         "eslint-plugin-playwright": "^0.20.0"
       }
@@ -151,17 +152,27 @@
       }
     },
     "node_modules/@playwright/test": {
-      "version": "1.41.1",
-      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.1.tgz",
-      "integrity": "sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==",
+      "version": "1.48.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz",
+      "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==",
+      "dev": true,
       "dependencies": {
-        "playwright": "1.41.1"
+        "playwright": "1.48.1"
       },
       "bin": {
         "playwright": "cli.js"
       },
       "engines": {
-        "node": ">=16"
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.7.7",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.7.tgz",
+      "integrity": "sha512-SRxCrrg9CL/y54aiMCG3edPKdprgMVGDXjA3gB8UmmBW5TcXzRUYAh8EWzTnSJFAd1rgImPELza+A3bJ+qxz8Q==",
+      "dev": true,
+      "dependencies": {
+        "undici-types": "~6.19.2"
       }
     },
     "node_modules/@ungap/structured-clone": {
@@ -855,6 +866,7 @@
       "version": "2.3.2",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
       "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
       "hasInstallScript": true,
       "optional": true,
       "os": [
@@ -1395,31 +1407,33 @@
       "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
     },
     "node_modules/playwright": {
-      "version": "1.41.1",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.1.tgz",
-      "integrity": "sha512-gdZAWG97oUnbBdRL3GuBvX3nDDmUOuqzV/D24dytqlKt+eI5KbwusluZRGljx1YoJKZ2NRPaeWiFTeGZO7SosQ==",
+      "version": "1.48.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz",
+      "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==",
+      "dev": true,
       "dependencies": {
-        "playwright-core": "1.41.1"
+        "playwright-core": "1.48.1"
       },
       "bin": {
         "playwright": "cli.js"
       },
       "engines": {
-        "node": ">=16"
+        "node": ">=18"
       },
       "optionalDependencies": {
         "fsevents": "2.3.2"
       }
     },
     "node_modules/playwright-core": {
-      "version": "1.41.1",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.1.tgz",
-      "integrity": "sha512-/KPO5DzXSMlxSX77wy+HihKGOunh3hqndhqeo/nMxfigiKzogn8kfL0ZBDu0L1RKgan5XHCPmn6zXd2NUJgjhg==",
+      "version": "1.48.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz",
+      "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==",
+      "dev": true,
       "bin": {
         "playwright-core": "cli.js"
       },
       "engines": {
-        "node": ">=16"
+        "node": ">=18"
       }
     },
     "node_modules/prelude-ls": {
@@ -1796,6 +1810,12 @@
         "node": ">=0.8.0"
       }
     },
+    "node_modules/undici-types": {
+      "version": "6.19.8",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+      "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+      "dev": true
+    },
     "node_modules/unpipe": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/tracker/package.json b/tracker/package.json
index 35c3f40bff82..ef202664c872 100644
--- a/tracker/package.json
+++ b/tracker/package.json
@@ -1,18 +1,21 @@
 {
   "scripts": {
     "deploy": "node compile.js",
-    "test": "npm run deploy && npx playwright test --config=./test/support/playwright.config.js",
+    "test": "npm run deploy && npx playwright test",
+    "test:local": "NODE_ENV=dev npm run deploy && npx playwright test",
+    "report-sizes": "node report-sizes.js",
     "start": "node test/support/server.js"
   },
   "license": "MIT",
   "dependencies": {
-    "@playwright/test": "^1.41.1",
     "express": "^4.18.1",
     "generatorics": "^1.1.0",
     "handlebars": "^4.7.8",
     "uglify-js": "^3.9.4"
   },
   "devDependencies": {
+    "@playwright/test": "^1.48.1",
+    "@types/node": "^22.7.7",
     "eslint": "^8.56.0",
     "eslint-plugin-playwright": "^0.20.0"
   }
diff --git a/tracker/test/support/playwright.config.js b/tracker/playwright.config.js
similarity index 82%
rename from tracker/test/support/playwright.config.js
rename to tracker/playwright.config.js
index 67c91de05868..21dc56f49ebe 100644
--- a/tracker/test/support/playwright.config.js
+++ b/tracker/playwright.config.js
@@ -1,10 +1,11 @@
-const { devices } = require('@playwright/test');
+// @ts-check
+const { defineConfig, devices } = require('@playwright/test');
 
 /**
  * @see https://playwright.dev/docs/test-configuration
  */
-module.exports = {
-  testDir: '../',
+module.exports = defineConfig({
+  testDir: './test',
   timeout: 60 * 1000,
   fullyParallel: true,
   /* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -33,5 +34,7 @@ module.exports = {
   webServer: {
     command: 'npm run start',
     port: 3000,
+    reuseExistingServer: !process.env.CI
   },
-}
+});
+
diff --git a/tracker/report-sizes.js b/tracker/report-sizes.js
new file mode 100644
index 000000000000..01f63ab43452
--- /dev/null
+++ b/tracker/report-sizes.js
@@ -0,0 +1,28 @@
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const PrivTrackerDir = '../priv/tracker/js/';
+
+const toReport = [
+  'plausible.js',
+  'plausible.pageleave.js',
+  'plausible.manual.pageleave.js',
+  'plausible.hash.pageleave.js'
+];
+
+const results = [];
+
+toReport.forEach((filename) => {
+  const filePath = path.join(PrivTrackerDir, filename);
+  if (fs.statSync(filePath).isFile()) {
+    results.push({
+      'Filename': filename,
+      'Real Size (Bytes)': fs.statSync(filePath).size,
+      'Gzipped Size (Bytes)': execSync(`gzip -c -9 "${filePath}"`).length,
+      'Brotli Size (Bytes)': execSync(`brotli -c -q 11 "${filePath}"`).length
+    });
+  }
+});
+
+console.table(results)
diff --git a/tracker/test/fixtures/pageleave-hash-exclusions.html b/tracker/test/fixtures/pageleave-hash-exclusions.html
new file mode 100644
index 000000000000..aecca154e936
--- /dev/null
+++ b/tracker/test/fixtures/pageleave-hash-exclusions.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta http-equiv="X-UA-Compatible" content="ie=edge">
+  <title>Plausible Playwright tests</title>
+  <script defer data-exclude='/*#*/hash/**/ignored' src="/tracker/js/plausible.exclusions.hash.local.pageleave.js"></script>
+</head>
+
+<body>
+  <a id="ignored-hash-link" href="#this/hash/should/be/ignored">Ignored Hash Link</a>
+  <a id="hash-link-1" href="#hash1">Hash Link 1</a>
+  <a id="hash-link-2" href="#hash2">Hash Link 2</a>
+</body>
+
+</html>
diff --git a/tracker/test/fixtures/pageleave-hash.html b/tracker/test/fixtures/pageleave-hash.html
new file mode 100644
index 000000000000..e164362ccf29
--- /dev/null
+++ b/tracker/test/fixtures/pageleave-hash.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta http-equiv="X-UA-Compatible" content="ie=edge">
+  <title>Plausible Playwright tests</title>
+  <script defer src="/tracker/js/plausible.hash.local.pageleave.js"></script>
+  <script>
+
+  </script>
+</head>
+
+<body>
+  <a id="hash-nav" href="#some-hash">Hash link</a>
+</body>
+
+</html>
diff --git a/tracker/test/fixtures/pageleave-manual.html b/tracker/test/fixtures/pageleave-manual.html
new file mode 100644
index 000000000000..6fa3fd5423ce
--- /dev/null
+++ b/tracker/test/fixtures/pageleave-manual.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta http-equiv="X-UA-Compatible" content="ie=edge">
+  <title>Plausible Playwright tests</title>
+  <script defer src="/tracker/js/plausible.local.manual.pageleave.js"></script>
+  <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
+</head>
+
+<body>
+  <a id="navigate-away" href="/manual.html">Navigate away</a>
+
+  <button id="pageview-trigger-custom-url">
+    Triggers a pageview with custom URL
+  </button>
+
+  <script>
+    document.addEventListener('click', (e) => {
+      if (e.target.id === 'pageview-trigger-custom-url') {
+        window.plausible('pageview', {u: 'https://example.com/custom/location'})
+      }
+    })
+  </script>
+</body>
+
+</html>
diff --git a/tracker/test/fixtures/pageleave.html b/tracker/test/fixtures/pageleave.html
new file mode 100644
index 000000000000..bb94b92ee2b6
--- /dev/null
+++ b/tracker/test/fixtures/pageleave.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta http-equiv="X-UA-Compatible" content="ie=edge">
+  <title>Plausible Playwright tests</title>
+  <script defer src="/tracker/js/plausible.local.pageleave.js"></script>
+</head>
+
+<body>
+  <a id="navigate-away" href="/manual.html">Navigate away</a>
+
+  <button id="history-nav">Navigate with history</button>
+
+  <script>
+    document.getElementById('history-nav').addEventListener('click', () => {
+      history.pushState({ page: 2 }, 'Another Page', '/another-page');
+    });
+  </script>
+</body>
+
+</html>
diff --git a/tracker/test/manual.spec.js b/tracker/test/manual.spec.js
index 0815d7276682..f67a7fccb153 100644
--- a/tracker/test/manual.spec.js
+++ b/tracker/test/manual.spec.js
@@ -1,35 +1,34 @@
-const { mockRequest } = require('./support/test-utils')
-const { expect, test } = require('@playwright/test');
+/* eslint-disable playwright/expect-expect */
+const { clickPageElementAndExpectEventRequests } = require('./support/test-utils')
+const { test } = require('@playwright/test');
 const { LOCAL_SERVER_ADDR } = require('./support/server');
 
-async function clickPageElementAndExpectEventRequest(page, buttonId, expectedBodyParams) {
-  const plausibleRequestMock = mockRequest(page, '/api/event')
-  await page.click(buttonId)
-  const plausibleRequest = await plausibleRequestMock;
-
-  expect(plausibleRequest.url()).toContain('/api/event')
-
-  const body = plausibleRequest.postDataJSON()
-
-  Object.keys(expectedBodyParams).forEach((key) => {
-    expect(body[key]).toEqual(expectedBodyParams[key])
-  })
-}
-
 test.describe('manual extension', () => {
   test('can trigger custom events with and without a custom URL if pageview was sent with the default URL', async ({ page }) => {
     await page.goto('/manual.html');
 
-    await clickPageElementAndExpectEventRequest(page, '#pageview-trigger', {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html`})
-    await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger', {n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`})
-    await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger-custom-url', {n: 'CustomEvent', u: `https://example.com/custom/location`})
+    await clickPageElementAndExpectEventRequests(page, '#pageview-trigger', [
+      {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html`}
+    ])
+    await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger', [
+      {n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`}
+    ])
+    await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [
+      {n: 'CustomEvent', u: `https://example.com/custom/location`}
+    ])
   });
 
   test('can trigger custom events with and without a custom URL if pageview was sent with a custom URL', async ({ page }) => {
     await page.goto('/manual.html');
 
-    await clickPageElementAndExpectEventRequest(page, '#pageview-trigger-custom-url', {n: 'pageview', u: `https://example.com/custom/location`})
-    await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger', {n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`})
-    await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger-custom-url', {n: 'CustomEvent', u: `https://example.com/custom/location`})
+    await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [
+      {n: 'pageview', u: `https://example.com/custom/location`}
+    ])
+    await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger', [
+      {n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`}
+    ])
+    await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [
+      {n: 'CustomEvent', u: `https://example.com/custom/location`}
+    ])
   });
 });
diff --git a/tracker/test/pageleave.spec.js b/tracker/test/pageleave.spec.js
new file mode 100644
index 000000000000..cfc2acad4464
--- /dev/null
+++ b/tracker/test/pageleave.spec.js
@@ -0,0 +1,95 @@
+/* eslint-disable playwright/expect-expect */
+/* eslint-disable playwright/no-skipped-test */
+const { clickPageElementAndExpectEventRequests, mockRequest } = require('./support/test-utils')
+const { test, expect } = require('@playwright/test');
+const { LOCAL_SERVER_ADDR } = require('./support/server');
+
+test.describe('pageleave extension', () => {
+  test.skip(({browserName}) => browserName === 'webkit', 'Not testable on Webkit');
+
+  test('sends a pageleave when navigating to the next page', async ({ page }) => {
+    const pageviewRequestMock = mockRequest(page, '/api/event')
+    await page.goto('/pageleave.html');
+    await pageviewRequestMock;
+
+    await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
+      {n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`}
+    ])
+  });
+
+  test('sends pageleave and pageview on hash-based SPA navigation', async ({ page }) => {
+    const pageviewRequestMock = mockRequest(page, '/api/event')
+    await page.goto('/pageleave-hash.html');
+    await pageviewRequestMock;
+
+    await clickPageElementAndExpectEventRequests(page, '#hash-nav', [
+      {n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html`},
+      {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html#some-hash`}
+    ])
+  });
+
+  test('sends pageleave and pageview on history-based SPA navigation', async ({ page }) => {
+    const pageviewRequestMock = mockRequest(page, '/api/event')
+    await page.goto('/pageleave.html');
+    await pageviewRequestMock;
+
+    await clickPageElementAndExpectEventRequests(page, '#history-nav', [
+      {n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`},
+      {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/another-page`}
+    ])
+  });
+
+  test('sends pageleave with the manually overridden URL', async ({ page }) => {
+    await page.goto('/pageleave-manual.html');
+
+    await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [
+      {n: 'pageview', u: 'https://example.com/custom/location'}
+    ])
+
+    await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
+      {n: 'pageleave', u: 'https://example.com/custom/location'}
+    ])
+  });
+
+  test('does not send pageleave when pageview was not sent in manual mode', async ({ page }) => {
+    await page.goto('/pageleave-manual.html');
+
+    await clickPageElementAndExpectEventRequests(page, '#navigate-away', [], [
+      {n: 'pageleave'}
+    ])
+  });
+
+  test('script.exclusions.hash.pageleave.js sends pageleave only from URLs where a pageview was sent', async ({ page }) => {
+    const pageBaseURL = `${LOCAL_SERVER_ADDR}/pageleave-hash-exclusions.html`
+    
+    const pageviewRequestMock = mockRequest(page, '/api/event')
+    await page.goto('/pageleave-hash-exclusions.html');
+    await pageviewRequestMock;
+
+    // After the initial pageview is sent, navigate to ignored page ->
+    // pageleave event is sent from the initial page URL
+    await clickPageElementAndExpectEventRequests(page, '#ignored-hash-link', [
+      {n: 'pageleave', u: pageBaseURL, h: 1}
+    ])
+
+    // Navigate from ignored page to a tracked page ->
+    // no pageleave from the current page, pageview on the next page
+    await clickPageElementAndExpectEventRequests(
+      page,
+      '#hash-link-1',
+      [{n: 'pageview', u: `${pageBaseURL}#hash1`, h: 1}],
+      [{n: 'pageleave'}]
+    )
+
+    // Navigate from a tracked page to another tracked page ->
+    // pageleave with the last page URL, pageview with the new URL
+    await clickPageElementAndExpectEventRequests(
+      page,
+      '#hash-link-2',
+      [
+        {n: 'pageleave', u: `${pageBaseURL}#hash1`, h: 1},
+        {n: 'pageview', u: `${pageBaseURL}#hash2`, h: 1}
+      ],
+    )
+  });
+});
\ No newline at end of file
diff --git a/tracker/test/support/test-utils.js b/tracker/test/support/test-utils.js
index 6c01e4f3ba31..10758ecb1bea 100644
--- a/tracker/test/support/test-utils.js
+++ b/tracker/test/support/test-utils.js
@@ -1,10 +1,10 @@
 const { expect } = require("@playwright/test");
 
 // Mocks an HTTP request call with the given path. Returns a Promise that resolves to the request
-// data. If the request is not made, resolves to null after 10 seconds.
-exports.mockRequest = function (page, path) {
+// data. If the request is not made, resolves to null after 3 seconds.
+const mockRequest = function (page, path) {
   return new Promise((resolve, _reject) => {
-    const requestTimeoutTimer = setTimeout(() => resolve(null), 10000)
+    const requestTimeoutTimer = setTimeout(() => resolve(null), 3000)
 
     page.route(path, (route, request) => {
       clearTimeout(requestTimeoutTimer)
@@ -14,6 +14,8 @@ exports.mockRequest = function (page, path) {
   })
 }
 
+exports.mockRequest = mockRequest
+
 exports.metaKey = function() {
   if (process.platform === 'darwin') {
     return 'Meta'
@@ -23,13 +25,13 @@ exports.metaKey = function() {
 }
 
 // Mocks a specified number of HTTP requests with given path. Returns a promise that resolves to a
-// list of requests as soon as the specified number of requests is made, or 10 seconds has passed.
-exports.mockManyRequests = function(page, path, numberOfRequests) {
+// list of requests as soon as the specified number of requests is made, or 3 seconds has passed.
+const mockManyRequests = function(page, path, numberOfRequests) {
   return new Promise((resolve, _reject) => {
     let requestList = []
-    const requestTimeoutTimer = setTimeout(() => resolve(requestList), 10000)
+    const requestTimeoutTimer = setTimeout(() => resolve(requestList), 3000)
 
-    page.route('/api/event', (route, request) => {
+    page.route(path, (route, request) => {
       requestList.push(request)
       if (requestList.length === numberOfRequests) {
         clearTimeout(requestTimeoutTimer)
@@ -40,6 +42,8 @@ exports.mockManyRequests = function(page, path, numberOfRequests) {
   })
 }
 
+exports.mockManyRequests = mockManyRequests
+
 exports.expectCustomEvent = function (request, eventName, eventProps) {
   const payload = request.postDataJSON()
 
@@ -49,3 +53,34 @@ exports.expectCustomEvent = function (request, eventName, eventProps) {
     expect(payload.p[key]).toEqual(value)
   }
 }
+
+exports.clickPageElementAndExpectEventRequests = async function (page, locatorToClick, expectedBodySubsets, refutedBodySubsets = []) {
+  const requestsToExpect = expectedBodySubsets.length
+  const requestsToAwait = requestsToExpect + refutedBodySubsets.length
+  
+  const plausibleRequestMockList = mockManyRequests(page, '/api/event', requestsToAwait)
+  await page.click(locatorToClick)
+  const requests = await plausibleRequestMockList
+
+  expect(requests.length).toBe(requestsToExpect)
+
+  expectedBodySubsets.forEach((bodySubset) => {
+    expect(requests.some((request) => {
+      return hasExpectedBodyParams(request, bodySubset)
+    })).toBe(true)
+  })
+
+  refutedBodySubsets.forEach((bodySubset) => {
+    expect(requests.every((request) => {
+      return !hasExpectedBodyParams(request, bodySubset)
+    })).toBe(true)
+  })
+}
+
+function hasExpectedBodyParams(request, expectedBodyParams) {
+  const body = request.postDataJSON()
+
+  return Object.keys(expectedBodyParams).every((key) => {
+    return body[key] === expectedBodyParams[key]
+  })
+}