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

708 lines
49 KiB
JavaScript
Raw Permalink 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-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));