Expand dashboard analytics and document production implementation

This commit is contained in:
maxwes08
2026-11-03 14:49:01 +01:00
parent 70b94f8672
commit aa0028e7e0
36 changed files with 2212 additions and 76 deletions

View File

@@ -36,7 +36,12 @@ function reveal(node) {
}
function error(message) {
fillMetrics({});
$$('[data-chart]').forEach(node=>node.innerHTML='<div class="empty">N/A — data unavailable</div>');
$$('[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;
@@ -58,8 +63,30 @@ function fillMetrics(metrics={}) {
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);
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';
@@ -67,7 +94,7 @@ function playerLink(playerId, name, robloxId) {
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>`]));
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>`]));
@@ -147,14 +174,68 @@ function inspectChart(node, {width, height, left, right, top=0, bottom=0, count,
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='<div class="empty">N/A — no reported values</div>'; return; }
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=760,height=230,left=48,right=dual ? 58 : 15,top=14,bottom=32;
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);
@@ -182,8 +263,12 @@ function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
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>`;
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('')
@@ -192,14 +277,16 @@ function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
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=760,height=220,left=16,bottom=45;
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" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escape(labels.join(', '))}">${values.map((v,i)=>{
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-20}" text-anchor="middle">${escape(labels[i].slice(0,15))}</text>`;
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}"/>`
@@ -219,9 +306,29 @@ function renderEngagement(data) {
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('')}`);
@@ -231,9 +338,15 @@ function renderRetention(data) {
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});
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)));
@@ -247,12 +360,14 @@ async function loadPlayers() {
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}`;
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();
@@ -265,6 +380,7 @@ function bindPlayers() {
}));
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));});
}
@@ -275,6 +391,58 @@ function bindTabs(tabAttribute,panelAttribute) {
$$(`[${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;
@@ -301,10 +469,10 @@ async function loadProfile() {
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>`]));
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;
@@ -326,7 +494,7 @@ async function loadActivities() {
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)]));
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')]));
@@ -366,25 +534,27 @@ 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=760,height=260,left=40,top=18,bottom=45,plotHeight=height-top-bottom;
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="745" y2="${y}"/><text x="33" y="${y+3}" text-anchor="end">${v}%</text>`;}).join('');
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(pass.name)}</text>`;
}).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" 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>`;
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.coverage,'percent'),cell(p.newest_check,'date')]));
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();
@@ -395,7 +565,7 @@ function drawEventChart() {
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>';
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 {}
}
@@ -429,6 +599,22 @@ function bindEventChart() {
});
}
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();
@@ -437,10 +623,16 @@ async function loadCurrentPage() {
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;
return loadQualityLog();
}
const data=await api(page,page==='sessions' ? {segment:$('[data-session-group]').value} : {});
fillMetrics(data.metrics);
@@ -452,6 +644,7 @@ async function loadCurrentPage() {
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() {
@@ -487,7 +680,14 @@ async function boot() {
});
}
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)));