Files
Stat-Dashboard/mockups/data-ui.js

508 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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='<div class="empty">N/A — data unavailable</div>');
$$('tbody').forEach(node=>node.innerHTML=`<tr><td colspan="${node.closest('table').querySelectorAll('th').length || 1}" class="empty">N/A — data unavailable</td></tr>`);
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=>`<tr>${record.map(value=>`<td>${value}</td>`).join('')}</tr>`).join('') : `<tr><td colspan="${span}" class="empty">N/A — no records for this selection</td></tr>`;
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 `<a class="player-name-link" href="player-info-mockup.html?${query}">${cell(name)}</a><div class="card-note">Roblox ID ${cell(robloxId,'text')}</div>`;
}
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'),`<button class="button join-session" data-join-session="${cell(r.id)}" ${r.joinAvailable ? '' : 'disabled'} title="${r.joinAvailable ? 'Join this session (placeholder)' : 'Session has ended or is too old to join'}">Join session</button>`,`<span class="pill ${r.ended_at ? 'good' : 'warn'}">${r.ended_at ? 'Completed' : 'End unreported'}</span>`]));
}
function renderEvents(records=[]) {
table('[data-event-rows]',records.map(r=>[`<code class="event-name">${cell(r.name)}</code>`,cell(r.category),cell(r.roblox_user_id),r.player_id === null ? 'Game' : 'Player',cell(r.occurred_at,'date'),`<code class="json-value">${cell(r.properties,'json')}</code>`]));
}
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','<g class="chart-inspection" aria-hidden="true" hidden></g>');
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=`<div class="chart-tooltip-title">${cell(detail.label,'text')}</div>${detail.rows.map(row=>`<div class="chart-tooltip-row"><span><i class="dot" style="background:${row.color}"></i>${cell(row.name,'text')}</span><strong>${cell(row.value,row.format || 'decimal')}</strong></div>`).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 (x<left || x>width-right || y<top || y>height-bottom) return hide();
let nearest=0;
for (let i=1;i<count;i++) if (Math.abs(pointX(i)-x)<Math.abs(pointX(nearest)-x)) nearest=i;
show(nearest,event.clientX,event.clientY);
});
const showSelected=()=>{
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='<div class="empty">N/A — no reported values</div>'; 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 ? `<text style="fill:${seriesColor(1)}" x="${width-right+6}" y="${yy+3}">${escape(tick(scales[1].high-i*(scales[1].high-scales[1].low)/3))}</text>` : '';
return `<line class="gridline" x1="${left}" y1="${yy}" x2="${width-right}" y2="${yy}"/><text ${dual ? `style="fill:${seriesColor(0)}"` : ''} x="${left-6}" y="${yy+3}" text-anchor="end">${escape(tick(range.high-i*(range.high-range.low)/3))}</text>${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) ? `<circle cx="${x(i)}" cy="${y(value,index)}" r="2" fill="${color}"><title>${escape(s.name)} · ${escape(labels[i])}: ${cell(value,s.format)}</title></circle>` : '').join('');
return `<path class="line ${index ? 'secondary' : ''}" style="stroke:${color}" pathLength="100" d="${path}"/>${points}`;
}).join('');
const ticks=labels.map((v,i)=>i % Math.max(1,Math.ceil(labels.length/6)) === 0 || i===labels.length-1 ? `<text x="${x(i)}" y="${height-8}" text-anchor="middle">${escape(v)}</text>` : '').join('');
node.innerHTML=`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escape(series.map(s=>s.name).join(', '))}">${grid}${paths}${ticks}</svg><div class="legend">${series.map((s,i)=>`<span><i class="dot" style="background:${colorFor(i)}"></i>${escape(s.name)}${dual ? ` (${i ? 'right' : 'left'} axis)` : ''}</span>`).join('')}</div>`;
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:`<line class="chart-crosshair" x1="${x(i)}" x2="${x(i)}" y1="${top}" y2="${height-bottom}"/>`+series.map((s,j)=>Number.isFinite(s.values[i]) ? `<circle class="chart-hover-point" cx="${x(i)}" cy="${y(s.values[i],j)}" r="4" style="stroke:${colorFor(j)}"/>` : '').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='<div class="empty">N/A</div>'; 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=`<svg class="chart short" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escape(labels.join(', '))}">${values.map((v,i)=>{
if (typeof v !== 'number') return '';
const h=v/max*(height-bottom-15),x=left+i*step;
return `<rect class="bar" style="animation-delay:${Math.min(i,12)*22}ms" x="${x}" y="${height-bottom-h}" width="${barWidth}" height="${h}" rx="3"><title>${escape(labels[i])}: ${cell(v)}</title></rect><text x="${x+barWidth/2}" y="${height-20}" text-anchor="middle">${escape(labels[i].slice(0,15))}</text>`;
}).join('')}</svg>`;
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:`<rect class="chart-hover-band" x="${left+i*step}" y="0" width="${step}" height="${height-bottom}"/>`
})});
}
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=>`<div class="heat-label">${escape(v)}</div>`).join('');
grid.rows.forEach(row=>{
node.insertAdjacentHTML('beforeend',`<div class="heat-label">${escape(row.name)} · ${row.players}</div>${row.values.map(value=>`<div style="background:rgba(124,140,255,${missing(value) ? .03 : .08+value/100*.7})">${cell(value,'percent')}</div>`).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='<th>Player</th>'+fields.map(f=>`<th>${escape(f.label)}</th>`).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=>`<span class="pill">${escape(v)}</span>`).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)}<div class="card-note">${escape(f.source)}</div>`,cell(p[f.key],f.format)]));
table('[data-snapshot-rows]',data.snapshots.map(r=>[cell(r.captured_at,'date'),`<code class="json-value">${cell(r.data,'json')}</code>`]));
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),`<code class="json-value">${cell(r.captured_values,'json')}</code>`]));
}
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 `<div class="config-event"><select class="select"><option>Step</option><option>Prompt</option><option>Outcome</option></select><input class="filter-input" value="${escape(name)}" placeholder="event_name"><button class="icon-button" data-remove-row aria-label="Remove event">×</button></div>`;
}
function fieldEditor(name='') {
return `<div class="capture-field"><input class="filter-input" value="${escape(name)}" placeholder="property_name"><select class="select"><option>Latest value</option><option>First value</option><option>Change over run</option></select><button class="icon-button" data-remove-row aria-label="Remove value">×</button></div>`;
}
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 ? '<span class="flow-connector"></span>' : ''}<div class="flow-node"><span class="flow-kind">${cell(step.label)}</span><code>${cell(step.event)}</code><strong class="stage-reach">${cell(step.reachPercent,'percent')} reach</strong><small>${cell(step.runs)} runs</small>${i ? `<small class="stage-drop">${cell(step.dropoffPercent,'percent')} drop from prior stage</small>` : '<small>All activity starts</small>'}</div>`).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='<div class="empty">N/A — no gamepasses reported</div>';
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 `<line class="gridline" x1="${left}" y1="${y}" x2="745" y2="${y}"/><text x="33" y="${y+3}" text-anchor="end">${v}%</text>`;}).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) ? `<text x="${xx+barWidth/2}" y="${height-bottom-7}" text-anchor="middle">N/A</text>` : `<rect class="bar" style="fill:${colors[j]};animation-delay:${i*25}ms" x="${xx}" y="${top+(100-value)/100*plotHeight}" width="${barWidth}" height="${value/100*plotHeight}" rx="3"><title>${cell(pass.name)} · ${j ? 'Paying players' : 'All players'}: ${cell(value,'percent')}</title></rect>`;
}).join('')+`<text x="${x}" y="${height-17}" text-anchor="middle">${cell(pass.name)}</text>`;
}).join('');
node.innerHTML=`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Gamepass ownership percentages">${grid}${bars}</svg><div class="legend"><span><i class="dot" style="background:${colors[0]}"></i>All players</span><span><i class="dot" style="background:${colors[1]}"></i>Paying players</span></div>`;
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:`<rect class="chart-hover-band" x="${left+i*step}" y="${top}" width="${step}" height="${plotHeight}"/>`
})});
}
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='<div class="empty">Select events to graph.</div>';
}
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)=>`<label class="event-option" data-event-option><input type="checkbox" value="${cell(s.name,'text')}" ${eventSelection.has(s.name) ? 'checked' : ''}><i class="dot" style="background:${seriesColor(i)}"></i><span>${cell(s.name,'text')}</span></label>`).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));