| name | self-improving-feedback-instrumentation |
| description | Track which feedback blocks users actually engage with — expand, copy, revisit, dwell on — and use that engagement signal to improve feedback structure over time without compromising user trust. |
Self-Improving Feedback Instrumentation
Review Checklist
What to Instrument — The Five Engagement Signals
Not all engagement is equal. These are the five signals that actually tell you whether feedback is working:
1. Expand events on evidence panels — A user opening an evidence panel means the top-level score wasn't enough; they needed justification. High expand rates = either the score is surprising (good) or users don't trust it (bad). Distinguishable by whether they then revisit the page.
2. Copy events on coaching items — The strongest signal. A user copying a coaching item means they believe it's actionable enough to use outside the platform. Low copy rate = coaching items are too generic.
3. Revisit rate on breakdown page — Users who return to the breakdown page after their initial session are still processing the feedback. High revisit rate = the feedback had staying power. Low revisit rate = one-and-done, no lasting impact.
4. Scroll depth on lane sections — Did users scroll past the first two lanes? Low scroll depth on lane sections means the top content consumed their attention and they never reached what might be more useful lower content.
5. Dwell time on feedback blocks — Time spent reading a specific block. Short dwell + no copy = content not landing. Long dwell + copy = high-value content.
SQL Schema: engagement_events
CREATE TABLE engagement_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id TEXT NOT NULL,
bout_id UUID,
challenge_id UUID,
block_type TEXT NOT NULL,
block_id TEXT NOT NULL,
event_type TEXT NOT NULL,
dwell_ms INTEGER,
scroll_pct INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
occurred_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_engagement_block_type ON engagement_events(block_type, event_type, occurred_at DESC);
CREATE INDEX idx_engagement_challenge ON engagement_events(challenge_id, block_type, occurred_at DESC);
CREATE INDEX idx_engagement_bout ON engagement_events(bout_id, event_type, occurred_at DESC);
TypeScript Instrumentation Hooks — Non-Blocking, Privacy-Safe
The instrumentation layer must be completely invisible to the user. Events fire and forget. Errors are silently caught. No event ever blocks rendering or awaits a server response.
Core Event Client
import { v4 as uuidv4 } from 'uuid';
export type BlockType = 'evidence_panel' | 'coaching_item' | 'lane_section' | 'breakdown_page';
export type EventType = 'expand' | 'copy' | 'revisit' | 'scroll_depth' | 'dwell_end';
interface EngagementEvent {
session_id: string;
bout_id?: string;
challenge_id?: string;
block_type: BlockType;
block_id: string;
event_type: EventType;
dwell_ms?: number;
scroll_pct?: number;
metadata?: Record<string, string | number | boolean>;
}
function getSessionId(): string {
try {
const stored = sessionStorage.getItem('bouts_session_id');
if (stored) return stored;
const fresh = uuidv4();
sessionStorage.setItem('bouts_session_id', fresh);
return fresh;
} catch {
return uuidv4();
}
}
export async function trackEngagement(event: Omit<EngagementEvent, 'session_id'>): Promise<void> {
const payload: EngagementEvent = {
...event,
session_id: getSessionId(),
};
fetch('/api/engagement', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {
});
}
React Hook: useExpandTracking
import { useRef, useCallback } from 'react';
import { trackEngagement } from '@/lib/feedback-instrumentation';
export function useExpandTracking(params: {
boutId?: string;
challengeId?: string;
blockType: 'evidence_panel' | 'lane_section';
blockId: string;
metadata?: Record<string, string | number | boolean>;
}) {
const hasTracked = useRef(false);
const trackExpand = useCallback(() => {
if (hasTracked.current) return;
hasTracked.current = true;
trackEngagement({
bout_id: params.boutId,
challenge_id: params.challengeId,
block_type: params.blockType,
block_id: params.blockId,
event_type: 'expand',
metadata: params.metadata,
});
}, [params.boutId, params.challengeId, params.blockType, params.blockId, params.metadata]);
return { trackExpand };
}
React Hook: useDwellTracking
import { useEffect, useRef } from 'react';
import { trackEngagement } from '@/lib/feedback-instrumentation';
export function useDwellTracking(params: {
boutId?: string;
challengeId?: string;
blockType: 'evidence_panel' | 'coaching_item' | 'lane_section';
blockId: string;
}) {
const startRef = useRef<number | null>(null);
const hiddenRef = useRef(false);
useEffect(() => {
startRef.current = Date.now();
const handleVisibilityChange = () => {
if (document.hidden) {
hiddenRef.current = true;
if (startRef.current !== null) {
const partialDwell = Date.now() - startRef.current;
startRef.current = null;
trackEngagement({
bout_id: params.boutId,
challenge_id: params.challengeId,
block_type: params.blockType,
block_id: params.blockId,
event_type: 'dwell_end',
dwell_ms: partialDwell,
metadata: { reason: 'tab_hidden' },
});
}
} else {
hiddenRef.current = false;
startRef.current = Date.now();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (startRef.current !== null && !hiddenRef.current) {
const dwell = Date.now() - startRef.current;
if (dwell > 500) {
trackEngagement({
bout_id: params.boutId,
challenge_id: params.challengeId,
block_type: params.blockType,
block_id: params.blockId,
event_type: 'dwell_end',
dwell_ms: dwell,
metadata: { reason: 'unmount' },
});
}
}
};
}, [params.boutId, params.challengeId, params.blockType, params.blockId]);
}
Engagement API Route
import { createClient } from '@supabase/supabase-js';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const EngagementSchema = z.object({
session_id: z.string().min(1).max(100),
bout_id: z.string().uuid().optional(),
challenge_id: z.string().uuid().optional(),
block_type: z.enum(['evidence_panel', 'coaching_item', 'lane_section', 'breakdown_page']),
block_id: z.string().min(1).max(100),
event_type: z.enum(['expand', 'copy', 'revisit', 'scroll_depth', 'dwell_end']),
dwell_ms: z.number().int().min(0).max(3_600_000).optional(),
scroll_pct: z.number().int().min(0).max(100).optional(),
metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = EngagementSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false }, { status: 200 });
}
await supabase.from('engagement_events').insert(parsed.data);
return NextResponse.json({ ok: true }, { status: 200 });
} catch {
return NextResponse.json({ ok: false }, { status: 200 });
}
}
Aggregate Queries — From Signal to Insight
Raw events are noise. Aggregated, they become structure improvement signals.
Feedback Block Performance Report
WITH block_sessions AS (
SELECT
challenge_id,
block_type,
block_id,
COUNT(DISTINCT session_id) AS unique_sessions,
COUNT(*) FILTER (WHERE event_type = 'expand') AS expand_count,
COUNT(*) FILTER (WHERE event_type = 'copy') AS copy_count,
AVG(dwell_ms) FILTER (WHERE event_type = 'dwell_end') AS avg_dwell_ms,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY scroll_pct)
FILTER (WHERE event_type = 'scroll_depth') AS median_scroll_pct
FROM engagement_events
WHERE occurred_at >= NOW() - INTERVAL '30 days'
AND challenge_id IS NOT NULL
GROUP BY challenge_id, block_type, block_id
)
SELECT
bs.*,
ROUND((bs.expand_count::FLOAT / NULLIF(bs.unique_sessions, 0) * 100)::NUMERIC, 1) AS expand_rate_pct,
ROUND((bs.copy_count::FLOAT / NULLIF(bs.unique_sessions, 0) * 100)::NUMERIC, 1) AS copy_rate_pct,
CASE
WHEN bs.expand_count::FLOAT / NULLIF(bs.unique_sessions, 0) > 0.6
THEN 'HIGH_DEMAND — users want more here'
WHEN bs.copy_count::FLOAT / NULLIF(bs.unique_sessions, 0) > 0.3
THEN 'HIGH_VALUE — content is actionable'
WHEN COALESCE(bs.avg_dwell_ms, 0) < 2000
THEN 'LOW_DWELL — content not landing'
ELSE 'NORMAL'
END AS signal_interpretation
FROM block_sessions bs
ORDER BY challenge_id, expand_rate_pct DESC;
Revisit Rate Query
WITH first_visits AS (
SELECT
bout_id,
session_id,
MIN(occurred_at) AS first_visit
FROM engagement_events
WHERE block_type = 'breakdown_page'
AND event_type = 'revisit'
GROUP BY bout_id, session_id
),
revisits AS (
SELECT
ee.bout_id,
ee.session_id,
COUNT(*) AS visit_count
FROM engagement_events ee
JOIN first_visits fv ON ee.bout_id = fv.bout_id AND ee.session_id = fv.session_id
WHERE ee.block_type = 'breakdown_page'
AND ee.event_type = 'revisit'
AND ee.occurred_at > fv.first_visit
GROUP BY ee.bout_id, ee.session_id
)
SELECT
fv.bout_id,
COUNT(DISTINCT fv.session_id) AS total_sessions,
COUNT(DISTINCT r.session_id) AS revisiting_sessions,
ROUND(
COUNT(DISTINCT r.session_id)::FLOAT / NULLIF(COUNT(DISTINCT fv.session_id), 0) * 100,
1
) AS revisit_rate_pct
FROM first_visits fv
LEFT JOIN revisits r ON fv.bout_id = r.bout_id AND fv.session_id = r.session_id
GROUP BY fv.bout_id
ORDER BY revisit_rate_pct DESC;
TSX Instrumentation Wrapper Components
EvidencePanelWithTracking
'use client';
import { useState, useCallback } from 'react';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import { useExpandTracking } from '@/hooks/useExpandTracking';
import { useDwellTracking } from '@/hooks/useDwellTracking';
import { cn } from '@/lib/utils';
interface Evidence {
id: string;
quote: string;
explanation: string;
lane: string;
}
interface EvidencePanelWithTrackingProps {
panelId: string;
lane: string;
evidenceItems: Evidence[];
boutId?: string;
challengeId?: string;
}
export function EvidencePanelWithTracking({
panelId,
lane,
evidenceItems,
boutId,
challengeId,
}: EvidencePanelWithTrackingProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { trackExpand } = useExpandTracking({
boutId,
challengeId,
blockType: 'evidence_panel',
blockId: panelId,
metadata: { lane, evidence_count: evidenceItems.length },
});
useDwellTracking({
boutId,
challengeId,
blockType: 'evidence_panel',
blockId: panelId,
});
const handleToggle = useCallback(() => {
if (!isExpanded) {
trackExpand();
}
setIsExpanded(prev => !prev);
}, [isExpanded, trackExpand]);
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={handleToggle}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<span className="text-sm font-medium text-gray-700">
Evidence ({evidenceItems.length} {evidenceItems.length === 1 ? 'item' : 'items'})
</span>
{isExpanded
? <ChevronUpIcon className="h-4 w-4 text-gray-500" />
: <ChevronDownIcon className="h-4 w-4 text-gray-500" />
}
</button>
{isExpanded && (
<div className="divide-y divide-gray-100">
{evidenceItems.map((ev, idx) => (
<div key={ev.id} className="px-4 py-3">
<blockquote className="text-sm text-gray-700 italic border-l-2 border-blue-300 pl-3 mb-2">
"{ev.quote}"
</blockquote>
<p className="text-sm text-gray-600">{ev.explanation}</p>
</div>
))}
</div>
)}
</div>
);
}
CoachingItemWithCopyTracking
'use client';
import { useCallback } from 'react';
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline';
import { trackEngagement } from '@/lib/feedback-instrumentation';
interface CoachingItemWithCopyTrackingProps {
itemId: string;
itemIndex: number;
text: string;
lane: string;
boutId?: string;
challengeId?: string;
}
export function CoachingItemWithCopyTracking({
itemId,
itemIndex,
text,
lane,
boutId,
challengeId,
}: CoachingItemWithCopyTrackingProps) {
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
}
trackEngagement({
bout_id: boutId,
challenge_id: challengeId,
block_type: 'coaching_item',
block_id: itemId,
event_type: 'copy',
metadata: { item_index: itemIndex, lane },
});
}, [itemId, itemIndex, text, lane, boutId, challengeId]);
return (
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg group">
<div className="flex-1 text-sm text-amber-900">{text}</div>
<button
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-amber-100"
title="Copy coaching item"
>
<ClipboardDocumentIcon className="h-4 w-4 text-amber-600" />
</button>
</div>
);
}
Anti-Patterns
❌ Anti-Pattern 1: Storing user_id in engagement events
await supabase.from('engagement_events').insert({
user_id: session.user.id,
bout_id: boutId,
event_type: 'expand',
block_id: 'planning_evidence',
});
await supabase.from('engagement_events').insert({
session_id: getSessionId(),
bout_id: boutId,
event_type: 'expand',
block_id: 'planning_evidence',
});
❌ Anti-Pattern 2: Awaiting engagement writes in render path
const handleExpand = async () => {
setIsExpanded(true);
await trackEngagement({ ... });
};
const handleExpand = () => {
setIsExpanded(true);
trackEngagement({ ... });
};
❌ Anti-Pattern 3: Using scroll event listener for scroll depth
useEffect(() => {
const handleScroll = () => {
const pct = Math.round(window.scrollY / document.body.scrollHeight * 100);
trackEngagement({ event_type: 'scroll_depth', scroll_pct: pct });
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
trackEngagement({ event_type: 'scroll_depth', scroll_pct: 75, block_id: 'lane_4' });
observer.disconnect();
}
});
},
{ threshold: 0.5 }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
Common Failures to Catch in Review
| Failure | Symptom | Fix |
|---|
Expand tracked on every re-render because useRef guard not used | One panel open = 10+ expand events in analytics | Add hasTracked = useRef(false) and set to true on first fire |
Copy events fire but don't include block_id — only event_type: 'copy' | Can't distinguish which coaching item is most copied | Always include block_id (item UUID or index-based ID) |
| Dwell timer not paused on tab visibility change | User switches tabs for 20 minutes; 20-minute dwell recorded for a 10-second read | Use document.addEventListener('visibilitychange', ...) to pause/resume |
| Engagement API returns 400/500 on bad payload causing client retry loop | Network tab shows 50+ failed requests per page load | Always return 200 from engagement endpoint, even for validation failures |
COUNT(*) used instead of COUNT(DISTINCT session_id) in expand rate | One user refreshing 5 times = 5 unique sessions = inflated expand rate | Use COUNT(DISTINCT session_id) for per-user metrics |
Aggregate query groups by block_id without challenge_id | Panel "planning_evidence" performs differently by challenge but data is blended | Always group by challenge_id, block_id together |
keepalive: true missing from fetch in instrumentation client | Tracking events on page unload are silently dropped | Add keepalive: true to all engagement fetch calls |
sessionStorage access not wrapped in try/catch | Privacy-focused browsers block sessionStorage access; uncaught exception crashes hook | Wrap all sessionStorage calls in try/catch, fall back to ephemeral UUID |
| No minimum dwell threshold — sub-100ms events recorded | Rapid scroll generates hundreds of 50ms dwell events | Filter out dwell events < 500ms before recording |
| Instrumentation enabled in development/test environments | Test fixtures inflate production analytics | Check process.env.NODE_ENV !== 'production' before firing events |
Changelog
- 2026-03-31: Created for Bouts self-improving feedback instrumentation build