Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions app/(admin)/admin/_components/AnalyticsControls.tsx
Original file line number Diff line number Diff line change
@@ -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:
// • <DataQualityBar /> — persistent strip naming the pipeline + exclusions
// • <DomainTabs /> — All · .ca · .in · .ai, applying to every panel
// • <AudienceToggle /> — "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 (
<div className="border border-rich-black/[0.08] bg-cream-dark/30 flex" role="tablist" aria-label="Domain">
{tabs.map((t) => {
const active = value === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
title={t.hint}
onClick={() => onChange(t.key)}
className={cn(
'relative px-4 py-3 transition-colors duration-200 cursor-pointer',
active
? 'bg-rich-black text-cream'
: 'text-rich-black/65 hover:text-rich-black hover:bg-cream-dark',
)}
>
{active && (
<span className="absolute top-0 left-0 right-0 h-[2px]" style={{ backgroundColor: GOLD }} aria-hidden />
)}
<span className="eyebrow">{t.label}</span>
</button>
);
})}
</div>
);
}

// ──────────────────────────────────────────────────────────────────────────
// 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 (
<button
type="button"
onClick={() => onChange(!includeTeam)}
aria-pressed={includeTeam}
className={cn(
'group inline-flex items-center gap-2.5 px-4 py-3 border transition-colors duration-200 cursor-pointer',
includeTeam
? 'border-rich-black/20 bg-cream-dark text-rich-black'
: 'border-rich-black/[0.08] bg-cream-dark/30 text-rich-black/65 hover:text-rich-black',
)}
>
<span
className={cn(
'relative w-8 h-[18px] rounded-full transition-colors duration-200 shrink-0',
includeTeam ? '' : 'bg-rich-black/15',
)}
style={includeTeam ? { backgroundColor: GOLD } : undefined}
aria-hidden
>
<span
className={cn(
'absolute top-[2px] w-[14px] h-[14px] rounded-full bg-cream transition-all duration-200',
includeTeam ? 'left-[16px]' : 'left-[2px]',
)}
/>
</span>
<span className="eyebrow whitespace-nowrap">
Include team
{!includeTeam && typeof excludedCount === 'number' && excludedCount > 0 && (
<span className="text-warm-gray"> · {excludedCount} hidden</span>
)}
</span>
</button>
);
}

// ──────────────────────────────────────────────────────────────────────────
// 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 (
<div
className={cn(
'mt-4 flex flex-wrap items-center gap-x-2 gap-y-1.5 px-4 py-2.5 border',
ga
? 'border-rich-black/[0.10] bg-cream-dark/60'
: 'border-rich-black/[0.08] bg-cream-dark/30',
)}
>
<span
className="inline-flex items-center gap-1.5 eyebrow text-rich-black"
title={ga ? 'Google Analytics 4' : 'Our own database'}
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: ga ? '#9AA0A6' : GOLD }}
aria-hidden
/>
{ga ? 'Google Analytics' : 'First-party'}
</span>
{facts.map((f) => (
<span key={f} className="eyebrow text-warm-gray">
<span className="text-rich-black/25 mr-2" aria-hidden>·</span>
{f}
</span>
))}
{note && (
<span className="eyebrow text-warm-gray">
<span className="text-rich-black/25 mr-2" aria-hidden>·</span>
{note}
</span>
)}
</div>
);
}

// ──────────────────────────────────────────────────────────────────────────
// Sparkline
// ──────────────────────────────────────────────────────────────────────────

