From 817eef047d69a8f56286f4dc9879a5ae18b8d2dc Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 08:57:07 +0530
Subject: [PATCH 1/9] Make the analytics numbers honest
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The admin analytics screens were describing our own team and our own
redirects. Three fixes, no new surface area.
1. Self-referral attribution. inferSourceMedium() only ever treated the
CURRENT request host as internal, so every other Taskly domain scored
as an external referrer. We move visitors between our own domains on
purpose — the geo router sends a Canadian on .ai to .ca, the gateway
hands off, legacy taskly.ca redirects in — and each hop carries a
Taskly Referer. New lib/attribution/owned-hosts.ts is the single
source of truth, derived from REGIONS so a new market is covered the
moment it is added. Migration 187 reclassifies history in place and
records every changed row in attribution_reclassified so it reverses
with one UPDATE.
Measured: 11 of 15 attributed signups, 14 of 15 tasks and 3 of 4
bookings were Taskly referring Taskly. After: 4 signups, 1 task,
1 booking — small, but real.
2. Internal traffic. Nothing excluded us from our own dashboards;
isDevHost() only blocks localhost. Migration 188 adds
profiles.is_internal and seeds it from the team/test email domains
(34 accounts). Migration 189 threads exclude_internal + exclude_bots
through funnel_step_stats, and lib/analytics/audience.ts gives every
report one vocabulary for "whose traffic is this". Default is the
outside world; including the team is an explicit opt-in. A reporting
lens only — no rows are deleted and nothing is dropped at ingest.
Effect on the last 30 days: 1528 -> 1443 sessions, 7191 -> 6420 events.
3. GMV. finalize-checkout wrote total_price = what Stripe charged, but
total_price is the agreed DEAL. Any coupon erased it: 7 production
rows sit at total_price = 0 with service_fee equal to minus the whole
payout, one at -$20,460. The charge and the deal are different facts —
the coupon belongs in discount_amount and the deal stays intact. Fixed
on both write paths (marketplace + curated); the existing 7 rows are
NOT rewritten here, see the PR for why.
Crawler hits are now flagged at ingest (is_bot) rather than dropped, so
"how much of this is bots?" becomes answerable instead of invisible.
Gates: typecheck clean, 1496 tests pass, lint 0 errors, knip clean,
check:migrations OK.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/api/admin/funnels/route.ts | 8 +
app/api/admin/journeys/route.ts | 27 +-
app/api/admin/utm/route.ts | 28 +-
app/api/track/route.ts | 7 +
docs/ANALYTICS_MAP.md | 299 ++++++++++++++++++
lib/analytics/audience.ts | 45 +++
lib/attribution/index.ts | 11 +
lib/attribution/owned-hosts.ts | 42 +++
lib/marketplace/finalize-checkout.ts | 26 +-
lib/stripe/process-event.ts | 11 +-
...187_attribution_self_referral_backfill.sql | 108 +++++++
.../migrations/188_profiles_is_internal.sql | 51 +++
...funnel_events_bot_and_internal_filters.sql | 79 +++++
tests/analytics/audience.test.ts | 51 +++
tests/attribution/owned-hosts.test.ts | 107 +++++++
15 files changed, 883 insertions(+), 17 deletions(-)
create mode 100644 docs/ANALYTICS_MAP.md
create mode 100644 lib/analytics/audience.ts
create mode 100644 lib/attribution/owned-hosts.ts
create mode 100644 supabase/migrations/187_attribution_self_referral_backfill.sql
create mode 100644 supabase/migrations/188_profiles_is_internal.sql
create mode 100644 supabase/migrations/189_funnel_events_bot_and_internal_filters.sql
create mode 100644 tests/analytics/audience.test.ts
create mode 100644 tests/attribution/owned-hosts.test.ts
diff --git a/app/api/admin/funnels/route.ts b/app/api/admin/funnels/route.ts
index 0bb22133..4ef73edc 100644
--- a/app/api/admin/funnels/route.ts
+++ b/app/api/admin/funnels/route.ts
@@ -5,6 +5,7 @@ import { getProfileRole } from '@/lib/api/auth';
import { coerceRegionParam } from '@/lib/region/config';
import { coerceRangeKey, resolveRange } from '@/lib/analytics/date-range';
import { topLeaks } from '@/lib/analytics/leaks';
+import { audienceFromParams } from '@/lib/analytics/audience';
// Admin drop-off reporting. Reads public.funnel_events (written by /api/track)
// via the funnel_step_stats() SQL function and turns each funnel's ordered steps
@@ -127,6 +128,9 @@ export async function GET(request: Request) {
// pre-region behaviour, byte-identical). funnel_step_stats (migration 158)
// applies it as `p_region is null or region = p_region`.
const region = coerceRegionParam(searchParams.get('region'));
+ // Whose traffic. Defaults to the outside world — team accounts and crawlers are
+ // excluded unless the caller explicitly asks for them (lib/analytics/audience).
+ const audience = audienceFromParams(searchParams);
const admin = createServiceRoleClient();
@@ -139,6 +143,8 @@ export async function GET(request: Request) {
p_since: range.since,
p_until: range.until,
p_region: region,
+ p_exclude_internal: audience.excludeInternal,
+ p_exclude_bots: audience.excludeBots,
}),
),
);
@@ -209,6 +215,8 @@ export async function GET(request: Request) {
configured: true,
dateRange: { key: range.key, label: range.label, days: range.days, since: range.since, until: range.until },
region,
+ // Echoed so the UI can state what it's showing rather than implying "everyone".
+ audience,
funnels,
// "What do I fix first?" — the worst step-to-step losses across every
// funnel, ranked by sessions lost (see lib/analytics/leaks).
diff --git a/app/api/admin/journeys/route.ts b/app/api/admin/journeys/route.ts
index e078de2b..0fc1a433 100644
--- a/app/api/admin/journeys/route.ts
+++ b/app/api/admin/journeys/route.ts
@@ -5,6 +5,7 @@ import { getProfileRole } from '@/lib/api/auth';
import { coerceRegionParam } from '@/lib/region/config';
import { coerceRangeKey, resolveRange } from '@/lib/analytics/date-range';
import { buildJourneys, dropSteps, type JourneyEventRow } from '@/lib/analytics/journeys';
+import { audienceFromParams } from '@/lib/analytics/audience';
// GET /api/admin/journeys — what individual people actually did (admin-only).
//
@@ -40,12 +41,27 @@ export async function GET(request: Request) {
to: searchParams.get('to'),
});
const region = coerceRegionParam(searchParams.get('region'));
+ const audience = audienceFromParams(searchParams);
const funnel = searchParams.get('funnel');
const limit = Math.max(1, Math.min(200, parseInt(searchParams.get('limit') || '40', 10) || 40));
const admin = createServiceRoleClient();
try {
+ // Team accounts to hide. Fetched as a SET and filtered in JS rather than with
+ // a `not in` predicate: `user_id not in (...)` is NULL for anonymous rows, so
+ // PostgREST would silently drop every anonymous journey — which is most of
+ // them. Internal accounts are a handful, so the set is tiny.
+ let internalIds: Set = new Set();
+ if (audience.excludeInternal) {
+ const { data: ints } = await admin
+ .from('profiles')
+ .select('id')
+ .eq('is_internal', true)
+ .limit(1000);
+ internalIds = new Set((ints ?? []).map((r) => r.id as string));
+ }
+
let q = admin
.from('funnel_events')
.select('funnel, step, event_type, funnel_session_id, anon_id, user_id, region, created_at')
@@ -56,19 +72,26 @@ export async function GET(request: Request) {
.limit(EVENT_SCAN_LIMIT);
if (region) q = q.eq('region', region);
if (funnel) q = q.eq('funnel', funnel);
+ // is_bot is NOT NULL, so this predicate is safe for anonymous rows.
+ if (audience.excludeBots) q = q.eq('is_bot', false);
const { data, error } = await q;
if (error) {
return NextResponse.json({ configured: true, error: error.message }, { status: 500 });
}
- const journeys = buildJourneys((data ?? []) as JourneyEventRow[], limit);
+ const rows = (internalIds.size
+ ? (data ?? []).filter((r) => !r.user_id || !internalIds.has(r.user_id as string))
+ : (data ?? [])) as JourneyEventRow[];
+
+ const journeys = buildJourneys(rows, limit);
return NextResponse.json({
configured: true,
dateRange: { key: range.key, label: range.label, days: range.days },
region,
- scanned: data?.length ?? 0,
+ audience,
+ scanned: rows.length,
truncated: (data?.length ?? 0) >= EVENT_SCAN_LIMIT,
journeys,
// Where dropped journeys ended — the complement to the funnel's
diff --git a/app/api/admin/utm/route.ts b/app/api/admin/utm/route.ts
index 6930c0d8..8d4a6e31 100644
--- a/app/api/admin/utm/route.ts
+++ b/app/api/admin/utm/route.ts
@@ -5,6 +5,7 @@ import { getProfileRole } from '@/lib/api/auth';
import { shortName } from '@/lib/marketplace/types';
import { coerceRegionParam } from '@/lib/region/config';
import { coerceRangeKey, resolveRange } from '@/lib/analytics/date-range';
+import { audienceFromParams } from '@/lib/analytics/audience';
// GET /api/admin/utm — marketing attribution report (admin-only).
//
@@ -57,6 +58,7 @@ export async function GET(request: Request) {
// people are filtered by the set of account ids in the region (resolved below).
const searchParams = new URL(request.url).searchParams;
const region = coerceRegionParam(searchParams.get('region'));
+ const audience = audienceFromParams(searchParams);
// Reporting window — WITHOUT this the report was all-time, so a campaign
// launched this morning was invisible. Applied to signups, tasks and bookings
// alike so every column describes the same period.
@@ -78,6 +80,9 @@ export async function GET(request: Request) {
// The account ids in the selected market (null = all markets, no filtering).
let regionIds: Set | null = null;
+ // Team accounts to hide from the report (migration 188). Applied to the
+ // customer_profiles-derived rows below; tasks/bookings inherit it via poster.
+ let internalIds: Set = new Set();
try {
if (region) {
@@ -90,6 +95,15 @@ export async function GET(request: Request) {
regionIds = new Set((rp ?? []).map((r) => r.id as string));
}
+ if (audience.excludeInternal) {
+ const { data: ints } = await admin
+ .from('profiles')
+ .select('id')
+ .eq('is_internal', true)
+ .limit(1000);
+ internalIds = new Set((ints ?? []).map((r) => r.id as string));
+ }
+
// tasks + bookings carry region directly → filtered in-query. A market with
// no matching accounts (empty regionIds) short-circuits the id-scoped queries.
let tasksQ = admin
@@ -148,13 +162,12 @@ export async function GET(request: Request) {
}
// Region-scope the customer_profiles rows by account membership (utm lives
- // here; region is on the sibling profiles row resolved above).
- const signups = regionIds
- ? (signupsRaw ?? []).filter((r) => regionIds!.has(r.id as string))
- : signupsRaw;
- const members = regionIds
- ? (membersRaw ?? []).filter((r) => regionIds!.has(r.id as string)).slice(0, 50)
- : membersRaw;
+ // here; region is on the sibling profiles row resolved above), then drop the
+ // team accounts. Both are id-set filters, so they compose.
+ const keep = (id: string) =>
+ (!regionIds || regionIds.has(id)) && !internalIds.has(id);
+ const signups = (signupsRaw ?? []).filter((r) => keep(r.id as string));
+ const members = (membersRaw ?? []).filter((r) => keep(r.id as string)).slice(0, 50);
// ── Rollups by source ──────────────────────────────────────────────────
const srcMap = new Map();
@@ -241,6 +254,7 @@ export async function GET(request: Request) {
return NextResponse.json({
configured: true,
region,
+ audience,
dateRange: { key: range.key, label: range.label, days: range.days },
sources,
campaigns,
diff --git a/app/api/track/route.ts b/app/api/track/route.ts
index 4a36ebfc..6a6eb76c 100644
--- a/app/api/track/route.ts
+++ b/app/api/track/route.ts
@@ -4,6 +4,7 @@ import { createServiceRoleClient } from '@/lib/supabase/admin';
import { rateLimit, clientIp } from '@/lib/auth/rate-limit';
import { getRegion } from '@/lib/region/server';
import { isDevHost } from '@/lib/analytics/dev-traffic';
+import { isBotUserAgent } from '@/lib/region/geo-route';
// First-party funnel ingest. The browser's trackFunnel() (lib/analytics/funnel.ts)
// POSTs / sendBeacon's one row per step event here; we write it to
@@ -112,6 +113,11 @@ export async function POST(request: Request) {
}
}
+ // Flag crawlers rather than dropping them. A dropped hit is unauditable — we
+ // could never answer "how much of this was bots?" — whereas a flagged one is
+ // excluded from every report by default and still there when we want to look.
+ const isBot = isBotUserAgent(request.headers.get('user-agent'));
+
// Region is resolved SERVER-SIDE from the request Host (the same getRegion()
// the whole app uses) — never taken from the client payload, so it can't be
// forged. Honours the launch gate: pre-launch every host resolves to 'ca'.
@@ -130,6 +136,7 @@ export async function POST(request: Request) {
category: str(body.category, 80),
value_cents: intOrNull(body.valueCents),
region,
+ is_bot: isBot,
metadata,
});
} catch {
diff --git a/docs/ANALYTICS_MAP.md b/docs/ANALYTICS_MAP.md
new file mode 100644
index 00000000..02e8f0b7
--- /dev/null
+++ b/docs/ANALYTICS_MAP.md
@@ -0,0 +1,299 @@
+# Analytics & Marketing Data — end-to-end map
+
+**What this is:** every number the admin panel shows under **Analytics**, where it comes from, how it
+is collected, and — the part that matters right now — **how much of it is real**.
+
+Written 2026-07-31. Live counts in §6 were measured against the production DB
+(`nfhflltrbsyraeibvbiw`) on that date. Code references are file:line.
+
+---
+
+## 1. The five screens
+
+| Screen | Route | API | Underlying source |
+|---|---|---|---|
+| Revenue | `/admin/analytics` | `/api/admin` | Postgres — `admin_booking_analytics()` RPC over `bookings` |
+| API costs | `/admin/costs` | `/api/admin/costs` | Postgres — `api_cost_events` |
+| Traffic & GA | `/admin/traffic` | `/api/admin/analytics` + `/api/admin/geo-handoff` | **Google Analytics 4 Data API** (+ our `funnel_events` for hand-offs) |
+| Funnels | `/admin/funnels` | `/api/admin/funnels` + `/api/admin/journeys` | Postgres — `funnel_events` (first-party) |
+| UTM links | `/admin/utm` | `/api/admin/utm` | Postgres — `customer_profiles.utm_*`, `tasks.utm_*`, `bookings.utm_*` |
+
+Nav is defined in `app/(admin)/admin/AdminShell.tsx:99-106`.
+
+**Three independent pipelines feed these.** They do not share a collector, a consent rule, or a
+definition of "a user". Expecting them to agree is the single biggest source of confusion — see §7.
+
+---
+
+## 2. Pipeline A — Google Analytics 4 (powers *Traffic & GA* only)
+
+### Write path
+
+1. `components/GoogleAnalytics.tsx` injects gtag.js — **only after the visitor accepts the cookie
+ banner** (`GoogleAnalytics.tsx:45-66`). Before consent, zero requests to Google.
+2. `isDevHost(window.location.hostname)` short-circuits the whole loader, so `localhost`,
+ `*.local`, `*.test` never measure (`GoogleAnalytics.tsx:51`, `lib/analytics/dev-traffic.ts`).
+3. `GoogleAnalyticsPageViews` fires a manual `page_view` per route change, plus a `region` user
+ property and an event-scoped `region` param.
+4. `GoogleAnalyticsIdentity` binds `user_id` (Supabase UUID) and a `user_role` user property.
+5. Typed business events come from `lib/analytics/events.ts`; every `trackFunnel()` call also emits a
+ generic `funnel_step` GA event (`lib/analytics/funnel.ts:157-165`).
+6. Stripe webhooks mirror `purchase` server-side via the Measurement Protocol
+ (`lib/analytics/ga-mp.ts`), de-duplicated on `transaction_id`. **No-op unless `GA_MP_API_SECRET`
+ is set.**
+
+### Read path — `app/api/admin/analytics/route.ts`
+
+Fourteen GA reports run in parallel. Everything the *Traffic & GA* page shows:
+
+| Panel | GA dimensions → metrics | Notes |
+|---|---|---|
+| Live users right now | realtime `activeUsers` | Always **global** — realtime has no `hostName` dimension, so the market tabs don't apply to it (`route.ts:218`) |
+| 6 KPI cells | `activeUsers`, `screenPageViews`, `sessions`, `averageSessionDuration`, `bounceRate`, `eventCount` | Two windows in one call; the delta chip compares against the equally-long previous window |
+| Daily active users | `date` → users, pageViews | Area chart |
+| Markets over time | `date` × `hostName` → users | **Deliberately never region-filtered** — it exists to compare markets. Folded apex+www and dev/legacy hosts dropped by `lib/analytics/market-trend.ts` |
+| Top pages | `pagePath`, `pageTitle` → views, users | top 10 |
+| Top events | `eventName` → eventCount | top 10 |
+| Devices | `deviceCategory` → users | |
+| Top countries / Top cities | `country` / `city` → users | |
+| Acquisition channels | `sessionDefaultChannelGroup` → sessions, users | GA's own Organic/Direct/Social/Referral/Paid grouping |
+| Conversions (key events) | `eventName` → count, filtered to 10 named events | `sign_up`, `login`, `begin_checkout`, `purchase`, `marketplace_task_posted`, `marketplace_offer_sent`, `marketplace_offer_accepted`, `provider_application_submitted`, `provider_bg_check_paid`, `review_submitted` |
+| New vs returning | `newVsReturning` → users | |
+| Landing pages | `landingPage` → sessions | entry points |
+| AI referrals | `sessionSource` CONTAINS chatgpt/openai/perplexity/copilot/gemini/claude | |
+
+**Market filter** = GA's `hostName` CONTAINS the region host (`route.ts:81-89`). It does *not* use the
+custom `region` dimension (that would require registering + backfilling it in GA4 Admin).
+
+**Config required:** `NEXT_PUBLIC_GA_ID` (collection), `GA_PROPERTY_ID` + `GA_SERVICE_ACCOUNT_KEY`
+(reporting), `GA_MP_API_SECRET` (server-side purchases). Any missing → the page renders its
+setup-instructions panel instead of numbers.
+
+---
+
+## 3. Pipeline B — first-party funnel events (powers *Funnels*, *Journeys*, *Domain hand-offs*)
+
+This is **our own** data. Exact, unsampled, real-time, no consent gate, joinable to `user_id`.
+
+### Write path
+
+- Client: `trackFunnel(funnel, step, stepIndex, opts)` → `sendBeacon('/api/track')`
+ (`lib/analytics/funnel.ts:129`). `sendBeacon` survives page unload, which is how `abandon` events
+ get recorded at all.
+- Server-only conversions (email verification, webhooks): `recordFunnelServer()`
+ (`lib/analytics/funnel-server.ts`).
+- Ingest `app/api/track/route.ts` validates the taxonomy, rate-limits 240/min/IP, **drops dev hosts**
+ (`route.ts:87`), resolves `region` **server-side from the Host header** so it can't be forged
+ (`route.ts:118`), attaches `user_id` from the session cookie when present, and inserts into
+ `public.funnel_events` with the service-role client.
+
+### Identity model
+
+- `funnel_session_id` — one *attempt* at one funnel (sessionStorage; rotated after `submit`/`purchase`).
+ **Drop-off is counted over distinct session ids.**
+- `anon_id` — durable per-browser id (localStorage), **only created if analytics consent was granted**.
+- `user_id` — set server-side when a session cookie rides along.
+
+### Instrumented funnels (7) and their steps
+
+Definitions live in `app/api/admin/funnels/route.ts:19-92`; the client slugs are in the flows. They
+currently **match exactly** — verified.
+
+| Funnel | Steps (index → slug) | Emitted from |
+|---|---|---|
+| `post_task` | 0 describe · 1 category · 2 where · 3 budget · 4 review · 5 completed | `app/post-task/PostTaskProvider.tsx` (STEPS in `types.ts:239-243`) |
+| `curated_booking` | 0 details · 1 schedule · 2 review · 3 checkout · 4 completed | `app/book/[category]/**`, `components/booking/**` |
+| `inapp_booking` | 0 select_service · 1 configure · 2 schedule · 3 address · 4 review · 5 completed | `app/(customer)/app/book/page.tsx:32` |
+| `tasker_onboarding` | 0 identity · 1 skills · 2 location · 3 verify · 4 completed | `app/onboarding/page.tsx:52` + `verify/page.tsx` |
+| `signup` | 0 start · 1 submitted · 2 completed | `app/login/page.tsx` |
+| `tasker_offer` | 0 feed_view · 1 task_open · 2 offer_sent | `browse-tasks/**`, `PublicOfferCta.tsx` |
+| `payout_setup` | 0 started · 1 completed | `components/provider/PayoutsSection.tsx` |
+
+`geo_redirect` is also written to this table but is **not** a funnel — it backs the "Domain hand-offs"
+panel on *Traffic*.
+
+### Read path
+
+- `funnel_step_stats(p_funnel, p_since, p_until, p_region)` — migration `164_report_windows.sql`,
+ **confirmed applied to production**. Returns per (step, event_type): distinct sessions, distinct
+ users, raw events.
+- The API then applies **monotonicity correction** (`route.ts:171-179`): "reached step N" = the max
+ sessions at step N *or any later step*. Necessary because Back/Forward and resuming a saved draft
+ can skip a step's `view`. **Consequence: step counts are corrected, not raw** — step 1 is inferred
+ from the deepest step anyone reached.
+- Per step the page shows: sessions, `retainedPct` (vs step 1), `dropPct` and `droppedCount` (vs the
+ previous step). Card header shows started → completed and overall conversion.
+- **Biggest leaks** panel (`lib/analytics/leaks.ts`) ranks every hop across all 7 funnels by
+ *absolute sessions lost*, not percentage — deliberately, so a 90% drop off 4 people doesn't outrank
+ a 30% drop off 300.
+- **Recent journeys** (`/api/admin/journeys` + `lib/analytics/journeys.ts`) reconstructs individual
+ paths: every step, time between steps, signed-in or anon, completed or dropped. Scans at most 4000
+ events newest-first and sets `truncated` when it hits the cap.
+
+---
+
+## 4. Pipeline C — UTM / marketing attribution (powers *UTM links*)
+
+### Capture — `proxy.ts`
+
+Two cookies, on purpose:
+
+- `tk_attr` (client-readable) — first touch, for client GA events.
+- `taskly_attr` (httpOnly, 90 days) — **`{ first, last }`**, built by `buildTouch()`
+ (`lib/attribution/index.ts:104`). First touch is immutable; last touch advances on every tagged or
+ organic visit.
+
+If no `utm_*` params are present, `inferSourceMedium()` derives (source, medium) from the **Referer
+header** against a table of known hosts (Reddit, Quora, X, IG, FB, TikTok, LinkedIn, YouTube,
+Pinterest, WhatsApp, Google, Bing, DDG). Unknown external host → `(host, 'referral')`. Pure direct
+traffic writes nothing.
+
+### Persist
+
+- **First touch → the member**: `/api/attribution/claim` copies it onto `customer_profiles`
+ (`utm_source/medium/campaign/content/term`, `landing_page`, `referrer`, full `attribution` jsonb).
+ Immutable once set. Migration `080_attribution.sql`.
+- **Last touch → the conversion**: `/api/tasks` stamps `tasks.utm_*`; bookings get `bookings.utm_*`
+ (migration `081`).
+
+### Read path — `app/api/admin/utm/route.ts`
+
+- **Build link** tab — pure client-side URL builder + QR, 12 channel presets, market-aware origin.
+ No data.
+- **Analytics** tab:
+ - `sources[]` — per `utm_source`: signups, signups30, tasks, tasks30, bookings, gmvCents
+ - `campaigns[]` — same rollup keyed on `utm_campaign`
+ - `totals` — column sums
+ - `people[]` — the last 50 attributed members: masked id, short name, source/medium/campaign,
+ landing page, joined date, tasks posted
+
+**Critical scoping rule:** every query is `.not('utm_source','is',null)`. **Direct traffic and
+un-inferrable organic traffic are invisible on this page.** UTM totals are therefore *always* a
+subset of real signups, never the whole.
+
+---
+
+## 5. Shared vocabulary
+
+- **Date range** — `lib/analytics/date-range.ts`. One resolver for all four screens: today /
+ yesterday / 7d / 30d / 90d / custom, with day boundaries computed in `America/Toronto`, SQL
+ windows as `[since, until)`, and GA's own native date tokens (never instants — that's how
+ off-by-one-day bugs happen). `previousRange()` is the baseline behind every delta chip.
+- **Region** — `?region=ca|in|global`, `null` = all markets.
+ - Funnels/journeys: `funnel_events.region`, stamped server-side from Host.
+ - Traffic: GA `hostName` CONTAINS the market host.
+ - UTM: tasks/bookings carry `region`; signups are filtered by the set of `profiles.id` in that
+ region (because `utm_*` lives on `customer_profiles`, which has no region column).
+ - Region tabs only render when `multiRegionLive()` is on.
+
+---
+
+## 6. Trust ledger — how real is the data? (measured 2026-07-31)
+
+### 6.1 CONFIRMED BUG — our own domains are being counted as marketing "referrals"
+
+`inferSourceMedium()` (`lib/attribution/index.ts:88-90`) only treats the **current** host as
+internal. Every other Taskly domain looks like an external site, so a visitor crossing
+`taskly.ca → tasklyanything.ca`, or being **geo-redirected** `.ai → .ca` (which we do deliberately in
+`proxy.ts`), gets attributed as a referral *from ourselves*.
+
+Live evidence — the entire attribution dataset:
+
+| Table | Attributed rows | Self-referral | Genuinely external |
+|---|---|---|---|
+| `customer_profiles` (signups) | 16 of 53 | **12** — `taskly.ca` ×6, `tasklyanything.ai` ×3, `tasklyanything.ca` ×3 | 4 — google/organic ×3, affiliate ×1 |
+| `tasks` | 16 of 236 | **15** — `taskly.ca` ×9, `tasklyanything.ca` ×4, `.in` ×1, `.ai` ×1 | 1 — google/organic |
+| `bookings` | 4 of 89 | **3** | 1 — google/organic |
+
+So **75% of the UTM report is us referring ourselves**, and the real external-acquisition dataset is
+4 signups. That is the "this isn't proper data" feeling, and it is a code bug, not a perception
+problem.
+
+**Fix:** make `inferSourceMedium()` treat every owned host as self — all `REGIONS[*].host` values,
+the legacy `taskly.ca`, and their `www.` variants — and return `{null, null}` for them. Historical
+rows can be re-classified in place with an UPDATE; the funnel/GA pipelines are unaffected.
+
+### 6.2 No internal/team exclusion exists anywhere on production
+
+`isDevHost()` only removes **localhost and dev TLDs**. There is **no** filter for the team browsing
+the live site — no IP allowlist, no admin-role exclusion, no internal flag. Every time anyone on the
+team opens tasklyanything.ca on their laptop or phone, it is a real user in GA, in `funnel_events`,
+and in Journeys.
+
+Last 30 days of `funnel_events` (excluding `geo_redirect`), 7,453 events / 1,564 sessions:
+
+| Bucket | Events | % | Sessions |
+|---|---|---|---|
+| Anonymous (no session cookie) | 5,208 | 69.9% | 1,326 |
+| Signed in — other accounts | 1,474 | 19.8% | 206 |
+| Signed in — **known internal** (`@taskly.ca`, `@test.taskly.ca`, `@demo.taskly.ca`, `@haleluiya.com`) | 771 | 10.3% | 108 |
+
+And the concentration is extreme: **the top 15 accounts produce ~92% of all signed-in events**, led
+by one account with 892 events across 53 sessions in 27 days. With 53 customer profiles and 236 tasks
+total, the platform is pre-traction — which means a large share of the "signed in" traffic on every
+dashboard is the team testing.
+
+The anonymous 70% is *not* automatically real either: a team member in a logged-out window, or an
+uncrawled bot, lands in exactly that bucket.
+
+**Fix options, cheapest first:**
+1. **GA4 Admin → Data Streams → Configure tag settings → Define internal traffic**, then a Data
+ Filter excluding it. Console-only, no code, applies to *Traffic & GA* going forward.
+2. Add an `is_internal boolean` to `profiles`, set it for team accounts, and exclude those
+ `user_id`s in `funnel_step_stats()` / journeys behind an "Exclude team" toggle. Keeps the raw
+ rows; changes only the reporting lens.
+3. Purge or tag the obvious `@test.taskly.ca` / `@demo.taskly.ca` accounts.
+
+### 6.3 GA4 and Funnels can never agree — by design
+
+| | GA4 | `funnel_events` |
+|---|---|---|
+| Cookie consent | **Required** — no consent, no data at all | Not required (only `anon_id` is consent-gated) |
+| Sampling / latency | Yes / hours | None / instant |
+| Dev traffic | Blocked client-side | Blocked server-side |
+| Bots | GA's own filtering | **None** |
+
+GA is therefore a strict **undercount** (consenting visitors only) and funnels an **overcount**
+relative to GA. Both are "right"; they answer different questions. Never reconcile one against the
+other.
+
+### 6.4 Historic GA pollution
+
+A 2026-07-23 audit (documented in `lib/analytics/dev-traffic.ts`) found `localhost` was **63% of
+active users and 73% of page views**. The guard was added then — but **GA cannot retroactively clean
+what it already collected.** Any GA window that reaches back before 2026-07-23 is still poisoned.
+Either only read GA from 2026-07-24 onward, or add a permanent GA Data Filter excluding
+`hostName = localhost`.
+
+### 6.5 Smaller caveats worth knowing when reading the numbers
+
+- **Funnel step counts are corrected, not raw** (§3). Step 1 "started" is inferred from the deepest
+ step reached.
+- **`signups30` / `tasks30` on the UTM page are a hardcoded 30-day sub-window** inside whatever range
+ you picked — on a "Today" range they equal the main column.
+- **Journeys caps at 4000 events** and shows a truncation notice; narrow the range to see further back.
+- **Realtime users ignore the market tabs** (GA limitation).
+- **UTM GMV uses `bookings.total_price`**, which is affected by the open `finalize-checkout` bug
+ (`amount_total ?? 0` overwriting totals with 0) — channel ROI reads low until that's fixed.
+- **`geo_redirect` rows carry no `funnel_session_id`** (842 events, 0 sessions last 30d) — correct,
+ they're single-event logs, but don't read "sessions" for them.
+- `funnel_events.region` distribution last 30d: `ca` 7,819 / `in` 437 / `global` 39 events.
+
+---
+
+## 7. What to do about it — ranked
+
+| # | Action | Effort | Impact |
+|---|---|---|---|
+| 1 | Fix `inferSourceMedium()` to treat all owned hosts as self; re-classify existing rows | ~30 min + migration | Makes the UTM page mean something. Removes 75% phantom "referrals" |
+| 2 | Configure GA4 internal-traffic definition + data filter (team IPs) | Console only | Cleans *Traffic & GA* going forward |
+| 3 | Add `profiles.is_internal` + an "Exclude team" toggle across funnels/journeys/UTM | ~half day | Makes first-party funnels trustworthy at current volume |
+| 4 | Add a GA data filter excluding `hostName = localhost`; treat pre-2026-07-24 GA as unusable | Console only | Stops old pollution leaking into 90d windows |
+| 5 | Delete or tag `@test.taskly.ca` / `@demo.taskly.ca` accounts | ~1 hr | Removes the loudest single contributors |
+| 6 | Add bot-UA filtering to `/api/track` (`isBotUserAgent` already exists in `lib/region/geo-route.ts`) | ~15 min | Trims the anonymous bucket |
+| 7 | Verify `GA_PROPERTY_ID`, `GA_SERVICE_ACCOUNT_KEY`, `GA_MP_API_SECRET` are set in Railway | 5 min | Missing MP secret = paid conversions never reach GA |
+
+**Bottom line:** the plumbing is sound and the taxonomy is consistent end to end. The reason the
+dashboards don't feel like real data is two concrete gaps — self-referral attribution (§6.1) and no
+internal-traffic exclusion on production (§6.2) — on top of a genuinely small pre-traction dataset
+where the team is a big share of the users.
diff --git a/lib/analytics/audience.ts b/lib/analytics/audience.ts
new file mode 100644
index 00000000..cba2fa70
--- /dev/null
+++ b/lib/analytics/audience.ts
@@ -0,0 +1,45 @@
+// lib/analytics/audience.ts
+//
+// One vocabulary for "whose traffic am I looking at?", shared by every admin
+// report — the audience equivalent of what date-range.ts does for time.
+//
+// The default is deliberate: the dashboards show the OUTSIDE WORLD unless you
+// ask otherwise. Seeing our own team in the numbers should be a decision, not an
+// accident — that accident is precisely why the panel stopped being believable.
+//
+// Pure: parses an untrusted querystring value and nothing else.
+
+export interface AudienceFilter {
+ /** Team/test accounts (profiles.is_internal) — excluded unless asked for. */
+ excludeInternal: boolean;
+ /** Crawler hits (funnel_events.is_bot) — excluded unless asked for. */
+ excludeBots: boolean;
+}
+
+/** The reporting default: real, human, outside traffic. */
+export const DEFAULT_AUDIENCE: AudienceFilter = {
+ excludeInternal: true,
+ excludeBots: true,
+};
+
+/**
+ * Read the audience filter off a request's query params.
+ *
+ * `?includeInternal=true` brings the team back in; `?includeBots=true` brings
+ * crawlers back in. Anything else — absent, malformed, 'false' — keeps the safe
+ * default, so a mistyped param can never silently re-pollute a report.
+ */
+export function audienceFromParams(params: URLSearchParams): AudienceFilter {
+ return {
+ excludeInternal: params.get('includeInternal') !== 'true',
+ excludeBots: params.get('includeBots') !== 'true',
+ };
+}
+
+/** Query-string fragment describing an audience, for cache keys + client fetches. */
+export function audienceQuery(a: AudienceFilter): string {
+ const parts: string[] = [];
+ if (!a.excludeInternal) parts.push('includeInternal=true');
+ if (!a.excludeBots) parts.push('includeBots=true');
+ return parts.join('&');
+}
diff --git a/lib/attribution/index.ts b/lib/attribution/index.ts
index 6f48a675..e4bf2eac 100644
--- a/lib/attribution/index.ts
+++ b/lib/attribution/index.ts
@@ -14,6 +14,8 @@
// Organic shares (someone re-posts your link WITHOUT a UTM) are still caught via
// inferSourceMedium() reading the referrer host.
+import { isOwnedHost } from './owned-hosts';
+
export const ATTR_COOKIE = 'taskly_attr';
export const ATTR_MAX_AGE = 60 * 60 * 24 * 90; // 90-day attribution window
@@ -85,6 +87,15 @@ export function inferSourceMedium(
} catch {
return { source: null, medium: null };
}
+ // ANY domain we own is internal — not just the one serving this request. We
+ // move visitors between our own domains on purpose (geo routing .ai → .ca, the
+ // .ai gateway hand-off, the legacy taskly.ca redirect), and each hop carries a
+ // Taskly Referer. Scoring those as referrals is what made 75% of the UTM
+ // report read as "Taskly referred Taskly". Same result as direct traffic —
+ // deliberately NOT an 'internal' source, which would just move the pollution
+ // into a new bucket on the report instead of removing it.
+ if (isOwnedHost(host)) return { source: null, medium: null };
+
const self = (selfHost || '').toLowerCase().replace(/:\d+$/, '');
if (self && (host === self || host.endsWith('.' + self.replace(/^www\./, '')))) {
return { source: null, medium: null }; // internal navigation — not a new touch
diff --git a/lib/attribution/owned-hosts.ts b/lib/attribution/owned-hosts.ts
new file mode 100644
index 00000000..e2d06c7b
--- /dev/null
+++ b/lib/attribution/owned-hosts.ts
@@ -0,0 +1,42 @@
+// lib/attribution/owned-hosts.ts
+//
+// Every hostname Taskly owns — the single source of truth for "is this referrer
+// actually us?".
+//
+// Why this exists: we deliberately move visitors BETWEEN our own domains. The
+// geo router sends a Canadian on .ai to .ca, the .ai gateway hands off to a
+// market, and the legacy taskly.ca still redirects in. Each of those hops sets a
+// Referer header pointing at another Taskly domain. Without this list,
+// inferSourceMedium() scores every one of them as an external referral — so the
+// UTM report fills up with "referral from tasklyanything.ca" and the marketing
+// numbers describe our own redirects instead of our acquisition.
+//
+// Measured before the fix (2026-07-31): 12 of 16 attributed signups, 15 of 16
+// attributed tasks and 3 of 4 attributed bookings were Taskly referring Taskly.
+//
+// Derived from REGIONS so a new market is covered the moment it's added there —
+// the one hard-coded entry is the legacy domain, which has no region config.
+//
+// PURE + edge-safe (REGIONS is itself pure), so proxy.ts can import it.
+
+import { REGIONS } from '@/lib/region/config';
+
+/** Domains we own that aren't a region host. Legacy, parked, or redirect-only. */
+const LEGACY_HOSTS = ['taskly.ca'];
+
+/** Every host we own, apex + `www.`, lowercased. */
+export const OWNED_HOSTS: ReadonlySet = new Set(
+ [...Object.values(REGIONS).map((r) => r.host), ...LEGACY_HOSTS]
+ .flatMap((h) => [h, `www.${h}`])
+ .map((h) => h.toLowerCase()),
+);
+
+/**
+ * True when `hostname` is a domain we own. Accepts a bare hostname or one
+ * carrying a port. A referrer from any of these is internal navigation, NOT a
+ * marketing referral.
+ */
+export function isOwnedHost(hostname: string | null | undefined): boolean {
+ if (!hostname) return false;
+ return OWNED_HOSTS.has(hostname.trim().toLowerCase().replace(/:\d+$/, ''));
+}
diff --git a/lib/marketplace/finalize-checkout.ts b/lib/marketplace/finalize-checkout.ts
index 5ec2d079..bb85794f 100644
--- a/lib/marketplace/finalize-checkout.ts
+++ b/lib/marketplace/finalize-checkout.ts
@@ -31,21 +31,37 @@ export async function finalizeMarketplaceCheckout(
const { data: existing } = await supabase
.from('bookings')
- .select('payment_status, status, task_id, provider_payout')
+ .select('payment_status, status, task_id, provider_payout, total_price')
.eq('id', bookingId)
.single();
if (existing?.payment_status !== 'captured') {
+ // `total_price` is the AGREED DEAL — the marketplace's GMV — set from
+ // marketplaceBookingAmounts() when the booking was created. It is NOT "what
+ // Stripe charged". Overwriting it with amount_total destroyed the deal value
+ // on every discounted checkout: a 100%-off promo wrote total_price = 0 and
+ // drove service_fee to minus the whole payout (7 prod rows, one at
+ // -$20,460). The charge and the deal are different facts, so they get
+ // different columns: the coupon lives in discount_amount, and the deal is
+ // left alone.
+ //
+ // Only trust amount_total to RAISE the recorded deal (a Stripe-side
+ // adjustment), never to erase it. Falling back to the stored value when the
+ // session reports nothing is the safe direction: under-reporting GMV is a
+ // silent, un-noticed loss, whereas a stale total is visible and fixable.
+ const dealCents = Math.max(amountTotal + discount, existing?.total_price ?? 0);
+ const payout = existing?.provider_payout ?? 0;
+
await supabase
.from('bookings')
.update({
payment_status: 'captured',
payment_captured_at: new Date().toISOString(),
- // Reflect the coupon: the tasker's provider_payout is untouched, so
- // service_fee (Taskly's net) absorbs the discount and may go negative.
- total_price: amountTotal,
+ total_price: dealCents,
discount_amount: discount,
- service_fee: amountTotal - (existing?.provider_payout ?? 0),
+ // Taskly's cut of the DEAL. A coupon is marketing spend, tracked in
+ // discount_amount — it must not read as a negative commission.
+ service_fee: dealCents - payout,
...(piId ? { stripe_payment_intent_id: piId } : {}),
})
.eq('id', bookingId);
diff --git a/lib/stripe/process-event.ts b/lib/stripe/process-event.ts
index cbc36368..a6c403cc 100644
--- a/lib/stripe/process-event.ts
+++ b/lib/stripe/process-event.ts
@@ -155,17 +155,22 @@ export async function processStripeEvent(
if (purpose === 'curated_booking') {
const { data: cur } = await supabase
.from('bookings')
- .select('payment_status, provider_payout, region')
+ .select('payment_status, provider_payout, region, total_price')
.eq('id', bookingId)
.single();
if (cur?.payment_status !== 'authorized' && cur?.payment_status !== 'captured') {
+ // Same rule as the marketplace path (lib/marketplace/finalize-checkout):
+ // total_price is the AGREED DEAL (GMV), not the amount Stripe charged.
+ // A coupon must land in discount_amount and leave the deal — and
+ // therefore the commission — intact.
+ const curDeal = Math.max(amountTotal + discount, cur?.total_price ?? 0);
must('mark curated authorized', await supabase
.from('bookings')
.update({
payment_status: 'authorized',
- total_price: amountTotal,
+ total_price: curDeal,
discount_amount: discount,
- service_fee: amountTotal - (cur?.provider_payout ?? 0),
+ service_fee: curDeal - (cur?.provider_payout ?? 0),
...(piId ? { stripe_payment_intent_id: piId } : {}),
})
.eq('id', bookingId));
diff --git a/supabase/migrations/187_attribution_self_referral_backfill.sql b/supabase/migrations/187_attribution_self_referral_backfill.sql
new file mode 100644
index 00000000..9683d68e
--- /dev/null
+++ b/supabase/migrations/187_attribution_self_referral_backfill.sql
@@ -0,0 +1,108 @@
+-- 187_attribution_self_referral_backfill.sql
+--
+-- Reclassify historical self-referrals out of the marketing attribution data.
+--
+-- THE BUG (fixed in code in the same PR, lib/attribution/owned-hosts.ts):
+-- inferSourceMedium() only ever treated the CURRENT request host as internal, so
+-- every other Taskly domain looked like an external website. We move visitors
+-- between our own domains deliberately — the geo router sends a Canadian on .ai
+-- to .ca, the .ai gateway hands off to a market, and the legacy taskly.ca
+-- redirects in — and each hop carries a Taskly Referer. All of those were being
+-- recorded as marketing referrals.
+--
+-- Measured on prod immediately before this migration (2026-07-31):
+-- customer_profiles 16 attributed → 12 self-referrals (taskly.ca 6, .ai 3, .ca 3)
+-- tasks 16 attributed → 15 self-referrals (taskly.ca 9, .ca 4, .in 1, .ai 1)
+-- bookings 4 attributed → 3 self-referrals
+-- Post-migration the genuinely external rows are: 4 signups, 1 task, 1 booking.
+--
+-- REVERSIBLE BY DESIGN. Nothing is deleted. Every changed row is recorded in
+-- public.attribution_reclassified with its original values first, so this can be
+-- undone with a single UPDATE ... FROM. customer_profiles ALSO keeps its original values
+-- inside the existing `attribution` jsonb under `reclassified_from`; tasks and
+-- bookings have no such jsonb column, which is exactly why the audit table exists.
+--
+-- Target state matches what the code now produces: a self-referral is
+-- indistinguishable from direct traffic (both null), NOT a new 'internal' bucket
+-- — that would move the pollution rather than remove it.
+
+-- ── The audit trail ─────────────────────────────────────────────────────────
+create table if not exists public.attribution_reclassified (
+ id bigserial primary key,
+ table_name text not null check (table_name in ('customer_profiles','tasks','bookings')),
+ row_id uuid not null,
+ old_source text,
+ old_medium text,
+ reason text not null default 'self_referral',
+ migration text not null default '187',
+ created_at timestamptz not null default now()
+);
+
+comment on table public.attribution_reclassified is
+ 'Audit of attribution rows nulled out by a reclassification migration. Keeps the '
+ 'self-referral backfill (187) reversible: the original utm_source/utm_medium are '
+ 'here, keyed by table + row id.';
+
+create index if not exists attribution_reclassified_row_idx
+ on public.attribution_reclassified (table_name, row_id);
+
+-- Analytics-internal bookkeeping: no client ever reads this. RLS on, no policies
+-- => service-role only, same posture as funnel_events.
+alter table public.attribution_reclassified enable row level security;
+
+-- ── Owned hosts ─────────────────────────────────────────────────────────────
+-- Mirrors lib/attribution/owned-hosts.ts. Kept literal (not derived) because a
+-- migration must reproduce byte-identically forever, even after REGIONS changes.
+create temporary table _owned_hosts (host text primary key) on commit drop;
+insert into _owned_hosts (host) values
+ ('tasklyanything.ca'), ('www.tasklyanything.ca'),
+ ('tasklyanything.in'), ('www.tasklyanything.in'),
+ ('tasklyanything.ai'), ('www.tasklyanything.ai'),
+ ('taskly.ca'), ('www.taskly.ca');
+
+-- ── 1. customer_profiles (first-touch, member-level) ────────────────────────
+insert into public.attribution_reclassified (table_name, row_id, old_source, old_medium)
+select 'customer_profiles', cp.id, cp.utm_source, cp.utm_medium
+from public.customer_profiles cp
+where lower(cp.utm_source) in (select host from _owned_hosts);
+
+update public.customer_profiles cp
+set
+ utm_source = null,
+ utm_medium = null,
+ -- Preserve the original inside the existing jsonb blob too, so a reader
+ -- looking only at the row can still see what happened.
+ attribution = coalesce(cp.attribution, '{}'::jsonb) || jsonb_build_object(
+ 'reclassified_from', jsonb_build_object(
+ 'utm_source', cp.utm_source,
+ 'utm_medium', cp.utm_medium,
+ 'reason', 'self_referral',
+ 'migration', '187'
+ )
+ )
+where lower(cp.utm_source) in (select host from _owned_hosts);
+
+-- ── 2. tasks (last-touch, conversion-level) ─────────────────────────────────
+insert into public.attribution_reclassified (table_name, row_id, old_source, old_medium)
+select 'tasks', t.id, t.utm_source, t.utm_medium
+from public.tasks t
+where lower(t.utm_source) in (select host from _owned_hosts);
+
+update public.tasks t
+set utm_source = null, utm_medium = null
+where lower(t.utm_source) in (select host from _owned_hosts);
+
+-- ── 3. bookings (last-touch, revenue-level) ─────────────────────────────────
+insert into public.attribution_reclassified (table_name, row_id, old_source, old_medium)
+select 'bookings', b.id, b.utm_source, b.utm_medium
+from public.bookings b
+where lower(b.utm_source) in (select host from _owned_hosts);
+
+update public.bookings b
+set utm_source = null, utm_medium = null
+where lower(b.utm_source) in (select host from _owned_hosts);
+
+-- ── Reversal (do NOT run as part of the migration) ──────────────────────────
+-- update public.tasks t set utm_source = r.old_source, utm_medium = r.old_medium
+-- from public.attribution_reclassified r
+-- where r.table_name = 'tasks' and r.row_id = t.id and r.migration = '187';
diff --git a/supabase/migrations/188_profiles_is_internal.sql b/supabase/migrations/188_profiles_is_internal.sql
new file mode 100644
index 00000000..67c2c908
--- /dev/null
+++ b/supabase/migrations/188_profiles_is_internal.sql
@@ -0,0 +1,51 @@
+-- 188_profiles_is_internal.sql
+--
+-- Mark team/test accounts so the admin dashboards can report on the OUTSIDE
+-- WORLD by default.
+--
+-- Today nothing excludes us from our own analytics. isDevHost() only drops
+-- localhost and dev TLDs, so every time anyone on the team opens the live site
+-- they are counted as a real user in funnel_events, journeys and the UTM report.
+-- At current volume that dominates: measured 2026-07-31, the top 15 accounts
+-- produced ~92% of all signed-in funnel events, and known-internal domains alone
+-- were 10.3% of events across 108 sessions.
+--
+-- This is a REPORTING LENS, not a purge. No rows are deleted, no events are
+-- dropped at ingest, and flipping the flag back restores the old numbers exactly.
+-- The dashboards default to excluding these accounts; an "Include team traffic"
+-- toggle turns them back on.
+--
+-- Seeding is done HERE rather than by hand so it is reproducible across
+-- environments and reviewable in the diff.
+
+alter table public.profiles
+ add column if not exists is_internal boolean not null default false;
+
+comment on column public.profiles.is_internal is
+ 'Team / test account. Excluded from admin analytics by default (reporting lens '
+ 'only — never affects product behaviour, gating, or what the member sees).';
+
+-- Partial index: the analytics queries all ask for "NOT internal", and internal
+-- is the rare value, so indexing only the true rows keeps it small while still
+-- serving the anti-join.
+create index if not exists profiles_is_internal_idx
+ on public.profiles (id) where is_internal;
+
+-- ── Seed: known team + test domains ─────────────────────────────────────────
+-- Email lives on auth.users, so match through it. Deliberately domain-based:
+-- individual personal addresses are the founder's call to add via /admin, not
+-- something a migration should guess at.
+update public.profiles p
+set is_internal = true
+from auth.users u
+where u.id = p.id
+ and (
+ u.email ilike '%@taskly.ca'
+ or u.email ilike '%@test.taskly.ca'
+ or u.email ilike '%@demo.taskly.ca'
+ or u.email ilike '%@tasklyanything.ca'
+ or u.email ilike '%@tasklyanything.in'
+ or u.email ilike '%@tasklyanything.ai'
+ or u.email ilike '%@haleluiya.com'
+ )
+ and p.is_internal = false;
diff --git a/supabase/migrations/189_funnel_events_bot_and_internal_filters.sql b/supabase/migrations/189_funnel_events_bot_and_internal_filters.sql
new file mode 100644
index 00000000..7056aed6
--- /dev/null
+++ b/supabase/migrations/189_funnel_events_bot_and_internal_filters.sql
@@ -0,0 +1,79 @@
+-- 189_funnel_events_bot_and_internal_filters.sql
+--
+-- Two reporting filters for the first-party funnel stream:
+-- • is_bot — stamped at ingest from the User-Agent (/api/track)
+-- • is_internal — joined from profiles (migration 188)
+--
+-- Both are EXCLUSIONS AT READ TIME, never at write time. /api/track keeps
+-- recording bot hits and keeps the row; we simply stop counting them by default.
+-- That preserves the ability to audit what was filtered and to answer "how much
+-- of our traffic is crawlers?" — a question we currently cannot answer at all.
+--
+-- SIGNATURE NOTE: funnel_step_stats gains two parameters. Postgres cannot add
+-- arguments via CREATE OR REPLACE, so the old function is dropped and recreated
+-- as the ONLY version (no overloads — an ambiguous overload set is how PostgREST
+-- starts 300-ing). Both new parameters DEFAULT, so a caller still passing just
+-- (p_funnel, p_since, p_until, p_region) — i.e. the currently-deployed code —
+-- keeps resolving during the window between this migration and the deploy.
+
+-- ── 1. Bot flag on the event stream ─────────────────────────────────────────
+alter table public.funnel_events
+ add column if not exists is_bot boolean not null default false;
+
+comment on column public.funnel_events.is_bot is
+ 'Request User-Agent matched the crawler list at ingest. Kept, not dropped — '
+ 'excluded from reports by default so the filtering stays auditable.';
+
+-- Reports scan (funnel, created_at) and then exclude bots; a partial index on the
+-- rare true rows keeps this cheap without bloating the main scan indexes.
+create index if not exists funnel_events_is_bot_idx
+ on public.funnel_events (created_at desc) where is_bot;
+
+-- ── 2. funnel_step_stats with the two exclusions ────────────────────────────
+drop function if exists public.funnel_step_stats(text, timestamptz, timestamptz, text);
+
+create function public.funnel_step_stats(
+ p_funnel text,
+ p_since timestamptz,
+ p_until timestamptz default null,
+ p_region text default null,
+ p_exclude_internal boolean default true,
+ p_exclude_bots boolean default true
+)
+returns table (
+ step text,
+ step_index smallint,
+ event_type text,
+ sessions bigint,
+ users bigint,
+ events bigint
+)
+language sql
+stable
+as $$
+ select
+ e.step,
+ e.step_index,
+ e.event_type,
+ count(distinct coalesce(e.funnel_session_id, e.id::text)) as sessions,
+ count(distinct e.user_id) as users,
+ count(*) as events
+ from public.funnel_events e
+ -- LEFT join: the overwhelming majority of events are anonymous (no user_id at
+ -- all). An inner join would silently drop them and turn the funnel into a
+ -- signed-in-only report.
+ left join public.profiles p on p.id = e.user_id
+ where e.funnel = p_funnel
+ and e.created_at >= p_since
+ and (p_until is null or e.created_at < p_until)
+ and (p_region is null or e.region = p_region)
+ and (not p_exclude_bots or e.is_bot = false)
+ and (not p_exclude_internal or coalesce(p.is_internal, false) = false)
+ group by e.step, e.step_index, e.event_type
+ order by e.step_index asc;
+$$;
+
+revoke all on function public.funnel_step_stats(text, timestamptz, timestamptz, text, boolean, boolean)
+ from public, anon;
+grant execute on function public.funnel_step_stats(text, timestamptz, timestamptz, text, boolean, boolean)
+ to authenticated, service_role;
diff --git a/tests/analytics/audience.test.ts b/tests/analytics/audience.test.ts
new file mode 100644
index 00000000..20ba5d07
--- /dev/null
+++ b/tests/analytics/audience.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from 'vitest';
+import { audienceFromParams, audienceQuery, DEFAULT_AUDIENCE } from '@/lib/analytics/audience';
+
+// The default matters more than the parsing: a report that quietly includes the
+// team is exactly how the dashboards stopped being believable.
+
+describe('audienceFromParams', () => {
+ it('excludes team and bots by default', () => {
+ expect(audienceFromParams(new URLSearchParams())).toEqual({
+ excludeInternal: true,
+ excludeBots: true,
+ });
+ });
+
+ it('includes the team only on an exact opt-in', () => {
+ expect(audienceFromParams(new URLSearchParams('includeInternal=true')).excludeInternal).toBe(false);
+ });
+
+ it('includes bots only on an exact opt-in', () => {
+ expect(audienceFromParams(new URLSearchParams('includeBots=true')).excludeBots).toBe(false);
+ });
+
+ it('falls back to the safe default on anything malformed', () => {
+ for (const q of ['includeInternal=1', 'includeInternal=yes', 'includeInternal=TRUE', 'includeInternal=']) {
+ expect(audienceFromParams(new URLSearchParams(q)).excludeInternal, q).toBe(true);
+ }
+ });
+
+ it('handles both opt-ins together', () => {
+ expect(audienceFromParams(new URLSearchParams('includeInternal=true&includeBots=true'))).toEqual({
+ excludeInternal: false,
+ excludeBots: false,
+ });
+ });
+});
+
+describe('audienceQuery', () => {
+ it('emits nothing for the default (keeps cache keys stable)', () => {
+ expect(audienceQuery(DEFAULT_AUDIENCE)).toBe('');
+ });
+
+ it('round-trips through audienceFromParams', () => {
+ for (const a of [
+ { excludeInternal: false, excludeBots: true },
+ { excludeInternal: true, excludeBots: false },
+ { excludeInternal: false, excludeBots: false },
+ ]) {
+ expect(audienceFromParams(new URLSearchParams(audienceQuery(a)))).toEqual(a);
+ }
+ });
+});
diff --git a/tests/attribution/owned-hosts.test.ts b/tests/attribution/owned-hosts.test.ts
new file mode 100644
index 00000000..398bed07
--- /dev/null
+++ b/tests/attribution/owned-hosts.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from 'vitest';
+import { isOwnedHost, OWNED_HOSTS } from '@/lib/attribution/owned-hosts';
+import { inferSourceMedium, buildTouch } from '@/lib/attribution';
+
+// The self-referral bug that made 75% of the UTM report describe our own
+// redirects instead of our acquisition. See migration 187.
+
+describe('isOwnedHost', () => {
+ it('recognises every region host, apex and www', () => {
+ for (const h of [
+ 'tasklyanything.ca', 'www.tasklyanything.ca',
+ 'tasklyanything.in', 'www.tasklyanything.in',
+ 'tasklyanything.ai', 'www.tasklyanything.ai',
+ ]) {
+ expect(isOwnedHost(h), h).toBe(true);
+ }
+ });
+
+ it('recognises the legacy domain', () => {
+ expect(isOwnedHost('taskly.ca')).toBe(true);
+ expect(isOwnedHost('www.taskly.ca')).toBe(true);
+ });
+
+ it('is case- and port-insensitive', () => {
+ expect(isOwnedHost('TasklyAnything.CA')).toBe(true);
+ expect(isOwnedHost('tasklyanything.ca:3000')).toBe(true);
+ expect(isOwnedHost(' tasklyanything.ai ')).toBe(true);
+ });
+
+ it('does not claim someone else’s domain', () => {
+ for (const h of [
+ 'reddit.com', 'google.com', 'taskly.com', 'nottasklyanything.ca',
+ 'tasklyanything.ca.evil.com', 'evil-tasklyanything.ca',
+ ]) {
+ expect(isOwnedHost(h), h).toBe(false);
+ }
+ });
+
+ it('handles empty input without throwing', () => {
+ expect(isOwnedHost(null)).toBe(false);
+ expect(isOwnedHost(undefined)).toBe(false);
+ expect(isOwnedHost('')).toBe(false);
+ });
+
+ it('covers all three markets plus legacy, apex + www', () => {
+ expect(OWNED_HOSTS.size).toBe(8);
+ });
+});
+
+describe('inferSourceMedium — owned hosts are never a referral', () => {
+ it('treats a cross-domain hop as internal, not a referral', () => {
+ // The exact case the geo router creates: .ai hands a Canadian to .ca.
+ expect(inferSourceMedium('https://tasklyanything.ai/post-task', 'tasklyanything.ca'))
+ .toEqual({ source: null, medium: null });
+ });
+
+ it('treats the legacy domain as internal', () => {
+ expect(inferSourceMedium('https://taskly.ca/', 'tasklyanything.ca'))
+ .toEqual({ source: null, medium: null });
+ });
+
+ it('treats a www variant of another owned host as internal', () => {
+ expect(inferSourceMedium('https://www.tasklyanything.in/', 'tasklyanything.ca'))
+ .toEqual({ source: null, medium: null });
+ });
+
+ it('still resolves genuine external referrers', () => {
+ expect(inferSourceMedium('https://www.reddit.com/r/toronto', 'tasklyanything.ca'))
+ .toEqual({ source: 'reddit', medium: 'social' });
+ expect(inferSourceMedium('https://www.google.com/search?q=x', 'tasklyanything.ca'))
+ .toEqual({ source: 'google', medium: 'organic' });
+ expect(inferSourceMedium('https://someblog.example/post', 'tasklyanything.ca'))
+ .toEqual({ source: 'someblog.example', medium: 'referral' });
+ });
+
+ it('keeps same-host internal navigation internal (unchanged behaviour)', () => {
+ expect(inferSourceMedium('https://tasklyanything.ca/browse', 'tasklyanything.ca'))
+ .toEqual({ source: null, medium: null });
+ });
+});
+
+describe('buildTouch — a self-referral is indistinguishable from direct', () => {
+ it('writes no touch for a cross-domain hop with no UTM', () => {
+ const touch = buildTouch(
+ new URLSearchParams(),
+ '/post-task',
+ 'https://tasklyanything.ai/',
+ 'tasklyanything.ca',
+ 1_700_000_000_000,
+ );
+ // null = do not churn the attribution cookie. Same as direct traffic.
+ expect(touch).toBeNull();
+ });
+
+ it('still honours an explicit UTM even when the referrer is ours', () => {
+ // A tracked link we posted ourselves is real marketing — the UTM wins.
+ const touch = buildTouch(
+ new URLSearchParams('utm_source=reddit&utm_medium=social'),
+ '/',
+ 'https://tasklyanything.ai/',
+ 'tasklyanything.ca',
+ 1_700_000_000_000,
+ );
+ expect(touch?.source).toBe('reddit');
+ expect(touch?.medium).toBe('social');
+ });
+});
From 1a009d6c830679769d2a9435daaa0c9f14860b99 Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 09:07:18 +0530
Subject: [PATCH 2/9] Quarantine the coupon-corrupted booking totals instead of
rewriting them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Founder decision: mark, don't repair. The seven rows whose total_price was
overwritten with "what Stripe charged" are arithmetically recoverable
(discount_amount holds on 6 of 7, provider_payout / 0.8 on all 7) but that
is inference — none of them has a stripe_payment_intent_id, because a
100%-off coupon never creates one, so there is nothing authoritative to
reconcile against.
Migration 190 adds bookings.total_price_suspect, flags the known-broken
shape (total_price = 0, or service_fee < 0 which means the deal was
clobbered below the payout), and rebuilds admin_booking_analytics to sum
revenue over trustworthy rows only while still COUNTING the suspect ones
and reporting them as their own figure. The jobs really happened; it is
only their money columns we don't believe.
Booking counts deliberately still include them — dropping a real booking
from the count to hide a bad number would be the same mistake in reverse.
Same rule in the UTM report: a suspect booking contributes to the channel's
booking count but not its GMV, so a channel is never reported as having
produced $0 of value when the truth is that we cannot say.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/api/admin/utm/route.ts | 11 ++-
.../190_bookings_total_price_suspect.sql | 89 +++++++++++++++++++
2 files changed, 97 insertions(+), 3 deletions(-)
create mode 100644 supabase/migrations/190_bookings_total_price_suspect.sql
diff --git a/app/api/admin/utm/route.ts b/app/api/admin/utm/route.ts
index 8d4a6e31..099fb76a 100644
--- a/app/api/admin/utm/route.ts
+++ b/app/api/admin/utm/route.ts
@@ -117,7 +117,7 @@ export async function GET(request: Request) {
let bookingsQ = admin
.from('bookings')
- .select('utm_source, utm_campaign, total_price, status')
+ .select('utm_source, utm_campaign, total_price, status, total_price_suspect')
.not('utm_source', 'is', null)
.neq('status', 'cancelled')
.gte('created_at', range.since)
@@ -202,13 +202,18 @@ export async function GET(request: Request) {
bumpCamp(r.utm_campaign, 'tasks');
}
for (const b of bookings ?? []) {
+ // The booking COUNTS every row — the job really happened. The GMV skips
+ // rows whose money columns we know are wrong (migration 190), so a channel
+ // isn't reported as having produced $0 of value when the truth is that we
+ // can't say. Counting them at zero would look like a real, bad number.
+ const gmv = b.total_price_suspect ? 0 : (b.total_price ?? 0);
const row = getSrc(b.utm_source, null);
row.bookings += 1;
- row.gmvCents += b.total_price ?? 0;
+ row.gmvCents += gmv;
if (b.utm_campaign) {
const c = getCamp(b.utm_campaign);
c.bookings += 1;
- c.gmvCents += b.total_price ?? 0;
+ c.gmvCents += gmv;
}
}
diff --git a/supabase/migrations/190_bookings_total_price_suspect.sql b/supabase/migrations/190_bookings_total_price_suspect.sql
new file mode 100644
index 00000000..5f4f196e
--- /dev/null
+++ b/supabase/migrations/190_bookings_total_price_suspect.sql
@@ -0,0 +1,89 @@
+-- 190_bookings_total_price_suspect.sql
+--
+-- Quarantine the bookings whose money columns were destroyed by the coupon bug,
+-- WITHOUT rewriting them.
+--
+-- The bug (write path fixed in the same PR — lib/marketplace/finalize-checkout.ts
+-- and lib/stripe/process-event.ts): `total_price` is the AGREED DEAL, the
+-- marketplace's GMV. Both webhook paths overwrote it with `session.amount_total`,
+-- i.e. what Stripe actually charged. A 100%-off coupon therefore wrote
+-- total_price = 0 and service_fee = -provider_payout. Seven production rows are
+-- affected, the worst reading -$20,460 in "commission".
+--
+-- WHY NOT BACKFILL. The obvious repair is total_price = discount_amount (which
+-- holds on 6 of the 7) or provider_payout / 0.8 (which holds on all 7). Both are
+-- INFERENCE. None of these rows has a stripe_payment_intent_id — a fully
+-- discounted checkout never creates a payment intent — so there is no
+-- authoritative source to reconcile against, and rewriting money rows from
+-- arithmetic we happen to believe is not a thing to do quietly. Founder decision
+-- (2026-08-01): mark, don't rewrite.
+--
+-- What the flag buys us: revenue reporting can EXCLUDE these rows explicitly and
+-- SAY that it did, instead of silently averaging in zeros. Visible beats tidy.
+
+alter table public.bookings
+ add column if not exists total_price_suspect boolean not null default false;
+
+comment on column public.bookings.total_price_suspect is
+ 'Money columns are known-unreliable on this row (the pre-190 coupon bug wrote '
+ 'total_price = amount charged instead of the agreed deal). Excluded from revenue '
+ 'rollups and reported separately — never silently averaged in. Not repaired: '
+ 'these rows have no payment intent to reconcile against.';
+
+create index if not exists bookings_total_price_suspect_idx
+ on public.bookings (id) where total_price_suspect;
+
+-- The known-broken shape: a captured booking that claims zero revenue, or one
+-- whose service fee went negative (fee = deal - payout, so negative means the
+-- deal was clobbered below the payout). Scoped to existing rows only — the write
+-- path can no longer produce either shape.
+update public.bookings
+set total_price_suspect = true
+where total_price_suspect = false
+ and (total_price = 0 or service_fee < 0);
+
+-- ── Revenue reporting excludes them, and counts them out loud ───────────────
+drop function if exists public.admin_booking_analytics(timestamptz, timestamptz, text);
+
+create function public.admin_booking_analytics(
+ p_since timestamptz,
+ p_until timestamptz default null,
+ p_region text default null
+)
+returns jsonb
+language sql
+stable
+set search_path = public
+as $$
+ with win as (
+ select b.total_price, b.provider_payout, b.hst_amount, b.status,
+ b.total_price_suspect,
+ coalesce(sc.vertical, 'other') as vertical
+ from public.bookings b
+ left join public.service_categories sc on sc.id = b.service_category_id
+ where b.created_at >= p_since
+ and (p_until is null or b.created_at < p_until)
+ and (p_region is null or b.region = p_region)
+ ),
+ -- Revenue is summed over trustworthy rows only. Booking COUNTS still include
+ -- the suspect rows — the jobs really happened; it's only their money columns
+ -- we don't believe.
+ done as (select * from win where status in ('completed', 'paid') and not total_price_suspect)
+ select jsonb_build_object(
+ 'grossRevenue', coalesce((select sum(total_price) from done), 0),
+ 'commissionEarned', coalesce((select sum(total_price - coalesce(provider_payout, 0) - coalesce(hst_amount, 0)) from done), 0),
+ 'totalBookings', (select count(*) from win),
+ 'completedBookings', (select count(*) from win where status in ('completed', 'paid')),
+ 'cancelledBookings', (select count(*) from win where status = 'cancelled'),
+ -- Surfaced so the dashboard can say "N bookings excluded from revenue"
+ -- rather than quietly under-reporting.
+ 'suspectBookings', (select count(*) from win where total_price_suspect),
+ 'revenueByVertical', coalesce((
+ select jsonb_object_agg(vertical, jsonb_build_object('revenue', rev, 'count', cnt))
+ from (select vertical, sum(total_price) as rev, count(*) as cnt from done group by vertical) g
+ ), '{}'::jsonb)
+ );
+$$;
+
+revoke all on function public.admin_booking_analytics(timestamptz, timestamptz, text) from public, anon;
+grant execute on function public.admin_booking_analytics(timestamptz, timestamptz, text) to authenticated, service_role;
From 3d5f6812cd3cf5a07d5963698141b18d332014a1 Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 09:54:39 +0530
Subject: [PATCH 3/9] Restore the coupon-corrupted booking totals from subtotal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
190 quarantined these seven rows on the belief that repairing them meant
inference. That was wrong. `subtotal` was never touched by the faulty write
path and still holds the agreed deal on every affected row — and it agrees
exactly with round(provider_payout / 0.8) on all seven, the payout having
been derived from the deal at booking time. Two independently-stored columns
agreeing is a recovered value, not a guess.
Recovers $27,755 of real GMV and restores the correct 20% commission. The
worst row goes from -$20,460 "commission" to +$5,115.
Guarded on the corroboration, not just the flag: a row that is suspect but
whose subtotal does NOT match the implied deal stays flagged and stays out
of revenue. A future corruption with a different fingerprint must not be
silently restored to a number nobody verified.
Prior values captured in booking_money_restored first, so this reverses
exactly. total_price_suspect stays on the table for the next case.
Co-Authored-By: Claude Opus 5 (1M context)
---
...92_attribution_self_referral_backfill.sql} | 7 +-
...ernal.sql => 193_profiles_is_internal.sql} | 5 +-
...unnel_events_bot_and_internal_filters.sql} | 5 +-
...l => 195_bookings_total_price_suspect.sql} | 7 +-
.../196_restore_coupon_corrupted_totals.sql | 81 +++++++++++++++++++
5 files changed, 101 insertions(+), 4 deletions(-)
rename supabase/migrations/{187_attribution_self_referral_backfill.sql => 192_attribution_self_referral_backfill.sql} (93%)
rename supabase/migrations/{188_profiles_is_internal.sql => 193_profiles_is_internal.sql} (92%)
rename supabase/migrations/{189_funnel_events_bot_and_internal_filters.sql => 194_funnel_events_bot_and_internal_filters.sql} (94%)
rename supabase/migrations/{190_bookings_total_price_suspect.sql => 195_bookings_total_price_suspect.sql} (92%)
create mode 100644 supabase/migrations/196_restore_coupon_corrupted_totals.sql
diff --git a/supabase/migrations/187_attribution_self_referral_backfill.sql b/supabase/migrations/192_attribution_self_referral_backfill.sql
similarity index 93%
rename from supabase/migrations/187_attribution_self_referral_backfill.sql
rename to supabase/migrations/192_attribution_self_referral_backfill.sql
index 9683d68e..a8568725 100644
--- a/supabase/migrations/187_attribution_self_referral_backfill.sql
+++ b/supabase/migrations/192_attribution_self_referral_backfill.sql
@@ -1,4 +1,9 @@
--- 187_attribution_self_referral_backfill.sql
+-- 192_attribution_self_referral_backfill.sql
+--
+-- ⚠ NUMBERING NOTE: applied to production on 2026-08-01 as `187`, then renumbered
+-- to 192 because a concurrent branch claimed 187-190 first. The SQL is unchanged.
+-- Rows written by it therefore carry migration = '187' — that tag is HISTORICAL
+-- and the reversal query at the bottom matches the DATA, not this filename.
--
-- Reclassify historical self-referrals out of the marketing attribution data.
--
diff --git a/supabase/migrations/188_profiles_is_internal.sql b/supabase/migrations/193_profiles_is_internal.sql
similarity index 92%
rename from supabase/migrations/188_profiles_is_internal.sql
rename to supabase/migrations/193_profiles_is_internal.sql
index 67c2c908..3e9f0ea0 100644
--- a/supabase/migrations/188_profiles_is_internal.sql
+++ b/supabase/migrations/193_profiles_is_internal.sql
@@ -1,4 +1,7 @@
--- 188_profiles_is_internal.sql
+-- 193_profiles_is_internal.sql
+--
+-- ⚠ NUMBERING NOTE: applied to production on 2026-08-01 as `188`, renumbered to
+-- 193 after a concurrent branch claimed 187-190. SQL unchanged.
--
-- Mark team/test accounts so the admin dashboards can report on the OUTSIDE
-- WORLD by default.
diff --git a/supabase/migrations/189_funnel_events_bot_and_internal_filters.sql b/supabase/migrations/194_funnel_events_bot_and_internal_filters.sql
similarity index 94%
rename from supabase/migrations/189_funnel_events_bot_and_internal_filters.sql
rename to supabase/migrations/194_funnel_events_bot_and_internal_filters.sql
index 7056aed6..0b8afde3 100644
--- a/supabase/migrations/189_funnel_events_bot_and_internal_filters.sql
+++ b/supabase/migrations/194_funnel_events_bot_and_internal_filters.sql
@@ -1,4 +1,7 @@
--- 189_funnel_events_bot_and_internal_filters.sql
+-- 194_funnel_events_bot_and_internal_filters.sql
+--
+-- ⚠ NUMBERING NOTE: applied to production on 2026-08-01 as `189`, renumbered to
+-- 194 after a concurrent branch claimed 187-190. SQL unchanged.
--
-- Two reporting filters for the first-party funnel stream:
-- • is_bot — stamped at ingest from the User-Agent (/api/track)
diff --git a/supabase/migrations/190_bookings_total_price_suspect.sql b/supabase/migrations/195_bookings_total_price_suspect.sql
similarity index 92%
rename from supabase/migrations/190_bookings_total_price_suspect.sql
rename to supabase/migrations/195_bookings_total_price_suspect.sql
index 5f4f196e..5344716d 100644
--- a/supabase/migrations/190_bookings_total_price_suspect.sql
+++ b/supabase/migrations/195_bookings_total_price_suspect.sql
@@ -1,4 +1,9 @@
--- 190_bookings_total_price_suspect.sql
+-- 195_bookings_total_price_suspect.sql
+--
+-- ⚠ NUMBERING NOTE: applied to production on 2026-08-01 as `190`, renumbered to
+-- 195 after a concurrent branch claimed 187-190. SQL unchanged. References to
+-- "the pre-190 coupon bug" below mean the ORIGINAL number and are left as-is so
+-- they match the column comment already live in the database.
--
-- Quarantine the bookings whose money columns were destroyed by the coupon bug,
-- WITHOUT rewriting them.
diff --git a/supabase/migrations/196_restore_coupon_corrupted_totals.sql b/supabase/migrations/196_restore_coupon_corrupted_totals.sql
new file mode 100644
index 00000000..af54301d
--- /dev/null
+++ b/supabase/migrations/196_restore_coupon_corrupted_totals.sql
@@ -0,0 +1,81 @@
+-- 196_restore_coupon_corrupted_totals.sql
+--
+-- ⚠ NUMBERING NOTE: applied to production on 2026-08-01 as `191`, renumbered to
+-- 196 after a concurrent branch claimed 187-190. SQL unchanged. Rows written by
+-- it carry migration = '191' — that tag is HISTORICAL, and the reversal query at
+-- the bottom matches the DATA, not this filename.
+--
+-- Restore the seven bookings whose money columns the coupon bug destroyed.
+--
+-- 195 (applied as 190) quarantined them because the repair looked like
+-- inference. It isn't:
+-- `subtotal` was never touched by the faulty write path and still holds the
+-- agreed deal on every affected row — and it agrees EXACTLY with
+-- round(provider_payout / 0.8) on all seven, the payout having been computed from
+-- the deal at booking time (COMMISSION_RATE = 0.2, lib/marketplace/fees.ts).
+-- Two independently-stored columns agreeing is a recovered value, not a guess.
+--
+-- Verified on prod before writing this (2026-08-01), all 7 rows:
+-- subtotal = round(provider_payout / 0.8) → true
+-- total_price = 0, service_fee = -provider_payout
+--
+-- Recovers ~$27,155 of real GMV and restores the correct 20% commission.
+--
+-- GUARDED: only rows where the two sources corroborate are touched. Anything
+-- flagged suspect that does NOT corroborate stays flagged and stays out of
+-- revenue — a future corruption with a different fingerprint must not be
+-- silently "restored" to a number nobody verified.
+--
+-- Reversible: prior values are captured in booking_money_restored first.
+
+create table if not exists public.booking_money_restored (
+ id bigserial primary key,
+ booking_id uuid not null,
+ old_total_price integer,
+ old_service_fee integer,
+ new_total_price integer,
+ new_service_fee integer,
+ source text not null default 'subtotal',
+ migration text not null default '191',
+ created_at timestamptz not null default now()
+);
+
+comment on table public.booking_money_restored is
+ 'Audit of booking money columns repaired by a migration. Holds the pre-repair '
+ 'values so 191 (coupon-corruption restore) can be reversed exactly.';
+
+create index if not exists booking_money_restored_booking_idx
+ on public.booking_money_restored (booking_id);
+
+alter table public.booking_money_restored enable row level security;
+
+-- Snapshot before touching anything.
+insert into public.booking_money_restored
+ (booking_id, old_total_price, old_service_fee, new_total_price, new_service_fee)
+select b.id, b.total_price, b.service_fee,
+ b.subtotal, b.subtotal - coalesce(b.provider_payout, 0)
+from public.bookings b
+where b.total_price_suspect
+ and b.subtotal > 0
+ and b.provider_payout > 0
+ and b.subtotal = round(b.provider_payout / 0.8);
+
+update public.bookings b
+set
+ total_price = b.subtotal,
+ -- Taskly's cut of the deal. Positive again, and equal to the 20% take rate.
+ service_fee = b.subtotal - coalesce(b.provider_payout, 0),
+ -- Restored and corroborated — it is no longer suspect. The column stays on the
+ -- table for any future case.
+ total_price_suspect = false
+where b.total_price_suspect
+ and b.subtotal > 0
+ and b.provider_payout > 0
+ and b.subtotal = round(b.provider_payout / 0.8);
+
+-- ── Reversal (do NOT run as part of the migration) ──────────────────────────
+-- update public.bookings b
+-- set total_price = r.old_total_price, service_fee = r.old_service_fee,
+-- total_price_suspect = true
+-- from public.booking_money_restored r
+-- where r.booking_id = b.id and r.migration = '191';
From dbbf32d3dd6c2bebe83b0cb2023e70ba63e8fa63 Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 21:45:18 +0530
Subject: [PATCH 4/9] Phase 2: first-party traffic measurement
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GA4 structurally cannot answer what /admin/traffic has to answer. It is
consent-gated — decline the banner and it records nothing — it cannot join
to our user_id, its realtime API has no hostName dimension so the market
tabs can never apply to the live counter, and it is sampled and delayed.
At our volume the consent gate alone probably costs most sessions. We
already own an exact, instant, joinable, region-stamped pipeline; this
extends it from funnels to traffic.
Migration 197 adds web_sessions + page_views, deliberately SEPARATE from
funnel_events whose narrow taxonomy validation is load-bearing and should
stay narrow. Migration 198 adds web_traffic_stats() — one round trip
returning every panel the page needs — and web_live_activity(), which can
answer "who is on the site right now" PER DOMAIN, the thing GA4 realtime
cannot do.
Identity is a first-party httpOnly device id set in proxy.ts on a page
request, not from the beacon: a first-ever visitor would otherwise have no
id on the very page view we most want to count. 400 days, no cross-site
capability, random UUID, no personal data. Founder-approved; cookie policy
copy follows in this PR.
Sessions end after 30 minutes idle, on a campaign change (else a second ad
click is credited to the first) and on a host change (one row cannot belong
to two markets). is_first_seen is decided at INSERT because "have we seen
this device before" is only cheap to answer at lookup time — and it is the
exact thing GA gets wrong for us, since every geo-redirect hop mints a
fresh client_id and every visitor looks new.
Bounce and duration are derived at read time, never stored, so tuning the
definition doesn't require a backfill.
lib/attribution/channel.ts lands here rather than in Phase 3 because the
ingest needs it: every session is classified at creation into exactly one
of paid/organic/social/referral/ai/direct/internal. Internal beats
everything — a team member clicking our own ad must not inflate paid.
Page views ride the existing /api/track endpoint, which already has the
rate limit, the dev-host drop and un-forgeable server-side region
resolution. A second endpoint would have to re-earn all three.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/api/track/route.ts | 71 ++++++++
components/GoogleAnalytics.tsx | 46 +++++
lib/analytics/device-cookie.ts | 15 ++
lib/analytics/web-session.ts | 158 ++++++++++++++++
lib/attribution/channel.ts | 108 +++++++++++
proxy.ts | 26 +++
supabase/migrations/197_web_sessions.sql | 92 ++++++++++
supabase/migrations/198_web_traffic_stats.sql | 170 ++++++++++++++++++
tests/attribution/channel.test.ts | 83 +++++++++
9 files changed, 769 insertions(+)
create mode 100644 lib/analytics/device-cookie.ts
create mode 100644 lib/analytics/web-session.ts
create mode 100644 lib/attribution/channel.ts
create mode 100644 supabase/migrations/197_web_sessions.sql
create mode 100644 supabase/migrations/198_web_traffic_stats.sql
create mode 100644 tests/attribution/channel.test.ts
diff --git a/app/api/track/route.ts b/app/api/track/route.ts
index 6a6eb76c..fe7a1db0 100644
--- a/app/api/track/route.ts
+++ b/app/api/track/route.ts
@@ -5,6 +5,8 @@ import { rateLimit, clientIp } from '@/lib/auth/rate-limit';
import { getRegion } from '@/lib/region/server';
import { isDevHost } from '@/lib/analytics/dev-traffic';
import { isBotUserAgent } from '@/lib/region/geo-route';
+import { DEVICE_COOKIE } from '@/lib/analytics/device-cookie';
+import { recordPageView, deviceCategory } from '@/lib/analytics/web-session';
// First-party funnel ingest. The browser's trackFunnel() (lib/analytics/funnel.ts)
// POSTs / sendBeacon's one row per step event here; we write it to
@@ -46,6 +48,67 @@ function intOrNull(v: unknown): number | null {
return Number.isFinite(n) ? Math.trunc(n) : null;
}
+/**
+ * First-party page view → web_sessions + page_views (migration 197).
+ *
+ * Everything that decides WHO this is comes from the request, never the client
+ * payload: the device id from an httpOnly cookie the page can't read or forge,
+ * the host from the Host header, geo from Cloudflare's edge headers, and the
+ * user from the session cookie. The client supplies only the path and title.
+ */
+async function handlePageView(request: Request, body: Record): Promise {
+ const host = request.headers.get('host') ?? '';
+ if (isDevHost(host)) return; // same rule as funnel events — never measure a dev machine
+
+ const cookie = request.headers.get('cookie') ?? '';
+ const deviceId = cookie.match(new RegExp(`(?:^|;\\s*)${DEVICE_COOKIE}=([^;]+)`))?.[1];
+ // No cookie yet means proxy.ts hasn't served this visitor a page — a beacon
+ // with no identity would create an orphan session that can never be joined to
+ // anything, so drop it rather than invent one.
+ if (!deviceId) return;
+
+ const path = str(body.path, 300);
+ if (!path) return;
+
+ let userId: string | null = null;
+ let isInternal = false;
+ try {
+ const supabase = await createClient();
+ const { data } = await supabase.auth.getUser();
+ userId = data.user?.id ?? null;
+ if (userId) {
+ const admin = createServiceRoleClient();
+ const { data: prof } = await admin
+ .from('profiles').select('is_internal').eq('id', userId).maybeSingle();
+ isInternal = prof?.is_internal === true;
+ }
+ } catch {
+ /* anonymous — fine */
+ }
+
+ const utmEntries = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']
+ .map((k) => [k, str((body.utm as Record | undefined)?.[k], 120)] as const)
+ .filter(([, v]) => v);
+
+ await recordPageView({
+ deviceId,
+ anonId: str(body.anonId, 64),
+ userId,
+ host: host.split(':')[0].toLowerCase(),
+ region: await getRegion(),
+ path,
+ referrer: str(body.referrer, 300),
+ utm: utmEntries.length ? Object.fromEntries(utmEntries) as Record : null,
+ deviceCategory: deviceCategory(request.headers.get('user-agent')),
+ // Cloudflare fronts all four zones, so geo is a header read — no lookup
+ // service, no IP stored.
+ country: request.headers.get('cf-ipcountry'),
+ city: request.headers.get('cf-ipcity'),
+ isInternal,
+ isBot: isBotUserAgent(request.headers.get('user-agent')),
+ });
+}
+
export async function POST(request: Request) {
// Cheap abuse guard — a misbehaving client (or bot) can't flood the table.
// Generous: a real funnel run is ~5-15 events.
@@ -60,6 +123,14 @@ export async function POST(request: Request) {
return new NextResponse(null, { status: 204 });
}
+ // Page views ride the SAME endpoint as funnel steps: it already has the rate
+ // limit, the dev-host drop and the un-forgeable server-side region resolution.
+ // A second endpoint would have to re-earn all three.
+ if (body.kind === 'page_view') {
+ await handlePageView(request, body);
+ return new NextResponse(null, { status: 204 });
+ }
+
const funnel = str(body.funnel, 40);
const step = str(body.step, 60);
const stepIndex = intOrNull(body.stepIndex);
diff --git a/components/GoogleAnalytics.tsx b/components/GoogleAnalytics.tsx
index ec075936..ea7c6b1e 100644
--- a/components/GoogleAnalytics.tsx
+++ b/components/GoogleAnalytics.tsx
@@ -128,6 +128,52 @@ export function GoogleAnalyticsPageViews() {
});
}, [pathname, region]);
+ // FIRST-PARTY page view → /api/track → web_sessions + page_views.
+ //
+ // Deliberately NOT gated on GA_ID or on cookie consent. That is the entire
+ // point: GA sees only visitors who accepted the banner, which at our volume
+ // is probably a minority, so its "active users" is a filtered subset we can
+ // neither inspect nor correct. This beacon is our own measurement of our own
+ // traffic on our own domain, keyed to an httpOnly first-party id.
+ //
+ // Everything identifying is resolved SERVER-side from the request; we send
+ // only where the visitor is and where they came from.
+ useEffect(() => {
+ if (isDevHost(window.location.hostname)) return;
+ try {
+ const params = new URLSearchParams(window.location.search);
+ const utm: Record = {};
+ for (const k of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
+ const v = params.get(k);
+ if (v) utm[k] = v;
+ }
+ const body = JSON.stringify({
+ kind: 'page_view',
+ path: pathname,
+ title: document.title,
+ // Only an EXTERNAL referrer is meaningful — an internal hop would
+ // reclassify a returning visitor's channel on every navigation.
+ referrer: document.referrer && !document.referrer.includes(window.location.host)
+ ? document.referrer
+ : null,
+ utm: Object.keys(utm).length ? utm : null,
+ });
+ const sent =
+ typeof navigator.sendBeacon === 'function' &&
+ navigator.sendBeacon('/api/track', new Blob([body], { type: 'application/json' }));
+ if (!sent) {
+ void fetch('/api/track', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body,
+ keepalive: true,
+ }).catch(() => {});
+ }
+ } catch {
+ /* measurement must never break a navigation */
+ }
+ }, [pathname]);
+
return null;
}
diff --git a/lib/analytics/device-cookie.ts b/lib/analytics/device-cookie.ts
new file mode 100644
index 00000000..6e231da1
--- /dev/null
+++ b/lib/analytics/device-cookie.ts
@@ -0,0 +1,15 @@
+// lib/analytics/device-cookie.ts
+//
+// The first-party device-id cookie, on its own so BOTH the Edge proxy and the
+// Node ingest can import it. Kept out of web-session.ts deliberately: that
+// module is `server-only` and pulls in the Supabase client, which has no place
+// in the Edge middleware bundle.
+//
+// PURE — two constants, no imports, no I/O.
+
+/** Cookie name. httpOnly: no client script reads it. */
+export const DEVICE_COOKIE = 'taskly_did';
+
+/** 400 days — Chrome's maximum. Anything longer is silently truncated, so
+ * asking for more would just make the code lie about its own retention. */
+export const DEVICE_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
diff --git a/lib/analytics/web-session.ts b/lib/analytics/web-session.ts
new file mode 100644
index 00000000..ad9dff80
--- /dev/null
+++ b/lib/analytics/web-session.ts
@@ -0,0 +1,158 @@
+import 'server-only';
+
+// lib/analytics/web-session.ts
+//
+// Server-side session resolution for first-party traffic measurement.
+// Companion to migration 197 (web_sessions + page_views).
+//
+// One job: given an incoming page-view beacon, decide whether it belongs to the
+// visitor's CURRENT session or starts a new one, and return the session id to
+// attach the page view to.
+//
+// Session rules (deliberately the industry-standard ones, so the numbers are
+// comparable to what anyone expects from an analytics tool):
+// • 30 minutes of inactivity ends a session.
+// • A CHANGE OF CAMPAIGN starts a new one, even mid-visit — otherwise a
+// visitor who clicks a second ad has their conversion credited to the first.
+// • A change of HOST starts a new one. Our geo redirects move people between
+// domains, and one row cannot belong to two markets.
+//
+// Everything here is best-effort: a failure returns null and the caller drops
+// the beacon. Measurement must never break a page load.
+
+import { createServiceRoleClient } from '@/lib/supabase/admin';
+import { classifyChannel, type Channel } from '@/lib/attribution/channel';
+
+/** Inactivity that ends a session. */
+export const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
+
+export interface SessionInput {
+ deviceId: string;
+ anonId?: string | null;
+ userId?: string | null;
+ host: string;
+ region: string;
+ path: string;
+ referrer?: string | null;
+ utm?: Record | null;
+ deviceCategory?: string | null;
+ country?: string | null;
+ city?: string | null;
+ isInternal?: boolean;
+ isBot?: boolean;
+}
+
+/** Three buckets, from the UA. Deliberately no UA-parsing dependency: the
+ * detail a library buys (exact browser, OS version) is not on any screen. */
+export function deviceCategory(ua: string | null | undefined): string {
+ if (!ua) return 'unknown';
+ const u = ua.toLowerCase();
+ if (/ipad|tablet|playbook|silk|kindle/.test(u)) return 'tablet';
+ if (/mobi|android|iphone|ipod|phone/.test(u)) return 'mobile';
+ return 'desktop';
+}
+
+/** Did the campaign change between two touches? A new campaign = a new session. */
+function utmChanged(prev: Record | null, next: Record | null): boolean {
+ const a = prev ?? {};
+ const b = next ?? {};
+ for (const k of ['utm_source', 'utm_medium', 'utm_campaign'] as const) {
+ if ((a[k] ?? null) !== (b[k] ?? null)) return true;
+ }
+ return false;
+}
+
+export interface ResolvedSession {
+ id: string;
+ isNew: boolean;
+ channel: Channel;
+}
+
+/**
+ * Find the visitor's live session or open a new one, then record the page view.
+ * Returns null when the store is unavailable — the caller drops the beacon.
+ */
+export async function recordPageView(input: SessionInput): Promise {
+ if (!process.env.SUPABASE_SERVICE_ROLE_KEY) return null;
+
+ try {
+ const admin = createServiceRoleClient();
+ const cutoff = new Date(Date.now() - SESSION_TIMEOUT_MS).toISOString();
+
+ // The visitor's most recent session, if it's still live.
+ const { data: openRows } = await admin
+ .from('web_sessions')
+ .select('id, utm, host, pageviews')
+ .eq('device_id', input.deviceId)
+ .gte('last_seen_at', cutoff)
+ .order('last_seen_at', { ascending: false })
+ .limit(1);
+
+ const open = openRows?.[0] ?? null;
+ const continues =
+ open &&
+ open.host === input.host &&
+ !utmChanged(open.utm as Record | null, input.utm ?? null);
+
+ if (continues) {
+ await admin
+ .from('web_sessions')
+ .update({
+ last_seen_at: new Date().toISOString(),
+ pageviews: (open.pageviews ?? 0) + 1,
+ // A visitor who signs in mid-session should stitch, not fork.
+ ...(input.userId ? { user_id: input.userId } : {}),
+ ...(input.anonId ? { anon_id: input.anonId } : {}),
+ })
+ .eq('id', open.id);
+
+ await admin.from('page_views').insert({ session_id: open.id, path: input.path });
+ return { id: open.id as string, isNew: false, channel: 'direct' };
+ }
+
+ // New session. "First seen" asks whether this DEVICE has ever appeared —
+ // cheap here, impossible to reconstruct later, and the honest basis for
+ // new-vs-returning.
+ const { count } = await admin
+ .from('web_sessions')
+ .select('id', { count: 'exact', head: true })
+ .eq('device_id', input.deviceId)
+ .limit(1);
+
+ const channel = classifyChannel({
+ utmSource: input.utm?.utm_source ?? null,
+ utmMedium: input.utm?.utm_medium ?? null,
+ referrer: input.referrer ?? null,
+ isInternal: input.isInternal,
+ });
+
+ const { data: created } = await admin
+ .from('web_sessions')
+ .insert({
+ device_id: input.deviceId,
+ anon_id: input.anonId ?? null,
+ user_id: input.userId ?? null,
+ host: input.host,
+ region: input.region,
+ is_first_seen: (count ?? 0) === 0,
+ channel,
+ utm: input.utm ?? null,
+ entry_path: input.path,
+ referrer: input.referrer ?? null,
+ device_category: input.deviceCategory ?? null,
+ country: input.country ?? null,
+ city: input.city ?? null,
+ is_internal: input.isInternal ?? false,
+ is_bot: input.isBot ?? false,
+ pageviews: 1,
+ })
+ .select('id')
+ .single();
+
+ if (!created) return null;
+ await admin.from('page_views').insert({ session_id: created.id, path: input.path });
+ return { id: created.id as string, isNew: true, channel };
+ } catch {
+ return null; // never break a page load over measurement
+ }
+}
diff --git a/lib/attribution/channel.ts b/lib/attribution/channel.ts
new file mode 100644
index 00000000..42da9fad
--- /dev/null
+++ b/lib/attribution/channel.ts
@@ -0,0 +1,108 @@
+// lib/attribution/channel.ts
+//
+// Every visit and every signup gets EXACTLY ONE channel. Never null, never
+// dropped from the report.
+//
+// This is the fix for the report's biggest lie by omission. /admin/utm scoped
+// every query with `.not('utm_source','is',null)`, so it showed the tagged
+// subset and presented it as the whole. After the self-referral cleanup that
+// subset is 4 of 53 signups — technically correct and completely useless.
+//
+// "Direct: 37" is a real number a founder can act on. A blank row is not.
+//
+// Order matters: the rules are tried top to bottom and the first match wins.
+// Paid before social, because a Facebook ad is paid traffic that happens to
+// carry a social source.
+//
+// PURE + edge-safe — no I/O, no React, no Node APIs — so proxy.ts, route
+// handlers and the admin reports all classify identically. One definition of
+// "organic" is the whole point; two would be worse than none.
+
+import { isOwnedHost } from './owned-hosts';
+
+export type Channel =
+ | 'paid'
+ | 'organic'
+ | 'social'
+ | 'referral'
+ | 'ai'
+ | 'direct'
+ | 'internal';
+
+/** Display order for the dashboard — acquisition value, roughly descending. */
+export const CHANNELS: Channel[] = [
+ 'organic', 'social', 'referral', 'ai', 'paid', 'direct', 'internal',
+];
+
+const PAID_MEDIUMS = /^(cpc|ppc|paid|paidsearch|paid_social|display|banner|cpm|retargeting)$/i;
+
+const SOCIAL_SOURCES = new Set([
+ 'reddit', 'quora', 'twitter', 'x', 'instagram', 'facebook', 'tiktok',
+ 'linkedin', 'youtube', 'pinterest', 'whatsapp', 'telegram', 'threads',
+]);
+
+const SEARCH_HOSTS = /(^|\.)(google|bing|duckduckgo|yahoo|ecosia|brave|baidu|yandex)\./i;
+
+// Same engines the /admin/traffic AI-referral panel already tracks, so the two
+// screens agree on what "AI" means.
+const AI_HOSTS = /(chatgpt|openai|perplexity|copilot|gemini|claude|anthropic)\./i;
+
+export interface ChannelInput {
+ utmSource?: string | null;
+ utmMedium?: string | null;
+ referrer?: string | null;
+ /** profiles.is_internal — a team account is never a marketing channel. */
+ isInternal?: boolean;
+}
+
+function hostOf(referrer: string | null | undefined): string | null {
+ if (!referrer) return null;
+ try {
+ return new URL(referrer).hostname.toLowerCase();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Classify one touch into exactly one channel.
+ *
+ * `internal` wins over everything: a team member arriving via a campaign link is
+ * still us, and letting them count as `paid` would overstate the one number a
+ * founder is most likely to act on.
+ */
+export function classifyChannel(input: ChannelInput): Channel {
+ if (input.isInternal) return 'internal';
+
+ const source = input.utmSource?.trim().toLowerCase() || null;
+ const medium = input.utmMedium?.trim().toLowerCase() || null;
+ const host = hostOf(input.referrer);
+
+ // 1. Explicitly tagged paid traffic.
+ if (medium && PAID_MEDIUMS.test(medium)) return 'paid';
+
+ // 2. A UTM source that isn't us. Owned hosts fall through to referrer/direct —
+ // the same rule inferSourceMedium() applies, so a cross-domain hop is never
+ // a channel.
+ if (source && !isOwnedHost(source)) {
+ if (SOCIAL_SOURCES.has(source)) return 'social';
+ if (medium === 'email' || source === 'email') return 'referral';
+ if (medium === 'organic') return 'organic';
+ return 'referral';
+ }
+
+ // 3. Untagged arrivals, classified from where they came from.
+ if (host && !isOwnedHost(host)) {
+ if (AI_HOSTS.test(host)) return 'ai'; // before search: gemini/copilot are Google-adjacent
+ if (SEARCH_HOSTS.test(host)) return 'organic';
+ const bare = host.replace(/^www\./, '').split('.')[0];
+ if (SOCIAL_SOURCES.has(bare)) return 'social';
+ return 'referral';
+ }
+
+ // 4. No tag, no usable referrer, or a referrer that was us. Typed the URL,
+ // a bookmark, an app, or a stripped referrer. This is the honest floor —
+ // and at pre-traction volume it is usually the biggest bucket, which is
+ // itself the finding.
+ return 'direct';
+}
diff --git a/proxy.ts b/proxy.ts
index 6b157146..964ff704 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -14,6 +14,7 @@ import {
mergeAttribution,
touchChanged,
} from '@/lib/attribution';
+import { DEVICE_COOKIE, DEVICE_COOKIE_MAX_AGE } from '@/lib/analytics/device-cookie';
import { regionFromHost, isKnownRegionHost, geoRegionFromCountry } from '@/lib/region/config';
import { geoRedirectTarget, isBotUserAgent } from '@/lib/region/geo-route';
import { indiaMarketplaceLive, multiRegionLive, regionRoutingLive, regionRedirectsLive } from '@/lib/flags';
@@ -48,6 +49,30 @@ function captureAttribution(request: NextRequest, res: NextResponse): void {
// taskly_attr: first immutable, last advances on every tagged/organic visit.
// buildTouch returns null for pure internal/direct navigation, so this never
// churns the cookie on ordinary page views.
+// taskly_did: the first-party measurement device id (migration 197).
+//
+// Set HERE rather than from /api/track because a page request is the one moment
+// we are guaranteed to reach the browser with a Set-Cookie it will honour —
+// beacons are fire-and-forget and a first-ever visitor would otherwise have no
+// id on the very page view we most want to count.
+//
+// httpOnly: no client script needs it, and keeping it out of JS means an XSS
+// can't read it. It is a random UUID with no personal data and no cross-site
+// capability — it is only ever sent back to the origin that set it.
+function captureDevice(request: NextRequest, res: NextResponse): void {
+ const { pathname } = request.nextUrl;
+ if (pathname.startsWith('/api/')) return; // pages only — beacons inherit it
+ if (request.cookies.get(DEVICE_COOKIE)) return; // first touch wins, then it persists
+
+ res.cookies.set(DEVICE_COOKIE, crypto.randomUUID(), {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: DEVICE_COOKIE_MAX_AGE,
+ path: '/',
+ });
+}
+
function captureTouch(request: NextRequest, res: NextResponse): void {
const { pathname, searchParams } = request.nextUrl;
if (pathname.startsWith('/api/')) return;
@@ -311,6 +336,7 @@ export async function proxy(request: NextRequest) {
const res = await updateSession(request);
captureAttribution(request, res);
captureTouch(request, res);
+ captureDevice(request, res);
captureRegion(request, res);
captureGeo(request, res);
return res;
diff --git a/supabase/migrations/197_web_sessions.sql b/supabase/migrations/197_web_sessions.sql
new file mode 100644
index 00000000..9d439f3f
--- /dev/null
+++ b/supabase/migrations/197_web_sessions.sql
@@ -0,0 +1,92 @@
+-- 197_web_sessions.sql
+--
+-- First-party traffic measurement: our own sessions + page views.
+--
+-- WHY NOT JUST FIX GA4. GA4 structurally cannot answer what this dashboard has
+-- to answer. It is consent-gated (decline the banner and we record nothing at
+-- all), it cannot join to our user_id, its realtime API has no hostName
+-- dimension so the market tabs can never apply to the live counter, and it is
+-- sampled and hours-delayed. At current volume the consent gate alone probably
+-- costs a majority of sessions. We already own an exact, instant, joinable,
+-- server-region-stamped pipeline in funnel_events — this extends it to traffic.
+--
+-- DELIBERATELY SEPARATE FROM funnel_events. That table's narrow taxonomy
+-- validation is load-bearing (an unknown funnel name is dropped at ingest); page
+-- views have no such taxonomy and would force it open. Two tables, one ingest.
+--
+-- Identity: `device_id` is a first-party httpOnly cookie, 400 days, set
+-- server-side. It is NOT anon_id — anon_id is consent-gated and lives in
+-- localStorage. device_id has no cross-site capability: it is readable only by
+-- our own origin, never sent anywhere else, and is a random UUID carrying no
+-- personal data. Founder-approved 2026-08-01, cookie policy updated in the same
+-- PR.
+
+create table if not exists public.web_sessions (
+ id uuid primary key default gen_random_uuid(),
+ device_id text not null,
+ anon_id text,
+ user_id uuid references auth.users(id) on delete set null,
+
+ -- The FULL hostname, not the resolved region. This is what makes the domain
+ -- comparison possible at all: region collapses .ca/.in/.ai into a market, and
+ -- the whole point of the domain control is to keep them apart.
+ host text not null,
+ region text not null,
+
+ -- Decided at INSERT, not derived later: "have we ever seen this device
+ -- before?" is only cheap to answer at the moment we look it up. This is the
+ -- specific thing GA4 gets wrong for us, because every geo-redirect hop mints
+ -- a fresh client_id and every visitor looks new.
+ is_first_seen boolean not null,
+
+ channel text not null,
+ utm jsonb,
+ entry_path text not null,
+ referrer text,
+
+ device_category text,
+ country text,
+ city text,
+
+ is_internal boolean not null default false,
+ is_bot boolean not null default false,
+
+ started_at timestamptz not null default now(),
+ last_seen_at timestamptz not null default now(),
+ pageviews integer not null default 0
+);
+
+comment on table public.web_sessions is
+ 'First-party web sessions. One row per visit attempt (30 min inactivity or a '
+ 'UTM change starts a new one). Source of truth for /admin/traffic — exact, '
+ 'unsampled, no consent gate, joinable to user_id.';
+comment on column public.web_sessions.is_first_seen is
+ 'No prior session existed for this device_id. Gives honest new-vs-returning.';
+comment on column public.web_sessions.host is
+ 'Full hostname as requested. The domain dimension GA4 cannot segment reliably.';
+
+create index if not exists web_sessions_started_idx on public.web_sessions (started_at desc);
+create index if not exists web_sessions_host_started_idx on public.web_sessions (host, started_at desc);
+create index if not exists web_sessions_device_idx on public.web_sessions (device_id, started_at desc);
+create index if not exists web_sessions_user_idx on public.web_sessions (user_id) where user_id is not null;
+
+create table if not exists public.page_views (
+ id bigserial primary key,
+ session_id uuid not null references public.web_sessions(id) on delete cascade,
+ path text not null,
+ title text,
+ ts timestamptz not null default now(),
+ dwell_ms integer
+);
+
+comment on table public.page_views is
+ 'One row per page view, joined to its web_session. Bounce and duration are '
+ 'DERIVED at read time from these + the session row, never stored.';
+
+create index if not exists page_views_session_idx on public.page_views (session_id, ts);
+create index if not exists page_views_ts_idx on public.page_views (ts desc);
+
+-- Same posture as funnel_events: RLS on with NO policies => service-role only.
+-- Admin reads go through the RPC (198), never direct table access.
+alter table public.web_sessions enable row level security;
+alter table public.page_views enable row level security;
diff --git a/supabase/migrations/198_web_traffic_stats.sql b/supabase/migrations/198_web_traffic_stats.sql
new file mode 100644
index 00000000..50ed40fb
--- /dev/null
+++ b/supabase/migrations/198_web_traffic_stats.sql
@@ -0,0 +1,170 @@
+-- 198_web_traffic_stats.sql
+--
+-- The read path for /admin/traffic. ONE round trip returns everything the page
+-- needs, so the dashboard stops depending on fourteen parallel GA API calls.
+--
+-- Mirrors the panel list in docs/ANALYTICS_MAP.md §2 one-for-one, so the switch
+-- off GA4 loses nothing from the screen.
+--
+-- Derived, never stored (per 197):
+-- • bounce — a session with exactly 1 pageview that lasted < 10s. Storing
+-- it would freeze a definition we may want to tune.
+-- • duration — last_seen_at - started_at.
+-- • users — distinct device_id, NOT row count. A returning visitor is one
+-- user across many sessions; that distinction is the entire
+-- reason this pipeline exists.
+--
+-- p_host is the FULL hostname ('tasklyanything.ca'), null = every domain. The
+-- domain control on the dashboard passes it straight through, which is why that
+-- control can now apply to every panel — including the live counter, something
+-- GA4's realtime API could never do.
+
+create or replace function public.web_traffic_stats(
+ p_since timestamptz,
+ p_until timestamptz default null,
+ p_host text default null,
+ p_exclude_internal boolean default true,
+ p_exclude_bots boolean default true,
+ p_limit integer default 10
+)
+returns jsonb
+language sql
+stable
+set search_path = public
+as $$
+ with s as (
+ select ws.*,
+ (ws.last_seen_at - ws.started_at) as duration,
+ (ws.pageviews <= 1 and ws.last_seen_at - ws.started_at < interval '10 seconds') as bounced
+ from public.web_sessions ws
+ where ws.started_at >= p_since
+ and (p_until is null or ws.started_at < p_until)
+ and (p_host is null or ws.host = p_host)
+ and (not p_exclude_internal or ws.is_internal = false)
+ and (not p_exclude_bots or ws.is_bot = false)
+ ),
+ pv as (
+ select p.* from public.page_views p join s on s.id = p.session_id
+ )
+ select jsonb_build_object(
+ 'summary', jsonb_build_object(
+ 'users', (select count(distinct device_id) from s),
+ 'sessions', (select count(*) from s),
+ 'pageviews', (select coalesce(sum(pageviews), 0) from s),
+ 'avgDuration',(select coalesce(round(avg(extract(epoch from duration)))::int, 0) from s),
+ 'bounceRate', (select case when count(*) = 0 then 0
+ else round(count(*) filter (where bounced)::numeric / count(*), 4) end from s),
+ 'newUsers', (select count(distinct device_id) from s where is_first_seen)
+ ),
+ 'byDay', coalesce((
+ select jsonb_agg(jsonb_build_object('date', d, 'users', u, 'sessions', n, 'pageviews', pvs) order by d)
+ from (
+ select started_at::date as d, count(distinct device_id) u, count(*) n, coalesce(sum(pageviews),0) pvs
+ from s group by 1
+ ) g
+ ), '[]'::jsonb),
+ -- Always ALL hosts, never filtered by p_host: this powers the market
+ -- comparison chart, whose whole purpose is to show the domains against
+ -- each other.
+ 'byHost', coalesce((
+ select jsonb_agg(jsonb_build_object('host', host, 'users', u, 'sessions', n) order by n desc)
+ from (
+ select ws.host, count(distinct ws.device_id) u, count(*) n
+ from public.web_sessions ws
+ where ws.started_at >= p_since
+ and (p_until is null or ws.started_at < p_until)
+ and (not p_exclude_internal or ws.is_internal = false)
+ and (not p_exclude_bots or ws.is_bot = false)
+ group by 1
+ ) g
+ ), '[]'::jsonb),
+ 'channels', coalesce((
+ select jsonb_agg(jsonb_build_object('channel', channel, 'sessions', n, 'users', u) order by n desc)
+ from (select channel, count(*) n, count(distinct device_id) u from s group by 1) g
+ ), '[]'::jsonb),
+ 'topPages', coalesce((
+ select jsonb_agg(jsonb_build_object('path', path, 'views', n) order by n desc)
+ from (select path, count(*) n from pv group by 1 order by n desc limit p_limit) g
+ ), '[]'::jsonb),
+ 'landingPages', coalesce((
+ select jsonb_agg(jsonb_build_object('path', entry_path, 'sessions', n) order by n desc)
+ from (select entry_path, count(*) n from s group by 1 order by n desc limit p_limit) g
+ ), '[]'::jsonb),
+ 'devices', coalesce((
+ select jsonb_agg(jsonb_build_object('device', coalesce(device_category,'unknown'), 'users', u) order by u desc)
+ from (select device_category, count(distinct device_id) u from s group by 1) g
+ ), '[]'::jsonb),
+ 'countries', coalesce((
+ select jsonb_agg(jsonb_build_object('country', coalesce(country,'unknown'), 'users', u) order by u desc)
+ from (select country, count(distinct device_id) u from s group by 1 order by 2 desc limit p_limit) g
+ ), '[]'::jsonb),
+ 'cities', coalesce((
+ select jsonb_agg(jsonb_build_object('city', coalesce(city,'unknown'), 'users', u) order by u desc)
+ from (select city, count(distinct device_id) u from s group by 1 order by 2 desc limit p_limit) g
+ ), '[]'::jsonb),
+ 'newVsReturning', jsonb_build_object(
+ 'new', (select count(distinct device_id) from s where is_first_seen),
+ 'returning', (select count(distinct device_id) from s where not is_first_seen)
+ ),
+ -- What the default lens is hiding, so the UI can SAY it rather than imply
+ -- the numbers are everything.
+ 'excluded', jsonb_build_object(
+ 'internalSessions', (select count(*) from public.web_sessions ws
+ where ws.started_at >= p_since
+ and (p_until is null or ws.started_at < p_until)
+ and (p_host is null or ws.host = p_host)
+ and ws.is_internal),
+ 'botSessions', (select count(*) from public.web_sessions ws
+ where ws.started_at >= p_since
+ and (p_until is null or ws.started_at < p_until)
+ and (p_host is null or ws.host = p_host)
+ and ws.is_bot)
+ )
+ );
+$$;
+
+revoke all on function public.web_traffic_stats(timestamptz, timestamptz, text, boolean, boolean, integer) from public, anon;
+grant execute on function public.web_traffic_stats(timestamptz, timestamptz, text, boolean, boolean, integer) to authenticated, service_role;
+
+-- ── Live activity feed + realtime counter ───────────────────────────────────
+-- "How many people are on the site right now", answerable per DOMAIN — the
+-- thing GA4's realtime API cannot do because hostName is not a realtime
+-- dimension there.
+create or replace function public.web_live_activity(
+ p_host text default null,
+ p_minutes integer default 5,
+ p_limit integer default 100
+)
+returns jsonb
+language sql
+stable
+set search_path = public
+as $$
+ with live as (
+ select * from public.web_sessions
+ where last_seen_at >= now() - make_interval(mins => p_minutes)
+ and (p_host is null or host = p_host)
+ and not is_internal and not is_bot
+ )
+ select jsonb_build_object(
+ 'activeUsers', (select count(distinct device_id) from live),
+ 'byHost', coalesce((
+ select jsonb_agg(jsonb_build_object('host', host, 'users', u) order by u desc)
+ from (select host, count(distinct device_id) u from live group by 1) g
+ ), '[]'::jsonb),
+ 'recent', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'path', p.path, 'host', l.host, 'region', l.region,
+ 'signedIn', l.user_id is not null, 'channel', l.channel, 'ts', p.ts
+ ) order by p.ts desc)
+ from (
+ select p.* from public.page_views p
+ join live l2 on l2.id = p.session_id
+ order by p.ts desc limit p_limit
+ ) p join live l on l.id = p.session_id
+ ), '[]'::jsonb)
+ );
+$$;
+
+revoke all on function public.web_live_activity(text, integer, integer) from public, anon;
+grant execute on function public.web_live_activity(text, integer, integer) to authenticated, service_role;
diff --git a/tests/attribution/channel.test.ts b/tests/attribution/channel.test.ts
new file mode 100644
index 00000000..545fd760
--- /dev/null
+++ b/tests/attribution/channel.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest';
+import { classifyChannel, CHANNELS, type Channel } from '@/lib/attribution/channel';
+
+// The contract that makes /admin/utm report 100% instead of a tagged subset:
+// every input gets exactly one channel, and it is never null.
+
+const ref = (host: string) => `https://${host}/some/page`;
+
+describe('classifyChannel — every visit lands in exactly one bucket', () => {
+ it('never returns null or an unknown channel', () => {
+ const inputs = [
+ {},
+ { referrer: 'not a url' },
+ { utmSource: '', utmMedium: '' },
+ { referrer: ref('example.com') },
+ { utmSource: 'reddit', utmMedium: 'social' },
+ ];
+ for (const i of inputs) {
+ const c = classifyChannel(i);
+ expect(CHANNELS, JSON.stringify(i)).toContain(c);
+ }
+ });
+
+ it('internal beats everything, including a paid campaign', () => {
+ // A team member clicking our own ad must not inflate paid performance.
+ expect(classifyChannel({ isInternal: true, utmMedium: 'cpc', utmSource: 'google' }))
+ .toBe('internal');
+ });
+
+ it('classifies paid before social', () => {
+ // A Facebook ad is paid traffic that happens to carry a social source.
+ expect(classifyChannel({ utmSource: 'facebook', utmMedium: 'cpc' })).toBe('paid');
+ expect(classifyChannel({ utmSource: 'facebook', utmMedium: 'social' })).toBe('social');
+ });
+
+ it('recognises the paid mediums', () => {
+ for (const m of ['cpc', 'ppc', 'paid', 'display', 'cpm', 'retargeting', 'PPC']) {
+ expect(classifyChannel({ utmSource: 'x', utmMedium: m }), m).toBe('paid');
+ }
+ });
+
+ it('reads search engines as organic', () => {
+ for (const h of ['www.google.com', 'google.co.in', 'bing.com', 'duckduckgo.com', 'yandex.ru']) {
+ expect(classifyChannel({ referrer: ref(h) }), h).toBe('organic');
+ }
+ });
+
+ it('reads AI engines as ai, not organic', () => {
+ // Checked BEFORE search on purpose — gemini/copilot are Google-adjacent.
+ for (const h of ['chatgpt.com', 'www.perplexity.ai', 'copilot.microsoft.com', 'claude.ai']) {
+ expect(classifyChannel({ referrer: ref(h) }), h).toBe('ai');
+ }
+ });
+
+ it('reads social hosts as social', () => {
+ for (const h of ['www.reddit.com', 'instagram.com', 'linkedin.com', 'quora.com']) {
+ expect(classifyChannel({ referrer: ref(h) }), h).toBe('social');
+ }
+ });
+
+ it('reads an unknown external site as referral', () => {
+ expect(classifyChannel({ referrer: ref('someblog.example') })).toBe('referral');
+ });
+
+ it('treats OUR OWN domains as direct, never referral', () => {
+ // The Phase 1 bug, guarded at the channel layer too: a geo-redirect hop or a
+ // gateway hand-off is not a marketing channel.
+ for (const h of ['tasklyanything.ca', 'www.tasklyanything.ai', 'taskly.ca', 'tasklyanything.in']) {
+ expect(classifyChannel({ referrer: ref(h) }), h).toBe('direct');
+ expect(classifyChannel({ utmSource: h }), h).toBe('direct');
+ }
+ });
+
+ it('falls back to direct for no signal at all', () => {
+ expect(classifyChannel({})).toBe('direct');
+ expect(classifyChannel({ referrer: null, utmSource: null })).toBe('direct');
+ expect(classifyChannel({ referrer: 'javascript:void(0)' })).toBe('direct');
+ });
+
+ it('is case- and whitespace-insensitive on utm values', () => {
+ expect(classifyChannel({ utmSource: ' REDDIT ', utmMedium: 'Social' })).toBe('social');
+ });
+});
From bef853ca8e7d68288724dd004facb6a51dc8b4c3 Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 21:46:55 +0530
Subject: [PATCH 5/9] Phase 3: attribution that reports 100%, not a tagged
subset
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/admin/utm scoped every query with .not('utm_source','is',null), so it
showed the tagged rows and presented them as the whole. After the Phase 1
self-referral cleanup that is 4 of 53 signups — technically correct and
completely useless for deciding anything.
Drops the null filter and classifies every signup instead: paid, organic,
social, referral, ai, direct or internal, exactly one each, never null.
"Direct: 37" is a number a founder can act on. A blank row is not.
sources[] and campaigns[] are unchanged and still describe the tagged
subset — that remains the right view for judging one campaign. The
response now carries totalSignups and taggedSignups alongside them so the
UI can label the difference, because the two tables legitimately disagree
on their totals and hiding that would be the same sin in a new place.
Also fixes signups30/tasks30, which used a hardcoded 30-day cutoff inside
whatever range was selected — so on any window of 30 days or less the
column equalled its own total and told you nothing. Now the most recent
third of the selected window, with recentLabel so the UI stops claiming
"30d" on a one-day range.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/api/admin/utm/route.ts | 59 +++++++++++++++++++++++++++++++++-----
1 file changed, 52 insertions(+), 7 deletions(-)
diff --git a/app/api/admin/utm/route.ts b/app/api/admin/utm/route.ts
index 099fb76a..3df2075e 100644
--- a/app/api/admin/utm/route.ts
+++ b/app/api/admin/utm/route.ts
@@ -6,6 +6,7 @@ import { shortName } from '@/lib/marketplace/types';
import { coerceRegionParam } from '@/lib/region/config';
import { coerceRangeKey, resolveRange } from '@/lib/analytics/date-range';
import { audienceFromParams } from '@/lib/analytics/audience';
+import { classifyChannel, CHANNELS, type Channel } from '@/lib/attribution/channel';
// GET /api/admin/utm — marketing attribution report (admin-only).
//
@@ -75,8 +76,15 @@ export async function GET(request: Request) {
}
const admin = createServiceRoleClient();
- const cutoff = Date.now() - 30 * DAY;
- const within30 = (ts: string | null) => (ts ? new Date(ts).getTime() >= cutoff : false);
+ // "Recent" = the most recent THIRD of the selected window, not a hardcoded 30
+ // days. The old fixed cutoff made these columns identical to their totals on
+ // any range of 30 days or less — so on "Today" every row read `5 / 5` and the
+ // column said nothing. A relative slice keeps the "is this accelerating?"
+ // signal meaningful at every range.
+ const winMs = new Date(range.until).getTime() - new Date(range.since).getTime();
+ const recentFrom = new Date(range.until).getTime() - Math.max(winMs / 3, DAY);
+ const within30 = (ts: string | null) => (ts ? new Date(ts).getTime() >= recentFrom : false);
+ const recentLabel = winMs <= DAY * 1.5 ? 'last 8h' : `last ${Math.round(winMs / 3 / DAY)}d`;
// The account ids in the selected market (null = all markets, no filtering).
let regionIds: Set | null = null;
@@ -131,18 +139,20 @@ export async function GET(request: Request) {
{ data: membersRaw, error: e3 },
{ data: bookings, error: e4 },
] = await Promise.all([
+ // NO utm_source filter. The report used to scope every query to tagged
+ // rows and then present that subset as the whole — after the self-referral
+ // cleanup that is 4 of 53 signups, which is correct and useless.
+ // `referrer` comes along so an untagged signup can still be classified.
admin
.from('customer_profiles')
- .select('id, utm_source, utm_medium, utm_campaign, created_at')
- .not('utm_source', 'is', null)
+ .select('id, utm_source, utm_medium, utm_campaign, referrer, created_at')
.gte('created_at', range.since)
.lt('created_at', range.until)
.limit(10000),
tasksQ,
admin
.from('customer_profiles')
- .select('id, utm_source, utm_medium, utm_campaign, landing_page, created_at')
- .not('utm_source', 'is', null)
+ .select('id, utm_source, utm_medium, utm_campaign, landing_page, referrer, created_at')
.gte('created_at', range.since)
.lt('created_at', range.until)
.order('created_at', { ascending: false })
@@ -166,9 +176,35 @@ export async function GET(request: Request) {
// team accounts. Both are id-set filters, so they compose.
const keep = (id: string) =>
(!regionIds || regionIds.has(id)) && !internalIds.has(id);
- const signups = (signupsRaw ?? []).filter((r) => keep(r.id as string));
+ const allSignups = (signupsRaw ?? []).filter((r) => keep(r.id as string));
const members = (membersRaw ?? []).filter((r) => keep(r.id as string)).slice(0, 50);
+ // ── Channel rollup: EVERY signup, not just the tagged ones ─────────────
+ // This is the headline table. "Direct: 37" is a number a founder can act on;
+ // a report that silently omits 49 of 53 signups is not. The source/campaign
+ // tables below still describe the tagged subset — that's the right view for
+ // judging one campaign, but it is not the whole picture and no longer
+ // pretends to be.
+ const channelMap = new Map();
+ for (const c of CHANNELS) channelMap.set(c, { channel: c, signups: 0 });
+ for (const r of allSignups) {
+ const ch = classifyChannel({
+ utmSource: r.utm_source,
+ utmMedium: r.utm_medium,
+ referrer: r.referrer,
+ // Team accounts were already filtered out above when the lens is on;
+ // when it's off they should still land in their own bucket rather than
+ // masquerading as a marketing channel.
+ isInternal: internalIds.has(r.id as string),
+ });
+ channelMap.get(ch)!.signups += 1;
+ }
+ const channels = [...channelMap.values()].filter((c) => c.signups > 0);
+ const totalSignups = allSignups.length;
+
+ // The tagged subset drives the source/campaign tables below.
+ const signups = allSignups.filter((r) => r.utm_source);
+
// ── Rollups by source ──────────────────────────────────────────────────
const srcMap = new Map();
const getSrc = (s: string | null, m: string | null) => {
@@ -261,6 +297,15 @@ export async function GET(request: Request) {
region,
audience,
dateRange: { key: range.key, label: range.label, days: range.days },
+ // 100% of signups in the window, one channel each. `sources`/`campaigns`
+ // below cover only the tagged ones — the UI must label that difference,
+ // because the two tables legitimately disagree on their totals.
+ channels,
+ totalSignups,
+ taggedSignups: signups.length,
+ // What the `*30` columns actually mean for THIS range, so the UI can label
+ // them honestly instead of always claiming "30d".
+ recentLabel,
sources,
campaigns,
totals,
From e2799025bc771132dbba1f7accf4d8b3970340cc Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 21:50:05 +0530
Subject: [PATCH 6/9] Phase 4 (1/2): honesty furniture + an always-on domain
filter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three shared pieces in _components/AnalyticsControls, wired into Funnels
first because that screen is entirely first-party and shows the effect
most clearly.
DomainTabs is deliberately NOT gated on multiRegionLive(). That flag
governs hreflang and the public country switcher — whether Google should
be told the markets relate to each other. It has nothing to do with
whether an admin may compare domains. While it read false, every market
tab on every analytics screen was hidden, so there was no way to see
per-domain numbers at all even though all three domains were live and
serving traffic. Reporting is not an SEO launch gate.
DataQualityBar is a persistent strip, not a tooltip. Two pipelines feed
these screens and they do not agree: GA is consent-gated and sampled, a
strict undercount; first-party is neither. Both are correct and they
answer different questions, so every card now states which one it came
from rather than leaving the reader to assume they are comparable.
AudienceToggle defaults OFF. Showing the outside world should be the
default and seeing our own team should be a decision — that accident is
what made these screens unbelievable in the first place.
Also adds the monotonicity footnote to Funnels. Counts are corrected
upward from the deepest step reached, because the wizards allow going
back and resuming a saved draft mid-flow. At low volume that makes
several steps read the same number, which looks broken and isn't. Hiding
that caveat is the difference between a chart and a claim.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../admin/_components/AnalyticsControls.tsx | 236 ++++++++++++++++++
app/(admin)/admin/funnels/page.tsx | 77 +++---
2 files changed, 267 insertions(+), 46 deletions(-)
create mode 100644 app/(admin)/admin/_components/AnalyticsControls.tsx
diff --git a/app/(admin)/admin/_components/AnalyticsControls.tsx b/app/(admin)/admin/_components/AnalyticsControls.tsx
new file mode 100644
index 00000000..536b8102
--- /dev/null
+++ b/app/(admin)/admin/_components/AnalyticsControls.tsx
@@ -0,0 +1,236 @@
+'use client';
+
+// Shared controls + honesty furniture for every analytics screen.
+//
+// The dashboards did not feel real partly because nothing on them said how much
+// to trust them. A number with no provenance is a claim; a number that states
+// its pipeline, its exclusions and its window is evidence. That is the whole
+// job of this file.
+//
+// Three pieces, used together at the top of each analytics page:
+// • — persistent strip naming the pipeline + exclusions
+// • — All · .ca · .in · .ai, applying to every panel
+// • — "Include team traffic", off by default
+
+import { cn } from '@/lib/utils';
+import { REGIONS, ALL_REGIONS, type Region } from '@/lib/region/config';
+
+const GOLD = '#B8873A';
+
+// ──────────────────────────────────────────────────────────────────────────
+// DomainTabs
+// ──────────────────────────────────────────────────────────────────────────
+
+export type DomainKey = 'all' | Region;
+
+/**
+ * Domain filter. DELIBERATELY NOT gated on multiRegionLive().
+ *
+ * That flag governs hreflang and the public country switcher — whether Google
+ * should be told the markets relate to each other. It has nothing to do with
+ * whether an admin may compare traffic across domains. While it read false,
+ * every market tab on every analytics screen was hidden, so there was no way to
+ * see per-domain numbers at all even though all three domains were live and
+ * serving. Reporting is not an SEO launch gate.
+ */
+export function DomainTabs({
+ value,
+ onChange,
+ compact,
+}: {
+ value: DomainKey;
+ onChange: (v: DomainKey) => void;
+ compact?: boolean;
+}) {
+ const tabs: { key: DomainKey; label: string; hint: string }[] = [
+ { key: 'all', label: 'All', hint: 'Every domain' },
+ ...ALL_REGIONS.map((r) => ({
+ key: r as DomainKey,
+ label: compact ? REGIONS[r].flag : `${REGIONS[r].flag} ${REGIONS[r].host.split('.').pop()!.toUpperCase()}`,
+ hint: REGIONS[r].host,
+ })),
+ ];
+
+ return (
+
+ {tabs.map((t) => {
+ const active = value === t.key;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// ──────────────────────────────────────────────────────────────────────────
+// AudienceToggle
+// ──────────────────────────────────────────────────────────────────────────
+
+/**
+ * Off by default, on purpose. The dashboard should show the outside world
+ * unless you ask otherwise — seeing our own team in the numbers ought to be a
+ * decision, not an accident, and that accident is why these screens stopped
+ * being believable.
+ */
+export function AudienceToggle({
+ includeTeam,
+ onChange,
+ excludedCount,
+}: {
+ includeTeam: boolean;
+ onChange: (v: boolean) => void;
+ excludedCount?: number;
+}) {
+ return (
+
+ );
+}
+
+// ──────────────────────────────────────────────────────────────────────────
+// DataQualityBar
+// ──────────────────────────────────────────────────────────────────────────
+
+export type Pipeline = 'first-party' | 'google-analytics';
+
+/**
+ * A persistent strip, not a tooltip.
+ *
+ * Two pipelines feed these screens and they do not agree — GA is consent-gated
+ * and sampled (a strict undercount), first-party is neither (an overcount
+ * relative to GA). Both are correct; they answer different questions. Rendering
+ * two conflicting numbers without saying which pipeline each came from is how a
+ * dashboard loses its reader, so every card carries its provenance.
+ */
+export function DataQualityBar({
+ pipeline,
+ excludedTeam,
+ excludedBots,
+ includeTeam,
+ note,
+}: {
+ pipeline: Pipeline;
+ excludedTeam?: number;
+ excludedBots?: number;
+ includeTeam?: boolean;
+ note?: string;
+}) {
+ const ga = pipeline === 'google-analytics';
+
+ const facts: string[] = ga
+ ? [
+ 'Consent-only — visitors who declined cookies are absent',
+ 'Sampled · hours delayed',
+ 'Usable from Jul 24 only',
+ ]
+ : [
+ 'Exact · unsampled · real time',
+ includeTeam
+ ? 'Team traffic INCLUDED'
+ : `Team excluded${excludedTeam ? ` (${excludedTeam})` : ''}`,
+ `Bots excluded${excludedBots ? ` (${excludedBots})` : ''}`,
+ ];
+
+ return (
+
+ {/* Provenance, stated up front rather than left to be assumed. */}
+
+
{/* Not configured */}
{!loading && data && !data.configured && (
@@ -199,6 +171,19 @@ export default function AdminFunnelsPage() {
))}
+
+ {/* The caveat that turns these charts back into claims if it's
+ hidden. Wizards allow Back/Forward and resuming a saved draft at
+ a later step, so a step's `view` can legitimately never fire —
+ counts are corrected upward from the deepest step reached. At low
+ volume that can make every step read the same number, which looks
+ broken and isn't. */}
+
+ Step counts are monotonicity-corrected: anyone who
+ reached a later step is counted at every earlier one, because the wizards allow going back and
+ resuming a saved draft mid-flow. At low volume this can show the same number across several steps —
+ that is the correction working, not a bug.
+
>
)
)}
From c6f84f5cbd9a2466fc21def40d2631064592e9b3 Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 23:15:08 +0530
Subject: [PATCH 7/9] Phase 4 (2/2a): UTM reports 100% on screen, domain filter
everywhere
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Renders the channel rollup Phase 3 started returning. The old headline
stat was "Attributed signups" — 4 of 53 after the self-referral cleanup —
presented as if it were the total. Now "All signups" leads, every account
classified into exactly one channel, and the source/campaign tables are
explicitly labelled as the TAGGED SUBSET they always were. The two
numbers legitimately differ; the screen says so instead of letting the
smaller one impersonate the whole.
A large Direct bar is not a failure of attribution. Most early-stage
traffic is direct, and knowing that is what stops a two-signup campaign
being over-read.
The `*30` columns now say what they actually measure for the selected
range instead of always claiming "30d".
Domain tabs and the team toggle come to this screen too, both ungated —
and the link builder's target-market picker is ungated as well. Which
domain a tracked link points at is a campaign decision, not an SEO launch
decision; while it was gated, every generated link pointed at one origin
regardless of which market the campaign was for.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/(admin)/admin/utm/page.tsx | 159 +++++++++++++++++++++++----------
1 file changed, 111 insertions(+), 48 deletions(-)
diff --git a/app/(admin)/admin/utm/page.tsx b/app/(admin)/admin/utm/page.tsx
index fc65bfd6..99972de6 100644
--- a/app/(admin)/admin/utm/page.tsx
+++ b/app/(admin)/admin/utm/page.tsx
@@ -12,9 +12,11 @@ import { cn } from '@/lib/utils';
import { fetchJson, qk } from '@/lib/query';
import { formatCents } from '@/lib/booking';
import { formatDate } from '@/lib/utils/date';
-import { multiRegionLive } from '@/lib/flags';
import { REGIONS, ALL_REGIONS, type Region } from '@/lib/region/config';
import { DateRangeTabs, rangeQuery, DEFAULT_RANGE, type DateRangeValue } from '@/components/admin/DateRangeTabs';
+import {
+ DomainTabs, AudienceToggle, DataQualityBar, type DomainKey,
+} from '@/app/(admin)/admin/_components/AnalyticsControls';
import type { SourceRow, CampaignRow, PersonRow } from '@/app/api/admin/utm/route';
const GOLD = '#B8873A';
@@ -29,6 +31,12 @@ interface Resp {
campaigns?: CampaignRow[];
totals?: { signups: number; signups30: number; tasks: number; tasks30: number; bookings: number; gmvCents: number };
people?: PersonRow[];
+ /** Every signup, one channel each — the rollup that adds up to the real total. */
+ channels?: Array<{ channel: string; signups: number }>;
+ totalSignups?: number;
+ taggedSignups?: number;
+ /** What the `*30` columns mean for the SELECTED range (not always "30d"). */
+ recentLabel?: string;
}
const SITE = process.env.NEXT_PUBLIC_SITE_URL || 'https://tasklyanything.ai';
@@ -50,27 +58,19 @@ const PLATFORMS: Array<{ id: string; label: string; source: string; medium: stri
const clean = (v: string) => v.trim().toLowerCase().replace(/\s+/g, '_');
-type UtmRegionKey = 'all' | 'ca' | 'in' | 'global';
-const UTM_REGION_TABS: { key: UtmRegionKey; label: string }[] = [
- { key: 'all', label: 'All' },
- { key: 'ca', label: '🇨🇦' },
- { key: 'in', label: '🇮🇳' },
- { key: 'global', label: '🌐' },
-];
-
export default function AdminUtmPage() {
const [tab, setTab] = useState<'build' | 'analytics'>('build');
- const [region, setRegion] = useState('all');
+ const [region, setRegion] = useState('all');
+ const [includeTeam, setIncludeTeam] = useState(false);
const [range, setRange] = useState(DEFAULT_RANGE);
- const showRegion = multiRegionLive();
- // Region only enters the request/cache key when live AND a market is picked →
- // pre-launch this is byte-identical to before.
- const regionQ = showRegion && region !== 'all' ? region : null;
+ const regionQ = region !== 'all' ? region : null;
const rq = rangeQuery(range);
+ const audienceQ = includeTeam ? '&includeInternal=true' : '';
const utmQuery = useQuery({
- queryKey: qk.admin.section('utm', { range: rq, region: regionQ }),
- queryFn: () => fetchJson(`/api/admin/utm?${rq}${regionQ ? `®ion=${regionQ}` : ''}`),
+ queryKey: qk.admin.section('utm', { range: rq, region: regionQ, team: includeTeam }),
+ queryFn: () =>
+ fetchJson(`/api/admin/utm?${rq}${regionQ ? `®ion=${regionQ}` : ''}${audienceQ}`),
});
const data: Resp | null =
utmQuery.data ??
@@ -94,29 +94,13 @@ export default function AdminUtmPage() {
+ {/* Domain filter — always available. Comparing markets is a reporting
+ question, not an SEO launch gate (see AnalyticsControls). */}
+ {tab === 'analytics' && (
+ <>
+
+
+ >
)}
{([
@@ -149,9 +133,16 @@ export default function AdminUtmPage() {
{/* Reporting period — analytics tab only. Without this the report was
all-time, so a campaign launched today was invisible. */}
{tab === 'analytics' && (
-
-
-
+ <>
+
+
+
+
+ >
)}
@@ -182,7 +173,11 @@ function Builder() {
// Target market — the DOMAIN the tracked link points at decides the region the
// click lands in (.ca → Canada, .ai → India). Only exposed once multi-region
// is live; before that it falls back to NEXT_PUBLIC_SITE_URL (byte-identical).
- const showMarket = multiRegionLive();
+ // Always offer the target market. Which DOMAIN a tracked link points at is a
+ // campaign decision — the link has to land people on the right market's site
+ // — and all three domains resolve today. Gating it on the SEO launch flag
+ // silently pointed every link at one origin.
+ const showMarket = true;
const [market, setMarket] = useState('ca');
const preset = PLATFORMS.find((p) => p.id === platform)!;
@@ -373,6 +368,69 @@ function Field({
// ── Analytics ─────────────────────────────────────────────────────────────
+// Every signup, one channel each — the table that finally adds up to reality.
+//
+// The old report scoped everything to tagged rows, so it described 4 of 53
+// signups and presented that as the whole. A big "Direct" bar is not a failure
+// of attribution; it is the finding. Most early-stage traffic IS direct, and
+// knowing that is what stops you over-reading a two-signup campaign.
+function ChannelsPanel({
+ channels,
+ total,
+}: {
+ channels: Array<{ channel: string; signups: number }>;
+ total: number;
+}) {
+ if (!channels.length) {
+ return (
+
+
+
+ );
+}
+
function Analytics({ data }: { data: Resp | null }) {
if (!data) return null;
@@ -406,13 +464,18 @@ function Analytics({ data }: { data: Resp | null }) {
className="space-y-6"
>
-
-
-
-
+
+
+
+
-
+
+
+
[s.source, s.medium ?? '—', s.signups, s.tasks, s.bookings, formatCents(s.gmvCents)])}
From f9388a9f58d59ff7427048e3e70afa91f1dc5cab Mon Sep 17 00:00:00 2001
From: saurabhpandey9752
Date: Sat, 1 Aug 2026 23:33:14 +0530
Subject: [PATCH 8/9] Phase 4 (2/2b): traffic domain filter ungated, GA
labelled as secondary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Same decoupling as Funnels and UTM: the domain control no longer hides
behind multiRegionLive(). GA's Data API filters on hostName — the domain
IS the market — so this always worked; the flag was the only thing
stopping anyone using it.
Two panels turn out to have been fully built and simply invisible behind
that flag: the markets-over-time chart, and the domain hand-off panel
showing geo redirects (842 events in 30 days, collected and never once
displayed). Both render now without a line of new chart code.
The page also states its provenance. GA is consent-gated and sampled, so
it is a strict undercount that can never reconcile with the first-party
funnels screen; the bar says so, including that realtime is always
all-domains because GA has no realtime host dimension. Two numbers that
disagree are only confusing while nothing says which pipeline each came
from.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/(admin)/admin/traffic/page.tsx | 63 +++++++++++-------------------
1 file changed, 23 insertions(+), 40 deletions(-)
diff --git a/app/(admin)/admin/traffic/page.tsx b/app/(admin)/admin/traffic/page.tsx
index c813caf5..d600c10b 100644
--- a/app/(admin)/admin/traffic/page.tsx
+++ b/app/(admin)/admin/traffic/page.tsx
@@ -19,22 +19,16 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { fetchJson, qk } from '@/lib/query';
-import { multiRegionLive } from '@/lib/flags';
import { REGIONS, type Region } from '@/lib/region/config';
import { deltaPct } from '@/lib/analytics/date-range';
import { DateRangeTabs, rangeQuery, DEFAULT_RANGE, type DateRangeValue } from '@/components/admin/DateRangeTabs';
+import {
+ DomainTabs, AudienceToggle, DataQualityBar, type DomainKey,
+} from '@/app/(admin)/admin/_components/AnalyticsControls';
const GOLD = '#B8873A';
const EASE = [0.16, 1, 0.3, 1] as const;
-type TrafficRegionKey = 'all' | 'ca' | 'in' | 'global';
-const TRAFFIC_REGION_TABS: { key: TrafficRegionKey; label: string }[] = [
- { key: 'all', label: 'All' },
- { key: 'ca', label: '🇨🇦' },
- { key: 'in', label: '🇮🇳' },
- { key: 'global', label: '🌐' },
-];
-
interface GAResponse {
configured: boolean;
message?: string;
@@ -122,12 +116,14 @@ function fmtDuration(seconds: number): string {
export default function AdminTrafficPage() {
const [range, setRange] = useState(DEFAULT_RANGE);
- const [region, setRegion] = useState('all');
- const showRegion = multiRegionLive();
-
- // Region only enters the request/cache key when live AND a market is picked —
- // the GA Data API filters by hostName (the domain = the market).
- const regionQ = showRegion && region !== 'all' ? region : null;
+ const [region, setRegion] = useState('all');
+ const [includeTeam, setIncludeTeam] = useState(false);
+ // Always available: the GA Data API filters by hostName (the domain IS the
+ // market), so this works regardless of the SEO launch flag that used to hide
+ // it. See AnalyticsControls for why reporting is not a launch gate.
+ const showRegion = true;
+
+ const regionQ = region !== 'all' ? region : null;
const rq = rangeQuery(range);
const trafficQuery = useQuery({
queryKey: qk.admin.section('traffic', { range: rq, region: regionQ }),
@@ -251,35 +247,22 @@ NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX`}
+ {/* GA is a SECONDARY view here, and says so. It is consent-gated and
+ sampled, so it is a strict undercount that can never reconcile with
+ the first-party funnels screen — labelling the pipeline is what stops
+ the two being read as a contradiction. */}
+
+
{/* Realtime band */}
{data && typeof data.realtimeActiveUsers === 'number' && (
Date: Sat, 1 Aug 2026 23:51:17 +0530
Subject: [PATCH 9/9] Disclose the first-party measurement cookie in the
privacy policy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The device-id cookie ships in this PR, and it was approved on the
condition that the policy is updated to match. Shipping the cookie
without the disclosure would break the terms it was agreed on.
Written to be read, not to satisfy a checklist: what it stores (a random
value, nothing personal), who can read it (only our own site), how long
it lasts (400 days), what it records, what it explicitly does not, why we
run our own measurement at all, and how to get rid of it.
The "what it never records" line is doing real work — the honest concern
with any measurement cookie is cross-site tracking, and this one cannot
do that. Saying so plainly is better than a paragraph that technically
covers us while leaving the reader guessing.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/privacy/page.tsx | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx
index 66a594cf..be403979 100644
--- a/app/privacy/page.tsx
+++ b/app/privacy/page.tsx
@@ -232,6 +232,13 @@ export default async function PrivacyPage() {
We use cookies and similar technologies to keep you signed in, remember preferences, secure the Platform, and understand how it’s used (including Google Analytics). Some cookies are essential; others are optional.
You can manage optional cookies through our cookie preferences and your browser settings. We do not use cookies for third-party targeted advertising. Analytics data is used to improve the Platform.
+
Our own measurement. Alongside Google Analytics we count visits ourselves, using a first-party cookie that stores a random identifier so we can tell a returning visitor from a new one and see which pages and steps people use. It is set by us, readable only by our own site, and never shared with or sent to any third party. It holds no name, email, or other personal detail — only a random value — and it cannot be used to follow you across other websites. It lasts up to 400 days.