708 lines
49 KiB
JavaScript
708 lines
49 KiB
JavaScript
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-overview-analysis]').forEach(node=>node.textContent='N/A — analysis unavailable');
|
||
$$('[data-analysis-dates], [data-analysis-updated]').forEach(node=>node.textContent='');
|
||
$$('[data-health-body]').forEach(node=>node.textContent='N/A — report unavailable');
|
||
$$('[data-health-dates], [data-health-notes], [data-health-updated]').forEach(node=>node.textContent='');
|
||
$$('[data-summary-executive]').forEach(node=>node.textContent='N/A — summaries unavailable');
|
||
$$('[data-chart]').forEach(node=>{node._chartDraw=null;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;
|
||
const card=node.closest('.card');
|
||
let pager=card?.querySelector('[data-table-pagination]');
|
||
if (pager) pager.remove();
|
||
let page=1;
|
||
const pages=Math.max(1,Math.ceil(records.length/20));
|
||
const draw=()=>{
|
||
node.innerHTML=records.length ? records.slice((page-1)*20,page*20).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>`;
|
||
if (pager) {
|
||
pager.querySelector('[data-prev]').disabled=page===1;
|
||
pager.querySelector('[data-next]').disabled=page===pages;
|
||
pager.querySelector('select').value=page;
|
||
pager.querySelector('span').textContent=`${records.length} records · 20 per page`;
|
||
}
|
||
reveal(node);
|
||
};
|
||
if (pages>1 && card) {
|
||
pager=document.createElement('div');pager.className='table-footer';pager.dataset.tablePagination='';
|
||
pager.innerHTML=`<span></span><div class="inline"><button class="button" data-prev>Previous</button><select class="select" aria-label="Table page">${Array.from({length:pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${pages}</option>`).join('')}</select><button class="button" data-next>Next</button></div>`;
|
||
card.append(pager);
|
||
pager.querySelector('[data-prev]').addEventListener('click',()=>{page--;draw();});
|
||
pager.querySelector('[data-next]').addEventListener('click',()=>{page++;draw();});
|
||
pager.querySelector('select').addEventListener('change',e=>{page=Number(e.target.value);draw();});
|
||
}
|
||
draw();
|
||
}
|
||
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' : 'Open'}</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;
|
||
}
|
||
|
||
const chartResizeObserver=typeof ResizeObserver==='undefined' ? null : new ResizeObserver(entries=>{
|
||
for (const {target} of entries) {
|
||
const width=target.getBoundingClientRect().width;
|
||
if (Math.abs(width-target._chartWidth)>1) {target._chartWidth=width;target._chartDraw?.();}
|
||
}
|
||
});
|
||
function watchChart(node,draw) {
|
||
node._chartDraw=draw;node._chartWidth=node.getBoundingClientRect().width;
|
||
if (!node._chartObserved) {chartResizeObserver?.observe(node);node._chartObserved=true;}
|
||
}
|
||
function chartLayout(node,short=false,minimumWidth=0) {
|
||
const style=getComputedStyle(node);
|
||
const available=node.clientWidth-(parseFloat(style.paddingLeft)||0)-(parseFloat(style.paddingRight)||0);
|
||
return {width:Math.max(220,minimumWidth,Math.round(available)||760),height:short ? 170 : parseFloat(style.getPropertyValue('--chart-height'))||230};
|
||
}
|
||
function axisLabel(value,maxChars) {
|
||
let text=String(value);
|
||
if (text.endsWith(' UTC')) text=text.slice(0,6);
|
||
return text.length>maxChars ? text.slice(0,Math.max(1,maxChars-1))+'…' : text;
|
||
}
|
||
const chartSeriesSelections=new Map();
|
||
function bindLinePicker(node,selection,redraw) {
|
||
const picker=node.querySelector('[data-series-picker]');if (!picker) return;
|
||
picker.addEventListener('change',event=>{
|
||
if (!event.target.matches('[data-series-name]')) return;
|
||
if (event.target.checked) selection.visible.add(event.target.value);
|
||
else selection.visible.delete(event.target.value);
|
||
redraw();
|
||
});
|
||
picker.querySelector('[data-series-all]').addEventListener('click',()=>{selection.visible=new Set(selection.known);redraw();});
|
||
picker.querySelector('[data-series-none]').addEventListener('click',()=>{selection.visible.clear();redraw();});
|
||
const search=picker.querySelector('[data-series-search]');
|
||
const applySearch=()=>picker.querySelectorAll('[data-series-option]').forEach(label=>label.hidden=!label.textContent.toLowerCase().includes((node._seriesSearch || '').toLowerCase()));
|
||
search.value=node._seriesSearch || '';
|
||
search.addEventListener('input',()=>{node._seriesSearch=search.value;applySearch();});
|
||
applySearch();
|
||
}
|
||
function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
|
||
if (chartTooltip) chartTooltip.hidden=true;
|
||
const node=$(selector); if (!node) return;
|
||
node._chartDraw=null;
|
||
const allSeries=series,redraw=()=>lineChart(selector,allSeries,labels,{separateAxes});
|
||
const needsPicker=allSeries.length>2 && !(document.body.dataset.page==='events' && selector==='[data-chart="trend"]');
|
||
let pickerMarkup='',selection;
|
||
if (needsPicker) {
|
||
const key=`${state.game?.id}:${selector}`;
|
||
if (!chartSeriesSelections.has(key)) chartSeriesSelections.set(key,{known:new Set(),visible:new Set()});
|
||
selection=chartSeriesSelections.get(key);
|
||
allSeries.forEach(item=>{if (!selection.known.has(item.name)) {selection.known.add(item.name);selection.visible.add(item.name);}});
|
||
series=allSeries.map((item,i)=>({...item,color:item.color || seriesColor(i)})).filter(item=>selection.visible.has(item.name));
|
||
const open=node.querySelector('[data-series-picker]')?.open;
|
||
pickerMarkup=`<details class="event-picker chart-series-picker" data-series-picker ${open ? 'open' : ''}><summary>Choose lines <span class="card-note">${series.length} of ${allSeries.length}</span></summary><div class="event-picker-panel"><div class="event-picker-toolbar"><input class="filter-input" type="search" data-series-search aria-label="Find graph lines" placeholder="Find a line"><button class="button" data-series-all>All</button><button class="button" data-series-none>None</button></div><div class="event-options">${allSeries.map((item,i)=>`<label class="event-option" data-series-option><input type="checkbox" data-series-name value="${cell(item.name,'text')}" ${selection.visible.has(item.name) ? 'checked' : ''}><i class="dot" style="background:${item.color || seriesColor(i)}"></i><span>${cell(item.name,'text')}</span></label>`).join('')}</div></div></details>`;
|
||
}
|
||
const values=series.flatMap(s=>s.values).filter(v=>typeof v === 'number' && Number.isFinite(v));
|
||
if (!labels.length || !values.length) {
|
||
node.innerHTML=pickerMarkup+`<div class="empty">${needsPicker && !series.length ? 'Select lines to graph.' : 'N/A — no reported values'}</div>`;
|
||
if (needsPicker) {bindLinePicker(node,selection,redraw);watchChart(node,redraw);}
|
||
return;
|
||
}
|
||
const dual=separateAxes && series.length===2;
|
||
const colorFor=index=>series[index].color || seriesColor(index);
|
||
const {width,height}=chartLayout(node),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 tickCount=Math.min(labels.length,Math.max(2,Math.floor((width-left-right)/110)));
|
||
const tickIndices=new Set(Array.from({length:tickCount},(_,i)=>Math.round(i*(labels.length-1)/Math.max(1,tickCount-1))));
|
||
const ticks=labels.map((v,i)=>tickIndices.has(i) ? `<text x="${x(i)}" y="${height-8}" text-anchor="${i===0 ? 'start' : i===labels.length-1 ? 'end' : 'middle'}">${escape(axisLabel(v,14))}</text>` : '').join('');
|
||
node.innerHTML=pickerMarkup+`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" 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>`;
|
||
if (needsPicker) bindLinePicker(node,selection,redraw);
|
||
watchChart(node,redraw);
|
||
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;
|
||
node._chartDraw=null;
|
||
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,height}=chartLayout(node,true,labels.length*90),left=16,bottom=40;
|
||
const step=(width-left*2)/Math.max(1,values.length),barWidth=step*.7;
|
||
node.innerHTML=`<svg class="chart short" style="min-width:${width}px" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" 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-16}" text-anchor="middle">${escape(axisLabel(labels[i],Math.floor(step/7)))}</text>`;
|
||
}).join('')}</svg>`;
|
||
watchChart(node,()=>barChart(selector,values,labels));
|
||
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 renderHealthReports(reports) {
|
||
const date=value=>new Intl.DateTimeFormat('en-GB',{timeZone:'UTC',dateStyle:'medium'}).format(new Date(value+'T00:00:00Z'));
|
||
for (const report of reports) {
|
||
const node=$(`[data-health-report="${report.periodDays}"]`);if (!node) continue;
|
||
const covered=report.startDate===report.endDate ? date(report.startDate) : `${date(report.startDate)} – ${date(report.endDate)}`;
|
||
node.querySelector('[data-health-dates]').textContent=`${report.gameName} · ${covered} · UTC`;
|
||
node.querySelector('[data-health-body]').innerHTML=report.paragraphs.map(p=>`<p><strong>${cell(p.title,'text')}</strong> ${cell(p.text,'text')}</p>`).join('')+(report.weekdayComparison ? `<p class="health-weekday">${cell(report.weekdayComparison,'text')}</p>` : '');
|
||
node.querySelector('[data-health-notes]').textContent='';
|
||
node.querySelector('[data-health-updated]').textContent=`Updated ${format(report.generatedAt,'date')} UTC · Compared with ${date(report.comparisonStart)} – ${date(report.comparisonEnd)}`;
|
||
}
|
||
}
|
||
function renderOverviewAnalysis(reports) {
|
||
const report=reports.find(r=>r.periodDays===7);
|
||
if (!report?.executive) return;
|
||
const date=value=>new Intl.DateTimeFormat('en-GB',{timeZone:'UTC',dateStyle:'medium'}).format(new Date(value+'T00:00:00Z'));
|
||
$('[data-analysis-dates]').textContent=`${report.gameName} | Trends through ${date(report.endDate)} UTC`;
|
||
$('[data-overview-analysis]').innerHTML=report.executive.paragraphs.map(p=>`<p><strong>${cell(p.title,'text')}</strong> ${cell(p.text,'text')}</p>`).join('');
|
||
$('[data-analysis-updated]').textContent=`Seven-day trends versus the preceding week, with latest-day context | Updated ${format(report.generatedAt,'date')} UTC`;
|
||
}
|
||
function renderRetention(data) {
|
||
const node=$('[data-retention]'); if (!node) return;
|
||
const grid=data.retentionGrid;
|
||
if (data.weekRetention) lineChart('[data-chart="retention-week"]',data.weekRetention.series,data.weekRetention.labels);
|
||
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,ranking:$('[data-leaderboard-value]').value || 'level'});
|
||
if (ticket!==playerRequest) return;
|
||
playerData=data; fillMetrics(data.metrics);
|
||
const ranking=$('[data-leaderboard-value]');
|
||
if (!ranking.options.length) {
|
||
data.fields.filter(f=>!['robux','playtime','cash'].includes(f.key)).forEach(f=>ranking.add(new Option(f.label,f.key)));
|
||
ranking.value='level';
|
||
}
|
||
$('[data-leaderboards]').innerHTML=data.leaderboards.map(board=>`<article class="card card-pad"><div class="card-title">${cell(board.label,'text')}</div><ol class="fame-list">${board.players.map((p,i)=>`<li><span class="fame-rank">${i+1}</span><div class="avatar-placeholder" role="img" aria-label="Avatar placeholder for ${cell(p.name,'text')}"><span>${cell((p.name || '?').slice(0,2).toUpperCase(),'text')}</span></div><div class="fame-player">${playerLink(p.playerId,p.name,p.robloxUserId)}</div><strong>${cell(p.value,board.format)}</strong></li>`).join('') || '<li class="empty">N/A</li>'}</ol></article>`).join('');
|
||
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`;
|
||
const pageChooser=$('[data-player-page]');
|
||
pageChooser.innerHTML=Array.from({length:page.pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${page.pages}</option>`).join('');pageChooser.value=page.page;
|
||
$('[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-leaderboard-value]').addEventListener('change',refresh);
|
||
$('[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-player-page]').addEventListener('change',e=>{state.page=Number(e.target.value);loadPlayers().catch(e=>error(e.message));});
|
||
$('[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)));
|
||
}));
|
||
}
|
||
const profileRecords={};
|
||
let profileRecordsReady=false;
|
||
function renderRecordPage(kind,data) {
|
||
const entry=profileRecords[kind];entry.pagination=data.pagination;
|
||
const records=data.records;
|
||
if (kind==='snapshots') table('[data-snapshot-rows]',records.map(r=>[cell(r.captured_at,'date'),`<code class="json-value">${cell(r.data,'json')}</code>`]));
|
||
if (kind==='sessions') renderSessions(records);
|
||
if (kind==='events') renderEvents(records);
|
||
if (kind==='purchases') renderSales(records);
|
||
if (kind==='activities') table('[data-activity-rows]',records.map(r=>[cell(r.name),cell(r.started_at,'date'),cell(r.duration,'duration'),cell(r.step_count),cell(r.outcome),`<code class="json-value">${cell(r.captured_values,'json')}</code>`]));
|
||
const p=data.pagination,chooser=$(`[data-record-page="${kind}"]`);
|
||
chooser.innerHTML=Array.from({length:p.pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${p.pages}</option>`).join('');
|
||
chooser.value=p.page;
|
||
$(`[data-record-summary="${kind}"]`).textContent=`${p.total.toLocaleString()} records · 20 per page`;
|
||
$(`[data-record-prev="${kind}"]`).disabled=p.page<=1;
|
||
$(`[data-record-next="${kind}"]`).disabled=p.page>=p.pages;
|
||
}
|
||
async function fetchRecordPage(kind,page) {
|
||
const entry=profileRecords[kind],ticket=++entry.request;
|
||
const query=new URLSearchParams({page});
|
||
for (const value of entry.filters) query.append(kind==='events' ? 'event' : 'product',value);
|
||
$(`[data-record-prev="${kind}"]`).disabled=true;
|
||
$(`[data-record-next="${kind}"]`).disabled=true;
|
||
const data=await fetchJSON(`/api/games/${state.game.id}/players/${profileId}/${kind}?${query}`);
|
||
if (ticket===entry.request) renderRecordPage(kind,data);
|
||
}
|
||
function initProfileRecords(data) {
|
||
for (const [kind,page] of Object.entries(data.recordPages)) {
|
||
profileRecords[kind]={filters:new Set(),request:0,pagination:page.pagination};
|
||
renderRecordPage(kind,page);
|
||
const refresh=page=>fetchRecordPage(kind,page).catch(e=>error(e.message));
|
||
$(`[data-record-prev="${kind}"]`).addEventListener('click',()=>refresh(profileRecords[kind].pagination.page-1));
|
||
$(`[data-record-next="${kind}"]`).addEventListener('click',()=>refresh(profileRecords[kind].pagination.page+1));
|
||
$(`[data-record-page="${kind}"]`).addEventListener('change',e=>refresh(e.target.value));
|
||
if (!['events','purchases'].includes(kind)) continue;
|
||
const options=data.recordFilters[kind],node=$(`[data-record-filter="${kind}"]`);
|
||
node.innerHTML='<p class="card-note">No selection shows all records.</p>'+options.map(option=>{
|
||
const name=kind==='events' ? option : option.name,value=kind==='events' ? option : option.id;
|
||
return `<label class="event-option"><input type="checkbox" value="${cell(value,'text')}"><span>${cell(name,'text')}</span></label>`;
|
||
}).join('');
|
||
node.addEventListener('change',e=>{
|
||
if (!e.target.matches('input')) return;
|
||
if (e.target.checked) profileRecords[kind].filters.add(e.target.value);
|
||
else profileRecords[kind].filters.delete(e.target.value);
|
||
refresh(1);
|
||
});
|
||
$(`[data-record-filter-all="${kind}"]`).addEventListener('click',()=>{
|
||
profileRecords[kind].filters.clear();node.querySelectorAll('input').forEach(input=>input.checked=false);refresh(1);
|
||
});
|
||
}
|
||
profileRecordsReady=true;
|
||
}
|
||
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)]));
|
||
lineChart('[data-chart="profile"]',data.series,data.labels,{separateAxes:true});
|
||
if (!profileRecordsReady) initProfileRecords(data);
|
||
table('[data-owned-pass-rows]',data.ownedGamepasses.map(r=>[cell(r.name),cell(r.roblox_gamepass_id,'text'),cell(r.purchased_at,'date')]));
|
||
table('[data-owned-product-rows]',data.purchasedProducts.map(r=>[cell(r.name),cell(r.quantity),cell(r.robux,'robux'),cell(r.first_purchase,'date'),cell(r.last_purchase,'date')]));
|
||
}
|
||
|
||
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)]));
|
||
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"]');
|
||
node._chartDraw=null;
|
||
if (!passes.length) node.innerHTML='<div class="empty">N/A — no gamepasses reported</div>';
|
||
else {
|
||
const {width,height}=chartLayout(node,false,passes.length*110),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="${width-15}" 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(axisLabel(pass.name,Math.floor(step/7)),'text')}</text>`;
|
||
}).join('');
|
||
node.innerHTML=`<svg class="chart" style="min-width:${width}px" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" 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>`;
|
||
watchChart(node,()=>renderGamepasses(data));
|
||
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.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;
|
||
const node=$('[data-chart="trend"]');node._chartDraw=null;node.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));
|
||
});
|
||
}
|
||
|
||
let qualityPagination={page:1,pages:1},qualityRequest=0;
|
||
async function loadQualityLog(page=1) {
|
||
const ticket=++qualityRequest,filter=$('[data-quality-filter]');
|
||
const data=await api('data-quality',{page,issue:filter.value || 'all'});
|
||
if (ticket!==qualityRequest) return;
|
||
const selected=filter.value || 'all';
|
||
filter.innerHTML='<option value="all">All issues</option>'+data.categories.map(c=>`<option value="${cell(c.issue,'text')}">${cell(c.title,'text')} (${cell(c.count)})</option>`).join('');
|
||
filter.value=selected;
|
||
table('[data-quality-rows]',data.records.map(r=>[cell(r.title),cell(r.record_type),cell(r.record_id,'text'),cell(r.details),cell(r.observed_at,'date')]));
|
||
const p=data.pagination;qualityPagination=p;
|
||
$('[data-quality-summary]').textContent=`${p.total.toLocaleString()} diagnostic entries · 20 per page`;
|
||
const chooser=$('[data-quality-page]');
|
||
chooser.innerHTML=Array.from({length:p.pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${p.pages}</option>`).join('');chooser.value=p.page;
|
||
$('[data-quality-prev]').disabled=p.page<=1;$('[data-quality-next]').disabled=p.page>=p.pages;
|
||
}
|
||
|
||
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==='prompts') {
|
||
const data=await api('prompts',{source:$('[data-prompt-source]').value || 'all'});fillMetrics(data.metrics);lineChart('[data-chart="trend"]',data.series,data.labels);
|
||
lineChart('[data-chart="prompt-sources"]',data.sourceSeries,data.labels);
|
||
table('[data-prompt-source-rows]',data.sourceComparison.map(r=>[cell(r.name),cell(r.prompts),cell(r.bought),cell(r.closed),cell(r.conversion,'percent'),cell(r.buy_time,'duration'),cell(r.close_time,'duration')]));
|
||
return table('[data-prompt-product-rows]',data.products.map(r=>[cell(r.name),cell(r.prompts),cell(r.bought),cell(r.closed),cell(r.conversion,'percent'),cell(r.buy_time,'duration'),cell(r.close_time,'duration')]));
|
||
}
|
||
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 loadQualityLog();
|
||
}
|
||
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.healthReports) {renderOverviewAnalysis(data.healthReports);renderHealthReports(data.healthReports);}
|
||
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==='settings') {
|
||
$('[data-quality-filter]').addEventListener('change',()=>loadQualityLog(1).catch(e=>error(e.message)));
|
||
$('[data-quality-prev]').addEventListener('click',()=>loadQualityLog(qualityPagination.page-1).catch(e=>error(e.message)));
|
||
$('[data-quality-next]').addEventListener('click',()=>loadQualityLog(qualityPagination.page+1).catch(e=>error(e.message)));
|
||
$('[data-quality-page]').addEventListener('change',e=>loadQualityLog(e.target.value).catch(e=>error(e.message)));
|
||
}
|
||
if (document.body.dataset.page==='events') bindEventChart();
|
||
if (document.body.dataset.page==='prompts') $('[data-prompt-source]').addEventListener('change',()=>loadCurrentPage().catch(e=>error(e.message)));
|
||
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));
|