/** A scorecard's shape, inline. No axis, no labels — trend only. */
export function Sparkline({ points, width = 64, height = 18 }: { points: number[]; width?: number; height?: number }) {
if (points.length < 2) return null;
const peak = Math.max(...points, 1);
const x = (i: number) => (i / (points.length - 1)) * width;
const y = (v: number) => height - (v / peak) * height;
const d = points.map((v, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' ');
return (
<svg width={width} height={height} className="overflow-visible" aria-hidden>
<path d={d} fill="none" stroke={GOLD} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
77 changes: 31 additions & 46 deletions app/(admin)/admin/funnels/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import { motion } from 'motion/react';
import { Filter, ArrowDown, TrendingDown, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { fetchJson, qk } from '@/lib/query';
import { multiRegionLive } from '@/lib/flags';
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;
Expand Down Expand Up @@ -77,36 +79,27 @@ interface Resp {
leaks?: Leak[];
}

// Market filter — only shown once multi-region is live (before that every row is
// Canadian, so an India tab would just read 0 everywhere). 'all' = both markets.
type RegionKey = 'all' | 'ca' | 'in' | 'global';
const REGION_TABS: { key: RegionKey; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'ca', label: '🇨🇦 Canada' },
{ key: 'in', label: '🇮🇳 India' },
{ key: 'global', label: '🌐 Global' },
];

export default function AdminFunnelsPage() {
const [range, setRange] = useState<DateRangeValue>(DEFAULT_RANGE);
const [region, setRegion] = useState<RegionKey>('all');
const showRegion = multiRegionLive();
const [region, setRegion] = useState<DomainKey>('all');
const [includeTeam, setIncludeTeam] = useState(false);

// Region only enters the query when multi-region is live AND a specific market
// is picked — so pre-launch the request + cache key are byte-identical.
const regionQ = showRegion && region !== 'all' ? region : null;
const regionQ = region !== 'all' ? region : null;
const rq = rangeQuery(range);
// The audience opt-in only ever ADDS a param, so the default request and its
// cache key stay byte-identical to before.
const audienceQ = includeTeam ? '&includeInternal=true' : '';
const funnelsQuery = useQuery({
queryKey: qk.admin.section('funnels', { range: rq, region: regionQ }),
queryKey: qk.admin.section('funnels', { range: rq, region: regionQ, team: includeTeam }),
queryFn: () =>
fetchJson<Resp>(`/api/admin/funnels?${rq}${regionQ ? `&region=${regionQ}` : ''}`),
fetchJson<Resp>(`/api/admin/funnels?${rq}${regionQ ? `&region=${regionQ}` : ''}${audienceQ}`),
});
// Individual paths — at low volume this explains the drop-off better than any
// percentage. Separate query so the funnel cards paint without waiting on it.
const journeysQuery = useQuery({
queryKey: qk.admin.section('journeys', { range: rq, region: regionQ }),
queryKey: qk.admin.section('journeys', { range: rq, region: regionQ, team: includeTeam }),
queryFn: () =>
fetchJson<JourneysResp>(`/api/admin/journeys?${rq}${regionQ ? `&region=${regionQ}` : ''}`),
fetchJson<JourneysResp>(`/api/admin/journeys?${rq}${regionQ ? `&region=${regionQ}` : ''}${audienceQ}`),
});
const data: Resp | null =
funnelsQuery.data ??
Expand All @@ -130,36 +123,15 @@ export default function AdminFunnelsPage() {
</p>
</div>
<div className="flex flex-wrap gap-3">
{/* Market filter — only once multi-region is live. */}
{showRegion && (
<div className="border border-rich-black/[0.08] bg-cream-dark/30 flex">
{REGION_TABS.map((r) => {
const isActive = region === r.key;
return (
<button
key={r.key}
type="button"
onClick={() => setRegion(r.key)}
className={cn(
'relative px-4 py-3 transition-colors duration-200 cursor-pointer',
isActive
? 'bg-rich-black text-cream'
: 'text-rich-black/65 hover:text-rich-black hover:bg-cream-dark',
)}
>
{isActive && (
<span className="absolute top-0 left-0 right-0 h-[2px]" style={{ backgroundColor: GOLD }} aria-hidden />
)}
<span className="eyebrow">{r.label}</span>
</button>
);
})}
</div>
)}
<DomainTabs value={region} onChange={setRegion} />
<AudienceToggle includeTeam={includeTeam} onChange={setIncludeTeam} />
<DateRangeTabs value={range} onChange={setRange} />
</div>
</section>

{/* Provenance, stated up front rather than left to be assumed. */}
<DataQualityBar pipeline="first-party" includeTeam={includeTeam} />

{/* Not configured */}
{!loading && data && !data.configured && (
<div className="mt-8 rounded-[20px] bg-cream border border-rich-black/[0.08] p-8 lg:p-12 text-center">
Expand Down Expand Up @@ -199,6 +171,19 @@ export default function AdminFunnelsPage() {
))}
</div>
<JourneysPanel data={journeysQuery.data} loading={journeysQuery.isPending} />

{/* 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. */}
<p className="mt-6 font-sans text-[11.5px] text-warm-gray leading-relaxed max-w-[70ch]">
Step counts are <strong className="text-rich-black/70">monotonicity-corrected</strong>: 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.
</p>
</>
)
)}
Expand Down
8 changes: 8 additions & 0 deletions app/api/admin/funnels/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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,
}),
),
);
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading