const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
const params = new URLSearchParams(location.search);
const state = { game: null, days: Number(params.get('days')) || 14, page: 1, sort: 'level', direction: 'desc', segment: 'all', q: '', template: 'overview' };
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const missing = value => value === null || value === undefined || value === '';
const escape = value => String(value).replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
function format(value, kind = 'number') {
if (missing(value)) return 'N/A';
if (kind === 'text') return String(value);
if (kind === 'json') return JSON.stringify(value, (key,item)=>item === null ? 'N/A' : item);
if (kind === 'date') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? 'N/A' : new Intl.DateTimeFormat('en-GB', {timeZone:'UTC',dateStyle:'medium',timeStyle:'short'}).format(date);
}
if (kind === 'duration') {
if (!Number.isFinite(Number(value))) return 'N/A';
const seconds = Math.max(0,Math.round(Number(value)));
return seconds >= 3600 ? `${Math.floor(seconds/3600).toLocaleString()}h ${String(Math.floor(seconds%3600/60)).padStart(2,'0')}m` : `${Math.floor(seconds/60)}m ${String(seconds%60).padStart(2,'0')}s`;
}
if (typeof value === 'object') return JSON.stringify(value);
if (typeof value !== 'number') return String(value);
if (!Number.isFinite(value)) return 'N/A';
const number = new Intl.NumberFormat('en-US',{maximumFractionDigits:kind === 'decimal' ? 2 : 1}).format(value);
return kind === 'percent' ? `${number}%` : kind === 'robux' ? `R$ ${number}` : kind === 'currency' ? `$${number}` : number;
}
const cell = (value, kind) => escape(format(value, kind));
const chartStyle=getComputedStyle(document.documentElement);
const seriesColors=Array.from({length:12},(_,i)=>chartStyle.getPropertyValue(`--chart-${i+1}`).trim());
const seriesColor=index=>seriesColors[index] || `hsl(${index*137.508%360} 70% 70%)`;
function reveal(node) {
if (!node || reducedMotion.matches || !node.animate) return;
node.getAnimations().forEach(animation => animation.cancel());
node.animate([{opacity:.65},{opacity:1}],{duration:180,easing:'ease-out'});
}
function error(message) {
fillMetrics({});
$$('[data-chart]').forEach(node=>node.innerHTML='
N/A — data unavailable
');
$$('tbody').forEach(node=>node.innerHTML=`| N/A — data unavailable |
`);
const banner=$('[data-error]');
banner.textContent=message; banner.hidden=false;
}
function clearError() { $('[data-error]').hidden=true; }
async function fetchJSON(url) {
const response=await fetch(url);
const data=await response.json();
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
return data;
}
function api(dataset='', query={}) {
const search=new URLSearchParams({days:state.days,...query});
return fetchJSON(`/api/games/${state.game.id}${dataset ? '/'+dataset : ''}?${search}`);
}
function fillMetrics(metrics={}) {
$$('[data-metric]').forEach(node => node.textContent=format(metrics[node.dataset.metric],node.dataset.format));
}
function table(selector, records) {
const node=$(selector); if (!node) return;
const span=node.closest('table').querySelectorAll('th').length || 1;
node.innerHTML=records.length ? records.map(record=>`${record.map(value=>`| ${value} | `).join('')}
`).join('') : `| N/A — no records for this selection |
`;
reveal(node);
}
function playerLink(playerId, name, robloxId) {
if (missing(playerId)) return 'N/A';
const query=new URLSearchParams({game_id:state.game.id,player_id:playerId});
return `${cell(name)}Roblox ID ${cell(robloxId,'text')}
`;
}
function renderSessions(records=[]) {
table('[data-session-rows]',records.map(r=>[playerLink(r.player_id,r.display_name,r.roblox_user_id),cell(r.started_at,'date'),cell(r.duration,'duration'),``,`${r.ended_at ? 'Completed' : 'End unreported'}`]));
}
function renderEvents(records=[]) {
table('[data-event-rows]',records.map(r=>[`${cell(r.name)}`,cell(r.category),cell(r.roblox_user_id),r.player_id === null ? 'Game' : 'Player',cell(r.occurred_at,'date'),`${cell(r.properties,'json')}`]));
}
function renderRoot(records=[]) {
table('[data-root-metric-rows]',records.map(r=>[cell(r.category),cell(r.name),cell(r.value),cell(r.recorded_at,'date')]));
}
function renderSales(records=[]) {
table('[data-sale-rows]',records.map(r=>[cell(r.receipt_id),cell(r.name),cell(r.roblox_product_id),cell(r.roblox_user_id),cell(r.unit_price_robux,'robux'),cell(r.quantity),cell(r.robux,'robux'),cell(r.purchased_at,'date')]));
}
let chartTooltip;
function inspectChart(node, {width, height, left, right, top=0, bottom=0, count, pointX, inspect}) {
const svg=node.querySelector('svg');
if (!svg || !count) return;
svg.classList.add('interactive-chart');
svg.setAttribute('tabindex','0');
svg.setAttribute('aria-label',`${svg.getAttribute('aria-label')}. Move the pointer or use left and right arrow keys to inspect values.`);
svg.insertAdjacentHTML('beforeend','');
const overlay=svg.querySelector('.chart-inspection');
let selected=0;
const hide=()=>{
overlay.setAttribute('hidden','');
if (chartTooltip?.owner===svg) chartTooltip.hidden=true;
};
const show=(index, clientX, clientY)=>{
selected=Math.max(0,Math.min(count-1,index));
const detail=inspect(selected);
overlay.innerHTML=detail.overlay || '';
overlay.removeAttribute('hidden');
if (!chartTooltip) {
chartTooltip=document.createElement('div');
chartTooltip.className='chart-tooltip';
chartTooltip.setAttribute('role','tooltip');
document.body.append(chartTooltip);
const dismiss=()=>{
chartTooltip.hidden=true;
$$('.chart-inspection').forEach(item=>item.setAttribute('hidden',''));
};
document.addEventListener('scroll',dismiss,true);
window.addEventListener('resize',dismiss);
}
chartTooltip.owner=svg;
chartTooltip.innerHTML=`${cell(detail.label,'text')}
${detail.rows.map(row=>`${cell(row.name,'text')}${cell(row.value,row.format || 'decimal')}
`).join('')}`;
chartTooltip.hidden=false;
const bounds=chartTooltip.getBoundingClientRect();
const x=clientX+16+bounds.width>innerWidth-8 ? clientX-bounds.width-16 : clientX+16;
chartTooltip.style.left=`${Math.max(8,Math.min(x,innerWidth-bounds.width-8))}px`;
chartTooltip.style.top=`${Math.max(8,Math.min(clientY+16,innerHeight-bounds.height-8))}px`;
const description=`${detail.label}: ${detail.rows.map(row=>`${row.name}: ${format(row.value,row.format || 'decimal')}`).join('; ')}`;
svg.setAttribute('aria-label',description);
};
svg.addEventListener('pointermove',event=>{
const rect=svg.getBoundingClientRect();
const x=(event.clientX-rect.left)*width/rect.width;
const y=(event.clientY-rect.top)*height/rect.height;
if (xwidth-right || yheight-bottom) return hide();
let nearest=0;
for (let i=1;i{
const rect=svg.getBoundingClientRect();
show(selected,rect.left+pointX(selected)/width*rect.width,rect.top+rect.height*.25);
};
svg.addEventListener('focus',showSelected);
svg.addEventListener('keydown',event=>{
if (event.key==='Escape') return hide();
if (!['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return;
event.preventDefault();
selected=event.key==='Home' ? 0 : event.key==='End' ? count-1 : selected+(event.key==='ArrowRight' ? 1 : -1);
selected=Math.max(0,Math.min(count-1,selected));
showSelected();
});
['pointerleave','pointercancel','blur'].forEach(name=>svg.addEventListener(name,hide));
// Replacing a chart or changing the page must not leave an old tooltip visible.
if (chartTooltip) chartTooltip.hidden=true;
}
function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
if (chartTooltip) chartTooltip.hidden=true;
const node=$(selector); if (!node) return;
const values=series.flatMap(s=>s.values).filter(v=>typeof v === 'number' && Number.isFinite(v));
if (!labels.length || !values.length) { node.innerHTML='N/A — no reported values
'; return; }
const dual=separateAxes && series.length===2;
const colorFor=index=>series[index].color || seriesColor(index);
const width=760,height=230,left=48,right=dual ? 58 : 15,top=14,bottom=32;
const low=Math.min(0,...values),high=Math.max(...values)*1.1 || 1;
const scales=series.map(s=>{
const valid=s.values.filter(Number.isFinite);
return {low:Math.min(0,...valid),high:Math.max(0,...valid)*1.1 || 1};
});
const x=i=>left+i*(width-left-right)/Math.max(1,labels.length-1);
const y=(value,index=0)=>{
const range=dual ? scales[index] : {low,high};
return top+(range.high-value)*(height-top-bottom)/(range.high-range.low);
};
const grid=Array.from({length:4},(_,i)=>{
const yy=top+i*(height-top-bottom)/3;
const range=dual ? scales[0] : {low,high};
const tick=value=>new Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(value);
const rightTick=dual ? `${escape(tick(scales[1].high-i*(scales[1].high-scales[1].low)/3))}` : '';
return `${escape(tick(range.high-i*(range.high-range.low)/3))}${rightTick}`;
}).join('');
const paths=series.map((s,index)=>{
const color=colorFor(index);
let connected=false;
const path=s.values.map((value,i)=>{
if (typeof value !== 'number' || !Number.isFinite(value)) { connected=false; return ''; }
const point=`${connected ? 'L' : 'M'}${x(i)},${y(value,index)}`; connected=true; return point;
}).join(' ');
const points=s.values.map((value,i)=>typeof value === 'number' && Number.isFinite(value) ? `${escape(s.name)} · ${escape(labels[i])}: ${cell(value,s.format)}` : '').join('');
return `${points}`;
}).join('');
const ticks=labels.map((v,i)=>i % Math.max(1,Math.ceil(labels.length/6)) === 0 || i===labels.length-1 ? `${escape(v)}` : '').join('');
node.innerHTML=`${series.map((s,i)=>`${escape(s.name)}${dual ? ` (${i ? 'right' : 'left'} axis)` : ''}`).join('')}
`;
inspectChart(node,{width,height,left,right,top,bottom,count:labels.length,pointX:x,inspect:i=>({
label:labels[i],rows:series.map((s,j)=>({name:s.name,value:s.values[i],format:s.format,color:colorFor(j)})),
overlay:``+series.map((s,j)=>Number.isFinite(s.values[i]) ? `` : '').join('')
})});
}
function barChart(selector, values=[], labels=[]) {
if (chartTooltip) chartTooltip.hidden=true;
const node=$(selector); if (!node) return;
if (!values.some(value=>typeof value === 'number')) { node.innerHTML='N/A
'; return; }
const max=Math.max(...values.filter(v=>typeof v === 'number'),1),width=760,height=220,left=16,bottom=45;
const step=(width-left*2)/Math.max(1,values.length),barWidth=step*.7;
node.innerHTML=``;
inspectChart(node,{width,height,left,right:left,top:0,bottom,count:values.length,pointX:i=>left+step*(i+.5),inspect:i=>({
label:labels[i],rows:[{name:'Value',value:values[i],color:seriesColor(0)}],
overlay:``
})});
}
function seriesFromDaily(selector, daily, mapping, dateKey='day') {
const labels=daily.map(r=>String(r[dateKey]).slice(5,16).replace('T',' '));
lineChart(`[data-chart="${selector}"]`,mapping.map(([key,name])=>({name,format:name.includes('(%)') ? 'percent' : name.includes('(s)') ? 'duration' : 'decimal',values:daily.map(r=>r[key])})),labels);
}
function renderEngagement(data) {
if (!data) return;
const daily=data.daily;
seriesFromDaily('active-users',daily,[['dau','DAU'],['mau','Rolling MAU']]);
seriesFromDaily('new-returning',daily,[['new_users','New users'],['returning_users','Returning users']]);
seriesFromDaily('session-groups',daily,[['new_session_time','New-user average session (s)'],['returning_session_time','Returning-user average session (s)']]);
seriesFromDaily('first-five',daily,[['first_session_5min','First-session 5m retention (%)']]);
seriesFromDaily('playtime-total',daily,[['total_playtime','Total completed playtime (s)']]);
seriesFromDaily('playtime-average',daily,[['average_playtime','Playtime per DAU (s)']]);
}
function renderRetention(data) {
const node=$('[data-retention]'); if (!node) return;
const grid=data.retentionGrid;
node.innerHTML=['Cohort',...grid.columns].map(v=>`${escape(v)}
`).join('');
grid.rows.forEach(row=>{
node.insertAdjacentHTML('beforeend',`${escape(row.name)} · ${row.players}
${row.values.map(value=>`${cell(value,'percent')}
`).join('')}`);
});
}
let playerData, playerRequest=0;
async function loadPlayers() {
const ticket=++playerRequest;
const data=await api('players',{page:state.page,sort:state.sort,direction:state.direction,segment:state.segment,q:state.q});
if (ticket!==playerRequest) return;
playerData=data; fillMetrics(data.metrics);
const template=$('[data-player-template]'),sort=$('[data-player-sort]');
if (!template.options.length) {
Object.entries(data.templates).forEach(([key,value])=>template.add(new Option(value.label,key)));
data.fields.forEach(field=>sort.add(new Option(field.label,field.key)));
template.value=state.template; sort.value=state.sort;
}
const selected=data.templates[state.template] || data.templates.all;
const keys=[state.sort,...selected.columns.filter(key=>key!==state.sort)];
const fields=keys.map(key=>data.fields.find(field=>field.key===key)).filter(Boolean);
$('[data-player-head]').innerHTML='Player | '+fields.map(f=>`${escape(f.label)} | `).join('');
table('[data-player-rows]',data.playerRows.map(p=>[playerLink(p.playerId,p.name,p.robloxUserId),...fields.map(f=>cell(p[f.key],f.format))]));
const page=data.pagination; state.page=page.page;
$('[data-page-summary]').textContent=`${page.total.toLocaleString()} matching players · ${page.pageSize} per page`;
$('[data-page-number]').textContent=`${page.page} / ${page.pages}`;
$('[data-prev]').disabled=page.page<=1; $('[data-next]').disabled=page.page>=page.pages;
$('[data-group-definitions]').textContent=Object.entries(data.groupDefinitions).map(([key,value])=>`${key}: ${value}`).join(' · ');
}
function bindPlayers() {
const refresh=()=>{state.page=1;loadPlayers().catch(e=>error(e.message));};
$('[data-player-template]').addEventListener('change',event=>{
state.template=event.target.value; state.sort=playerData.templates[state.template].columns[0];
$('[data-player-sort]').value=state.sort; refresh();
});
$('[data-player-sort]').addEventListener('change',e=>{state.sort=e.target.value;refresh();});
$('[data-player-direction]').addEventListener('change',e=>{state.direction=e.target.value;refresh();});
$$('[data-player-segment]').forEach(button=>button.addEventListener('click',()=>{
state.segment=button.dataset.playerSegment;
$$('[data-player-segment]').forEach(other=>other.classList.toggle('active',other===button)); refresh();
}));
let searchTimer;
$('[data-player-search]').addEventListener('input',event=>{clearTimeout(searchTimer);state.q=event.target.value;searchTimer=setTimeout(refresh,220);});
$('[data-prev]').addEventListener('click',()=>{state.page--;loadPlayers().catch(e=>error(e.message));});
$('[data-next]').addEventListener('click',()=>{state.page++;loadPlayers().catch(e=>error(e.message));});
}
function bindTabs(tabAttribute,panelAttribute) {
$$(`[${tabAttribute}]`).forEach(tab=>tab.addEventListener('click',event=>{
event.preventDefault();
$$(`[${tabAttribute}]`).forEach(other=>other.classList.toggle('active',other===tab));
$$(`[${panelAttribute}]`).forEach(panel=>panel.classList.toggle('active',panel.getAttribute(panelAttribute)===tab.getAttribute(tabAttribute)));
}));
}
let profileId=params.get('player_id'),profileRequest=0;
async function loadProfile() {
const ticket=++profileRequest;
if (!profileId) {
const list=await api('players',{page_size:1}); profileId=list.playerRows[0]?.playerId;
if (!profileId) throw new Error('N/A — this game has no players.');
}
const selected=$('[data-profile-value]').value || 'cash';
const second=$('[data-profile-value-second]');
const secondary=second.options.length ? second.value : 'robux';
const data=await api(`players/${profileId}`,{value:selected,value2:secondary});
if (ticket!==profileRequest) return;
const p=data.player;
$('[data-profile-name]').textContent=format(p.name);
$('[data-profile-id]').textContent=`Internal player ${p.playerId} · Roblox user ${p.robloxUserId} · First seen ${format(p.firstSeen,'date')} · Snapshot ${format(p.snapshotAt,'date')}`;
$('[data-profile-tags]').innerHTML=(data.groups || []).map(v=>`${escape(v)}`).join('');
fillMetrics(p);
const chooser=$('[data-profile-value]');
if (!chooser.options.length) data.historyFields.forEach(f=>chooser.add(new Option(f.label,f.key)));
if (!second.options.length) {
second.add(new Option('None',''));
data.historyFields.forEach(f=>second.add(new Option(f.label,f.key)));
}
chooser.value=selected;
second.value=secondary;
table('[data-latest-value-rows]',data.fields.map(f=>[`${escape(f.label)}${escape(f.source)}
`,cell(p[f.key],f.format)]));
table('[data-snapshot-rows]',data.snapshots.map(r=>[cell(r.captured_at,'date'),`${cell(r.data,'json')}`]));
lineChart('[data-chart="profile"]',data.series,data.labels,{separateAxes:true});
renderSessions(data.sessions); renderEvents(data.events); renderSales(data.sales || []);
table('[data-activity-rows]',data.activities.map(r=>[cell(r.name),cell(r.started_at,'date'),cell(r.duration,'duration'),cell(r.step_count),cell(r.prompt_count),cell(r.outcome),`${cell(r.captured_values,'json')}`]));
}
let activityData,activityId,toastTimer;
function toast(message) {
const node=$('[data-toast]'); node.textContent=message;node.classList.add('visible');
clearTimeout(toastTimer);toastTimer=setTimeout(()=>node.classList.remove('visible'),2600);
}
function eventEditor(name='') {
return ``;
}
function fieldEditor(name='') {
return ``;
}
async function loadActivities() {
const query={segment:$('[data-activity-group]').value}; if (activityId) query.activity_id=activityId;
const data=await api('activities',query); activityData=data; fillMetrics(data.metrics);
const chooser=$('[data-activity-select]');
if (!chooser.options.length) data.definitions.forEach(d=>chooser.add(new Option(d.name,d.id)));
if (!data.definition) return;
chooser.value=data.definition.id; activityId=data.definition.id;
$('[data-activity-journey]').innerHTML=data.journey.map((step,i)=>`${i ? '' : ''}${cell(step.label)}${cell(step.event)}${cell(step.reachPercent,'percent')} reach${cell(step.runs)} runs${i ? `${cell(step.dropoffPercent,'percent')} drop from prior stage` : 'All activity starts'}
`).join('');
table('[data-stage-rows]',data.journey.map((step,i)=>[`${i+1}. ${cell(step.event)}`,cell(step.runs),cell(step.reachPercent,'percent'),cell(step.conversionPercent,'percent'),cell(step.dropoffPercent,'percent'),cell(step.droppedRuns),cell(step.unknownRuns)]));
lineChart('[data-chart="trend"]',data.series,data.labels);
table('[data-captured-rows]',data.captured.map(r=>[cell(r.key),cell(r.summary),cell(r.value)]));
table('[data-outcome-rows]',data.outcomes.map(r=>[cell(r.outcome),cell(r.runs),cell(r.share,'percent'),cell(r.average,'duration'),cell(r.median,'duration')]));
$('[data-activity-name]').value=data.definition.name;
$('[data-start-event]').value=data.definition.start_event;
$('[data-end-event]').value=data.definition.end_event || 'N/A';
$('[data-config-events]').innerHTML=(data.definition.configuration.steps || []).map(eventEditor).join('');
$('[data-capture-fields]').innerHTML=(data.definition.configuration.fields || []).map(fieldEditor).join('');
}
function bindActivities() {
bindTabs('data-activity-tab','data-activity-panel');
$('[data-activity-select]').addEventListener('change',event=>{activityId=event.target.value;loadActivities().catch(e=>error(e.message));});
$('[data-activity-group]').addEventListener('change',()=>loadActivities().catch(e=>error(e.message)));
$('[data-add-step]').addEventListener('click',()=>$('[data-config-events]').insertAdjacentHTML('beforeend',eventEditor()));
$('[data-add-field]').addEventListener('click',()=>$('[data-capture-fields]').insertAdjacentHTML('beforeend',fieldEditor()));
document.addEventListener('click',event=>{if(event.target.closest('[data-remove-row]')) event.target.closest('.config-event,.capture-field').remove();});
$('[data-save-activity]').addEventListener('click',()=>toast('UI draft only — database definition is unchanged.'));
$('[data-new-activity]').addEventListener('click',()=>{
$('[data-activity-tab="configure"]').click();
$('[data-activity-name]').value=''; $('[data-start-event]').value=''; $('[data-end-event]').value='';
$('[data-config-events]').innerHTML='';$('[data-capture-fields]').innerHTML='';
$('[data-activity-name]').focus();toast('New UI draft. Ingestion and saving are not connected.');
});
}
function renderMonetization(data) {
fillMetrics(data.metrics);
seriesFromDaily('revenue-daily',data.daily,[['robux','Daily Robux spent']]);
seriesFromDaily('revenue-hourly',data.hourly,[['robux','Hourly Robux spent']],'hour');
seriesFromDaily('payers',data.daily,[['payers','Daily paying users']]);
seriesFromDaily('payer-conversion',data.daily,[['conversion','Payer conversion (%)']]);
seriesFromDaily('arpdau',data.daily,[['arpdau','Robux per DAU']]);
barChart('[data-chart="products"]',data.products.map(p=>p.robux),data.products.map(p=>format(p.name)));
table('[data-product-rows]',data.products.map(p=>[cell(p.name),cell(p.roblox_product_id),cell(p.current_price_robux,'robux'),cell(p.robux,'robux'),cell(p.quantity),cell(p.buyers),cell(p.perPayer,'decimal')]));
}
function renderGamepasses(data) {
fillMetrics(data.metrics);
const passes=data.gamepasses, colors=[seriesColor(0),seriesColor(1)];
const node=$('[data-chart="ownership"]');
if (!passes.length) node.innerHTML='N/A — no gamepasses reported
';
else {
const width=760,height=260,left=40,top=18,bottom=45,plotHeight=height-top-bottom;
const step=(width-left-15)/passes.length,barWidth=Math.min(42,step*.28);
const grid=[0,25,50,75,100].map(v=>{const y=top+(100-v)/100*plotHeight;return `${v}%`;}).join('');
const bars=passes.map((pass,i)=>{
const x=left+step*i+step/2;
return ['playerPercent','payerPercent'].map((key,j)=>{
const value=pass[key],xx=x+(j ? 3 : -barWidth-3);
return missing(value) ? `N/A` : `${cell(pass.name)} · ${j ? 'Paying players' : 'All players'}: ${cell(value,'percent')}`;
}).join('')+`${cell(pass.name)}`;
}).join('');
node.innerHTML=`All playersPaying players
`;
inspectChart(node,{width,height,left,right:15,top,bottom,count:passes.length,pointX:i=>left+step*(i+.5),inspect:i=>({
label:passes[i].name,rows:[{name:'All players',value:passes[i].playerPercent,format:'percent',color:colors[0]},{name:'Paying players',value:passes[i].payerPercent,format:'percent',color:colors[1]}],
overlay:``
})});
}
table('[data-gamepass-rows]',passes.map(p=>[cell(p.name),cell(p.roblox_gamepass_id,'text'),cell(p.owners),cell(p.playerPercent,'percent'),cell(p.paying_owners),cell(p.payerPercent,'percent'),cell(p.coverage,'percent'),cell(p.newest_check,'date')]));
}
let eventChartData,eventSelection=new Set();
function drawEventChart() {
if (!eventChartData) return;
const series=eventChartData.series.map((item,index)=>({...item,color:seriesColor(index)})).filter(item=>eventSelection.has(item.name));
$('[data-event-selection-count]').textContent=`${series.length} of ${eventChartData.series.length}`;
if (series.length) lineChart('[data-chart="trend"]',series,eventChartData.labels);
else {
if (chartTooltip) chartTooltip.hidden=true;
$('[data-chart="trend"]').innerHTML='Select events to graph.
';
}
try { localStorage.setItem(`eventChartSelection:${state.game.id}`,JSON.stringify([...eventSelection])); } catch {}
}
function renderEventChart(data) {
eventChartData=data;
let saved;
try { saved=JSON.parse(localStorage.getItem(`eventChartSelection:${state.game.id}`)); } catch {}
eventSelection=new Set(data.series.map(s=>s.name).filter(name=>!Array.isArray(saved) || saved.includes(name)));
$('[data-event-options]').innerHTML=data.series.map((s,i)=>``).join('');
$('[data-event-search]').value='';
drawEventChart();
}
function bindEventChart() {
$('[data-event-options]').addEventListener('change',event=>{
if (!event.target.matches('input[type="checkbox"]')) return;
if (event.target.checked) eventSelection.add(event.target.value);
else eventSelection.delete(event.target.value);
drawEventChart();
});
for (const [selector,checked] of [['[data-events-all]',true],['[data-events-none]',false]]) {
$(selector).addEventListener('click',()=>{
if (!eventChartData) return;
eventSelection=new Set(checked ? eventChartData.series.map(s=>s.name) : []);
$$('[data-event-options] input').forEach(input=>input.checked=checked);
drawEventChart();
});
}
$('[data-event-search]').addEventListener('input',event=>{
const query=event.target.value.trim().toLowerCase();
$$('[data-event-option]').forEach(label=>label.hidden=!label.textContent.toLowerCase().includes(query));
});
}
async function loadCurrentPage() {
if (chartTooltip) chartTooltip.hidden=true;
clearError();
const page=document.body.dataset.page;
if (page==='players') return loadPlayers();
if (page==='player-info') return loadProfile();
if (page==='activities') return loadActivities();
if (page==='monetization') return renderMonetization(await api('monetization'));
if (page==='gamepasses') return renderGamepasses(await api('gamepasses'));
if (page==='settings') {
$$('[data-game-field]').forEach(input=>input.value=format(state.game[input.dataset.gameField],'text'));
return;
}
const data=await api(page,page==='sessions' ? {segment:$('[data-session-group]').value} : {});
fillMetrics(data.metrics);
if (page==='events') renderEventChart(data);
else lineChart('[data-chart="trend"]',data.series,data.labels);
barChart('[data-chart="events"]',data.eventValues,data.eventLabels);
if (data.sessions) renderSessions(data.sessions);
if (data.events) renderEvents(data.events);
if (data.eventSummary) table('[data-event-summary]',data.eventSummary.map(r=>[cell(r.name),cell(r.categories.join(', ')),cell(r.total),cell(r.average,'decimal'),cell(r.players),cell(r.latest,'date')]));
if (data.retentionGrid) renderRetention(data);
if (data.engagement) renderEngagement(data.engagement);
if (data.rootMetrics) {renderRoot(data.rootMetrics.rows);lineChart('[data-chart="ccu"]',data.rootMetrics.series,data.rootMetrics.labels);}
}
async function boot() {
const result=await fetchJSON('/api/games');
const requested=params.get('game_id') || localStorage.getItem('sampleGameId');
const chosen=result.games.find(g=>String(g.id)===requested);
if (params.has('game_id') && !chosen) throw new Error('Game not found. Open the dashboard without game_id to choose a game.');
state.game=chosen || result.games[0];
if (!state.game) throw new Error('No games available. Run the database seed.');
localStorage.setItem('sampleGameId',state.game.id);
$$('[data-game-select]').forEach(select=>{
select.replaceChildren(...result.games.map(game=>new Option(`${game.name} · #${game.id}`,game.id)));
select.value=state.game.id;
select.addEventListener('change',()=>{
localStorage.setItem('sampleGameId',select.value);
const url=new URL(location.href);url.searchParams.set('game_id',select.value);url.searchParams.delete('player_id');
location.href=url;
});
});
$('.breadcrumbs').textContent=`${state.game.name} / ${document.title.split(' — ')[0]}`;
$$('a[href*="-mockup.html"]').forEach(link=>{
const url=new URL(link.getAttribute('href'),location.href);url.searchParams.set('game_id',state.game.id);url.searchParams.set('days',state.days);link.href=url;
});
const date=$('[data-days]');
if (date) {
date.value=state.days;
if (['players','settings','gamepasses'].includes(document.body.dataset.page)) date.hidden=true;
date.addEventListener('change',()=>{
state.days=Number(date.value);
const url=new URL(location.href);url.searchParams.set('days',state.days);url.searchParams.set('game_id',state.game.id);history.replaceState(null,'',url);
$$('a[href*="-mockup.html"]').forEach(link=>{const target=new URL(link.href);target.searchParams.set('days',state.days);link.href=target;});
loadCurrentPage().catch(e=>error(e.message));
});
}
if (document.body.dataset.page==='players') bindPlayers();
if (document.body.dataset.page==='events') bindEventChart();
if (document.body.dataset.page==='player-info') {
bindTabs('data-profile-tab','data-profile-panel');
$('[data-profile-value]').addEventListener('change',()=>loadProfile().catch(e=>error(e.message)));
$('[data-profile-value-second]').addEventListener('change',()=>loadProfile().catch(e=>error(e.message)));
$('[data-session-rows]').addEventListener('click',event=>{
const button=event.target.closest('[data-join-session]');
if (button && !button.disabled) toast('Join session is a placeholder — Roblox joining is not connected yet.');
});
}
if (document.body.dataset.page==='activities') bindActivities();
if (document.body.dataset.page==='sessions') $('[data-session-group]').addEventListener('change',()=>loadCurrentPage().catch(e=>error(e.message)));
document.addEventListener('change',event=>{
if (event.target.matches('select.select') && !reducedMotion.matches && event.target.animate) event.target.animate([{boxShadow:'0 0 0 3px rgb(124 140 255 / 16%)'},{boxShadow:'0 0 0 3px transparent'}],{duration:280,easing:'ease-out'});
});
await loadCurrentPage();
}
boot().catch(e=>error(e.message));