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
107 changes: 79 additions & 28 deletions ga4-worker/ga4-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,20 @@ export default {

try {
const url = new URL(request.url);
const days = parseInt(url.searchParams.get('days') || '90', 10);
const daysParam = url.searchParams.get('days') || '90';
const days = daysParam === 'all' || daysParam === '0' ? 0 : parseInt(daysParam, 10);

const accessToken = await getAccessToken(env);
const propertyId = env.GA4_PROPERTY_ID;

// Run all reports in parallel
const [timeSeries, sources, pages, ballEvents, geography] = await Promise.all([
const [timeSeries, sources, pages, ballEvents, geography, impactDistribution] = await Promise.all([
fetchTimeSeries(accessToken, propertyId, days),
fetchSources(accessToken, propertyId, days),
fetchTopPages(accessToken, propertyId, days),
fetchBallEvents(accessToken, propertyId, days),
fetchGeography(accessToken, propertyId, days),
fetchImpactDistribution(accessToken, propertyId, days),
]);

const body = JSON.stringify({
Expand All @@ -39,9 +41,10 @@ export default {
pages,
ballEvents,
geography,
impactDistribution,
_ctaDebug: ballEvents._debug || null,
fetchedAt: new Date().toISOString(),
days,
days: days || 'all',
});

return new Response(body, {
Expand Down Expand Up @@ -159,6 +162,11 @@ async function signJWT(header, payload, privateKeyPem) {
// GA4 Data API Report Helpers
// ═══════════════════════════════════════════════════════════════════════════

// Resolve the start date: days=0 means "all time" (from GA4 property creation)
function startDate(days) {
return days > 0 ? `${days}daysAgo` : '2020-01-01';
}

async function runReport(accessToken, propertyId, body) {
const res = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`,
Expand All @@ -182,7 +190,7 @@ async function runReport(accessToken, propertyId, body) {

async function fetchTimeSeries(token, propId, days) {
const report = await runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'activeUsers' },
Expand All @@ -192,13 +200,13 @@ async function fetchTimeSeries(token, propId, days) {
{ name: 'eventCount' },
],
orderBys: [{ dimension: { dimensionName: 'date' } }],
limit: days + 1,
limit: days > 0 ? days + 1 : 10000,
});

// Also fetch ball_launch and cta_click event counts per day
const [ballReport, ctaReport] = await Promise.all([
runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'date' }],
metrics: [{ name: 'eventCount' }],
dimensionFilter: {
Expand All @@ -208,10 +216,10 @@ async function fetchTimeSeries(token, propId, days) {
},
},
orderBys: [{ dimension: { dimensionName: 'date' } }],
limit: days + 1,
limit: days > 0 ? days + 1 : 10000,
}),
runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'date' }],
metrics: [{ name: 'eventCount' }],
dimensionFilter: {
Expand All @@ -221,7 +229,7 @@ async function fetchTimeSeries(token, propId, days) {
},
},
orderBys: [{ dimension: { dimensionName: 'date' } }],
limit: days + 1,
limit: days > 0 ? days + 1 : 10000,
}),
]);

Expand Down Expand Up @@ -270,7 +278,7 @@ const SOURCE_COLORS = {

async function fetchSources(token, propId, days) {
const report = await runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'sessionSource' }],
metrics: [{ name: 'sessions' }],
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
Expand Down Expand Up @@ -300,8 +308,8 @@ async function fetchTopPages(token, propId, days) {
// Current period
const report = await runReport(token, propId, {
dateRanges: [
{ startDate: `${days}daysAgo`, endDate: 'today' },
{ startDate: `${days * 2}daysAgo`, endDate: `${days + 1}daysAgo` },
{ startDate: startDate(days), endDate: 'today' },
{ startDate: days > 0 ? `${days * 2}daysAgo` : '2020-01-01', endDate: days > 0 ? `${days + 1}daysAgo` : '2020-01-01' },
],
dimensions: [{ name: 'pagePath' }],
metrics: [
Expand Down Expand Up @@ -355,7 +363,7 @@ async function fetchBallEvents(token, propId, days) {
// Get ball_launch, ball_score, detail_open, and cta_click counts by project
const [launchReport, scoreReport, openReport, ctaReport] = await Promise.all([
runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'customEvent:project_name' }],
metrics: [{ name: 'eventCount' }],
dimensionFilter: {
Expand All @@ -367,7 +375,7 @@ async function fetchBallEvents(token, propId, days) {
limit: 20,
}),
runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'customEvent:project_name' }],
metrics: [{ name: 'eventCount' }],
dimensionFilter: {
Expand All @@ -379,7 +387,7 @@ async function fetchBallEvents(token, propId, days) {
limit: 20,
}),
runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'customEvent:project_name' }],
metrics: [{ name: 'eventCount' }],
dimensionFilter: {
Expand All @@ -391,7 +399,7 @@ async function fetchBallEvents(token, propId, days) {
limit: 20,
}),
runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'customEvent:project_name' }],
metrics: [{ name: 'eventCount' }],
dimensionFilter: {
Expand All @@ -418,22 +426,12 @@ async function fetchBallEvents(token, propId, days) {
...Object.keys(ctaClicks),
]);

const BALL_META = {
'Josh Merritt': { id: 'aboutMe', color: '#6B9F6B', category: 'Me' },
'Microsoft Power BI': { id: 'powerBIMetrics', color: '#D4A843', category: 'Business' },
'The Wine You Drink': { id: 'thewineyoudrink', color: '#8B1A32', category: 'Apps' },
'Black Sheep Dart League': { id: 'dartleague', color: '#5985B1', category: 'Apps' },
'Smart Chicken Coop': { id: 'arduinoCoopDoor', color: '#BF360C', category: 'Technology' },
'Site Analytics': { id: 'SiteAnalytics', color: '#5985B1', category: 'Technology' },
'Google Data Studio Streaming Dashboard': { id: 'googleDataStudioServiceTechs', color: '#4285F4', category: 'Business' },
'Portfolio Website': { id: 'thisWebsite', color: '#5985B1', category: 'Technology' },
};
const palette = ['#D4A843', '#5985B1', '#6B9F6B', '#5985B1', '#C05050', '#BF360C', '#4285F4', '#8B1A32'];

const result = [];
let i = 0;
for (const name of allNames) {
const meta = BALL_META[name] || { id: name, color: palette[i % palette.length], category: 'Other' };
const meta = BALL_META_LOOKUP[name] || { id: name, color: palette[i % palette.length], category: 'Other' };
const launchCount = launches[name] || 0;
const scoreCount = scores[name] || 0;
const openCount = opens[name] || 0;
Expand Down Expand Up @@ -466,7 +464,7 @@ async function fetchBallEvents(token, propId, days) {

async function fetchGeography(token, propId, days) {
const report = await runReport(token, propId, {
dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [{ name: 'country' }],
metrics: [
{ name: 'activeUsers' },
Expand All @@ -492,6 +490,59 @@ async function fetchGeography(token, propId, days) {
}).filter(g => g.country !== 'Unknown');
}

// ── Impact distribution — per-shot coordinate data from GA4 ───────────
// impact_x/impact_y are registered as custom dimensions, so each unique
// (project_name, impact_x, impact_y, is_goal) tuple is its own row.
// Coordinates are rounded to 3 decimals on send, so most rows represent
// individual shots (eventCount=1), with occasional collisions.

async function fetchImpactDistribution(token, propId, days) {
const report = await runReport(token, propId, {
dateRanges: [{ startDate: startDate(days), endDate: 'today' }],
dimensions: [
{ name: 'customEvent:project_name' },
{ name: 'customEvent:is_goal' },
{ name: 'customEvent:impact_x' },
{ name: 'customEvent:impact_y' },
],
metrics: [
{ name: 'eventCount' },
],
dimensionFilter: {
filter: {
fieldName: 'eventName',
stringFilter: { matchType: 'EXACT', value: 'ball_impact' },
},
},
orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }],
limit: 500,
});

return (report.rows || []).map(row => {
const meta = BALL_META_LOOKUP[row.dimensionValues[0].value];
return {
projectName: row.dimensionValues[0].value,
ballId: meta?.id || row.dimensionValues[0].value,
isGoal: row.dimensionValues[1].value === 'true',
x: parseFloat(row.dimensionValues[2].value),
y: parseFloat(row.dimensionValues[3].value),
count: parseInt(row.metricValues[0].value, 10),
};
}).filter(r => !isNaN(r.x) && !isNaN(r.y));
}

// Shared ball metadata for both fetchBallEvents and fetchImpactDistribution
const BALL_META_LOOKUP = {
'Josh Merritt': { id: 'aboutMe', color: '#6B9F6B', category: 'Me' },
'Microsoft Power BI': { id: 'powerBIMetrics', color: '#D4A843', category: 'Business' },
'The Wine You Drink': { id: 'thewineyoudrink', color: '#8B1A32', category: 'Apps' },
'Black Sheep Dart League': { id: 'dartleague', color: '#5985B1', category: 'Apps' },
'Smart Chicken Coop': { id: 'arduinoCoopDoor', color: '#BF360C', category: 'Technology' },
'Site Analytics': { id: 'SiteAnalytics', color: '#5985B1', category: 'Technology' },
'Google Data Studio Streaming Dashboard': { id: 'googleDataStudioServiceTechs', color: '#4285F4', category: 'Business' },
'Portfolio Website': { id: 'thisWebsite', color: '#5985B1', category: 'Technology' },
};

function indexByDimension(report) {
const map = {};
(report.rows || []).forEach((row) => {
Expand Down
4 changes: 2 additions & 2 deletions src/analytics/AnalyticsDashboardV3.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ function AnalyticsTab({ timeSeriesData, rangeDays, ballData, sourcesData, pagesD
}
return { shots: bridge.shots || 0, makes: bridge.makes || 0, ballStats, impacts: Array.isArray(impacts) ? impacts : [] };
} catch (_) { return undefined; }
})()} />
})()} impactDistribution={isLive ? (liveData?.impactDistribution || []) : []} />
</div>

{/* Ball Engagement Funnel — V1-style table */}
Expand Down Expand Up @@ -922,7 +922,7 @@ class DashboardErrorBoundary extends Component {
}

// ═══ MAIN DASHBOARD ═════════════════════════════════════════════════════
const TIME_RANGES = [{ key: "7d", label: "7D", days: 7 }, { key: "30d", label: "30D", days: 30 }, { key: "90d", label: "90D", days: 90 }];
const TIME_RANGES = [{ key: "7d", label: "7D", days: 7 }, { key: "30d", label: "30D", days: 30 }, { key: "90d", label: "90D", days: 90 }, { key: "all", label: "All", days: 0 }];
const TABS = [{ key: "analytics", label: "Analytics" }, { key: "architecture", label: "Data Architecture" }];

function AnalyticsDashboardV3Inner() {
Expand Down
46 changes: 38 additions & 8 deletions src/analytics/ShotChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@ function filterValid(balls) {
}

// ── Generate score/miss dots ──────────────────────────────────────────
// Uses real impact data (from __dadatadad_impacts) when available,
// falls back to seeded random scatter based on score/miss counts.
function generateDots(balls, seed = 7, impacts) {
// If we have real impact data, use actual coordinates
// Priority: session impacts (real coords) > GA4 distribution (avg coords) > seeded scatter
function generateDots(balls, seed = 7, impacts, impactDistribution) {
// Path 1: Real per-shot impact data from localStorage (session mode)
if (impacts && impacts.length > 0) {
const dots = [];
impacts.forEach(imp => {
Expand All @@ -62,7 +61,38 @@ function generateDots(balls, seed = 7, impacts) {
return dots;
}

// Fallback: seeded random scatter
// Path 2: GA4 impact distribution (all-time mode with worker data)
// Each entry has real (x, y) coordinates from customEvent dimensions.
// Entries with count > 1 (coordinate collisions) emit multiple dots with jitter.
if (impactDistribution && impactDistribution.length > 0) {
const dots = [];
const rand = seededRand(seed);
impactDistribution.forEach(entry => {
const ball = balls.find(b => b.id === entry.ballId);
if (!ball) return;
if (entry.x == null || entry.y == null) return;
const type = entry.isGoal ? 'score' : 'miss';
const baseX = 20 + entry.x * 300;
const baseY = 50 + entry.y * 320;
const n = Math.min(entry.count || 1, 10);
for (let i = 0; i < n; i++) {
// First dot at exact position, extras get slight jitter for collisions
const jX = i === 0 ? 0 : (rand() - 0.5) * 12;
const jY = i === 0 ? 0 : (rand() - 0.5) * 12;
dots.push({
x: Math.max(20, Math.min(320, baseX + jX)),
y: Math.max(50, Math.min(370, baseY + jY)),
type,
ballId: ball.id,
color: ball.color,
category: ball.category,
});
}
});
return dots;
}

// Path 3: Fallback seeded random scatter from aggregate counts
const rand = seededRand(seed);
const dots = [];
balls.forEach((ball) => {
Expand Down Expand Up @@ -139,7 +169,7 @@ function shortName(name) {
}


export default function ShotChart({ liveData, sessionData }) {
export default function ShotChart({ liveData, sessionData, impactDistribution }) {
const [mode, setMode] = useState('all');
const [hoveredBall, setHoveredBall] = useState(null);
const [hoveredCat, setHoveredCat] = useState(null);
Expand All @@ -166,8 +196,8 @@ export default function ShotChart({ liveData, sessionData }) {
[data, maxLaunches]);

const dots = useMemo(() =>
generateDots(data, mode === 'session' ? 99 : 7, mode === 'session' ? sessionData?.impacts : null),
[data, mode, sessionData]);
generateDots(data, mode === 'session' ? 99 : 7, mode === 'session' ? sessionData?.impacts : null, mode === 'all' ? impactDistribution : null),
[data, mode, sessionData, impactDistribution]);

const totalShots = data.reduce((s, b) => s + (b.launches || 0), 0);
const totalScores = data.reduce((s, b) => s + (b.scores || 0), 0);
Expand Down
1 change: 1 addition & 0 deletions src/analytics/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export async function fetchGA4Data(days = 90) {
pages: data.pages || [],
ballEvents: data.ballEvents || [],
geography: data.geography || [],
impactDistribution: data.impactDistribution || [],
isLive: true,
};
} catch (err) {
Expand Down
17 changes: 10 additions & 7 deletions src/game/ga4.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export function initGA4Tracking() {
// Per-ball launch
unsubs.push(
bus.on('ball:launched', ({ name, category, ballLaunches, ballMakes, shotNumber }) => {
if (!name) return;
track('ball_launch', {
project_name: name,
project_category: category,
Expand Down Expand Up @@ -232,6 +233,7 @@ export function initGA4Tracking() {
// Per-ball score
unsubs.push(
bus.on('ball:scored', ({ name, category, ballLaunches, ballMakes, shotNumber }) => {
if (!name) return;
track('ball_score', {
project_name: name,
project_category: category,
Expand Down Expand Up @@ -316,17 +318,18 @@ export function initGA4Tracking() {
// First-impact tracking
unsubs.push(
bus.on('impact:first', (data) => {
if (!data.ballName) return;
// Keep coordinates from first contact, but don't trust first-contact
// goal classification until shot resolution updates it.
impactStore.add({ ...data, isGoal: false });
track('ball_impact', {
ball_name: data.ballName,
ball_category: data.ballCategory,
hit_type: data.hitType,
is_goal: data.isGoal ? 'true' : 'false',
impact_x: data.x,
impact_y: data.y,
shot_number: data.shotNumber,
project_name: data.ballName,
project_category: data.ballCategory,
hit_type: data.hitType,
is_goal: data.isGoal ? 'true' : 'false',
impact_x: data.x,
impact_y: data.y,
shot_number: data.shotNumber,
});
}),
);
Expand Down