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='
N/A — data unavailable
';}); $$('tbody').forEach(node=>node.innerHTML=`N/A — data unavailable`); const banner=$('[data-error]'); banner.textContent=message; banner.hidden=false; } function clearError() { $('[data-error]').hidden=true; } async function fetchJSON(url) { const response=await fetch(url); const data=await response.json(); if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); return data; } function api(dataset='', query={}) { const search=new URLSearchParams({days:state.days,...query}); return fetchJSON(`/api/games/${state.game.id}${dataset ? '/'+dataset : ''}?${search}`); } function fillMetrics(metrics={}) { $$('[data-metric]').forEach(node => node.textContent=format(metrics[node.dataset.metric],node.dataset.format)); } function table(selector, records) { const node=$(selector); if (!node) return; const span=node.closest('table').querySelectorAll('th').length || 1; 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=>`${record.map(value=>`${value}`).join('')}`).join('') : `N/A — no records for this selection`; 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=`
`; 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 `${cell(name)}
Roblox ID ${cell(robloxId,'text')}
`; } function renderSessions(records=[]) { table('[data-session-rows]',records.map(r=>[playerLink(r.player_id,r.display_name,r.roblox_user_id),cell(r.started_at,'date'),cell(r.duration,'duration'),``,`${r.ended_at ? 'Completed' : 'Open'}`])); } function renderEvents(records=[]) { table('[data-event-rows]',records.map(r=>[`${cell(r.name)}`,cell(r.category),cell(r.roblox_user_id),r.player_id === null ? 'Game' : 'Player',cell(r.occurred_at,'date'),`${cell(r.properties,'json')}`])); } function renderRoot(records=[]) { table('[data-root-metric-rows]',records.map(r=>[cell(r.category),cell(r.name),cell(r.value),cell(r.recorded_at,'date')])); } function renderSales(records=[]) { table('[data-sale-rows]',records.map(r=>[cell(r.receipt_id),cell(r.name),cell(r.roblox_product_id),cell(r.roblox_user_id),cell(r.unit_price_robux,'robux'),cell(r.quantity),cell(r.robux,'robux'),cell(r.purchased_at,'date')])); } let chartTooltip; function inspectChart(node, {width, height, left, right, top=0, bottom=0, count, pointX, inspect}) { const svg=node.querySelector('svg'); if (!svg || !count) return; svg.classList.add('interactive-chart'); svg.setAttribute('tabindex','0'); svg.setAttribute('aria-label',`${svg.getAttribute('aria-label')}. Move the pointer or use left and right arrow keys to inspect values.`); svg.insertAdjacentHTML('beforeend',''); const overlay=svg.querySelector('.chart-inspection'); let selected=0; const hide=()=>{ overlay.setAttribute('hidden',''); if (chartTooltip?.owner===svg) chartTooltip.hidden=true; }; const show=(index, clientX, clientY)=>{ selected=Math.max(0,Math.min(count-1,index)); const detail=inspect(selected); overlay.innerHTML=detail.overlay || ''; overlay.removeAttribute('hidden'); if (!chartTooltip) { chartTooltip=document.createElement('div'); chartTooltip.className='chart-tooltip'; chartTooltip.setAttribute('role','tooltip'); document.body.append(chartTooltip); const dismiss=()=>{ chartTooltip.hidden=true; $$('.chart-inspection').forEach(item=>item.setAttribute('hidden','')); }; document.addEventListener('scroll',dismiss,true); window.addEventListener('resize',dismiss); } chartTooltip.owner=svg; chartTooltip.innerHTML=`
${cell(detail.label,'text')}
${detail.rows.map(row=>`
${cell(row.name,'text')}${cell(row.value,row.format || 'decimal')}
`).join('')}`; chartTooltip.hidden=false; const bounds=chartTooltip.getBoundingClientRect(); const x=clientX+16+bounds.width>innerWidth-8 ? clientX-bounds.width-16 : clientX+16; chartTooltip.style.left=`${Math.max(8,Math.min(x,innerWidth-bounds.width-8))}px`; chartTooltip.style.top=`${Math.max(8,Math.min(clientY+16,innerHeight-bounds.height-8))}px`; const description=`${detail.label}: ${detail.rows.map(row=>`${row.name}: ${format(row.value,row.format || 'decimal')}`).join('; ')}`; svg.setAttribute('aria-label',description); }; svg.addEventListener('pointermove',event=>{ const rect=svg.getBoundingClientRect(); const x=(event.clientX-rect.left)*width/rect.width; const y=(event.clientY-rect.top)*height/rect.height; if (xwidth-right || yheight-bottom) return hide(); let nearest=0; for (let i=1;i{ const rect=svg.getBoundingClientRect(); show(selected,rect.left+pointX(selected)/width*rect.width,rect.top+rect.height*.25); }; svg.addEventListener('focus',showSelected); svg.addEventListener('keydown',event=>{ if (event.key==='Escape') return hide(); if (!['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return; event.preventDefault(); selected=event.key==='Home' ? 0 : event.key==='End' ? count-1 : selected+(event.key==='ArrowRight' ? 1 : -1); selected=Math.max(0,Math.min(count-1,selected)); showSelected(); }); ['pointerleave','pointercancel','blur'].forEach(name=>svg.addEventListener(name,hide)); // Replacing a chart or changing the page must not leave an old tooltip visible. if (chartTooltip) chartTooltip.hidden=true; } 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=`
Choose lines ${series.length} of ${allSeries.length}
${allSeries.map((item,i)=>``).join('')}
`; } const values=series.flatMap(s=>s.values).filter(v=>typeof v === 'number' && Number.isFinite(v)); if (!labels.length || !values.length) { node.innerHTML=pickerMarkup+`
${needsPicker && !series.length ? 'Select lines to graph.' : 'N/A — no reported values'}
`; 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 ? `${escape(tick(scales[1].high-i*(scales[1].high-scales[1].low)/3))}` : ''; return `${escape(tick(range.high-i*(range.high-range.low)/3))}${rightTick}`; }).join(''); const paths=series.map((s,index)=>{ const color=colorFor(index); let connected=false; const path=s.values.map((value,i)=>{ if (typeof value !== 'number' || !Number.isFinite(value)) { connected=false; return ''; } const point=`${connected ? 'L' : 'M'}${x(i)},${y(value,index)}`; connected=true; return point; }).join(' '); const points=s.values.map((value,i)=>typeof value === 'number' && Number.isFinite(value) ? `${escape(s.name)} · ${escape(labels[i])}: ${cell(value,s.format)}` : '').join(''); return `${points}`; }).join(''); const 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) ? `${escape(axisLabel(v,14))}` : '').join(''); node.innerHTML=pickerMarkup+`${grid}${paths}${ticks}
${series.map((s,i)=>`${escape(s.name)}${dual ? ` (${i ? 'right' : 'left'} axis)` : ''}`).join('')}
`; 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:``+series.map((s,j)=>Number.isFinite(s.values[i]) ? `` : '').join('') })}); } function barChart(selector, values=[], labels=[]) { if (chartTooltip) chartTooltip.hidden=true; const node=$(selector); if (!node) return; node._chartDraw=null; if (!values.some(value=>typeof value === 'number')) { node.innerHTML='
N/A
'; 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=`${values.map((v,i)=>{ if (typeof v !== 'number') return ''; const h=v/max*(height-bottom-15),x=left+i*step; return `${escape(labels[i])}: ${cell(v)}${escape(axisLabel(labels[i],Math.floor(step/7)))}`; }).join('')}`; 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:`` })}); } 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=>`

${cell(p.title,'text')} ${cell(p.text,'text')}

`).join('')+(report.weekdayComparison ? `

${cell(report.weekdayComparison,'text')}

` : ''); 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=>`

${cell(p.title,'text')} ${cell(p.text,'text')}

`).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=>`
${escape(v)}
`).join(''); grid.rows.forEach(row=>{ node.insertAdjacentHTML('beforeend',`
${escape(row.name)} · ${row.players}
${row.values.map(value=>`
${cell(value,'percent')}
`).join('')}`); }); } let playerData, playerRequest=0; async function loadPlayers() { const ticket=++playerRequest; const data=await api('players',{page:state.page,sort:state.sort,direction:state.direction,segment:state.segment,q:state.q,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=>`
${cell(board.label,'text')}
    ${board.players.map((p,i)=>`
  1. ${i+1}
    ${playerLink(p.playerId,p.name,p.robloxUserId)}
    ${cell(p.value,board.format)}
  2. `).join('') || '
  3. N/A
  4. '}
`).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='Player'+fields.map(f=>`${escape(f.label)}`).join(''); table('[data-player-rows]',data.playerRows.map(p=>[playerLink(p.playerId,p.name,p.robloxUserId),...fields.map(f=>cell(p[f.key],f.format))])); const page=data.pagination; state.page=page.page; $('[data-page-summary]').textContent=`${page.total.toLocaleString()} matching players · ${page.pageSize} per page`; const pageChooser=$('[data-player-page]'); pageChooser.innerHTML=Array.from({length:page.pages},(_,i)=>``).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'),`${cell(r.data,'json')}`])); 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),`${cell(r.captured_values,'json')}`])); const p=data.pagination,chooser=$(`[data-record-page="${kind}"]`); chooser.innerHTML=Array.from({length:p.pages},(_,i)=>``).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='

No selection shows all records.

'+options.map(option=>{ const name=kind==='events' ? option : option.name,value=kind==='events' ? option : option.id; return ``; }).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=>`${escape(v)}`).join(''); fillMetrics(p); const chooser=$('[data-profile-value]'); if (!chooser.options.length) data.historyFields.forEach(f=>chooser.add(new Option(f.label,f.key))); if (!second.options.length) { second.add(new Option('None','')); data.historyFields.forEach(f=>second.add(new Option(f.label,f.key))); } chooser.value=selected; second.value=secondary; table('[data-latest-value-rows]',data.fields.map(f=>[`${escape(f.label)}
${escape(f.source)}
`,cell(p[f.key],f.format)])); 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 `
`; } function fieldEditor(name='') { return `
`; } async function loadActivities() { const query={segment:$('[data-activity-group]').value}; if (activityId) query.activity_id=activityId; const data=await api('activities',query); activityData=data; fillMetrics(data.metrics); const chooser=$('[data-activity-select]'); if (!chooser.options.length) data.definitions.forEach(d=>chooser.add(new Option(d.name,d.id))); if (!data.definition) return; chooser.value=data.definition.id; activityId=data.definition.id; $('[data-activity-journey]').innerHTML=data.journey.map((step,i)=>`${i ? '' : ''}
${cell(step.label)}${cell(step.event)}${cell(step.reachPercent,'percent')} reach${cell(step.runs)} runs${i ? `${cell(step.dropoffPercent,'percent')} drop from prior stage` : 'All activity starts'}
`).join(''); table('[data-stage-rows]',data.journey.map((step,i)=>[`${i+1}. ${cell(step.event)}`,cell(step.runs),cell(step.reachPercent,'percent'),cell(step.conversionPercent,'percent'),cell(step.dropoffPercent,'percent'),cell(step.droppedRuns)])); 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='
N/A — no gamepasses reported
'; 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 `${v}%`;}).join(''); const bars=passes.map((pass,i)=>{ const x=left+step*i+step/2; return ['playerPercent','payerPercent'].map((key,j)=>{ const value=pass[key],xx=x+(j ? 3 : -barWidth-3); return missing(value) ? `N/A` : `${cell(pass.name)} · ${j ? 'Paying players' : 'All players'}: ${cell(value,'percent')}`; }).join('')+`${cell(axisLabel(pass.name,Math.floor(step/7)),'text')}`; }).join(''); node.innerHTML=`${grid}${bars}
All playersPaying players
`; 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:`` })}); } 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='
Select events to graph.
'; } try { localStorage.setItem(`eventChartSelection:${state.game.id}`,JSON.stringify([...eventSelection])); } catch {} } function renderEventChart(data) { eventChartData=data; let saved; try { saved=JSON.parse(localStorage.getItem(`eventChartSelection:${state.game.id}`)); } catch {} eventSelection=new Set(data.series.map(s=>s.name).filter(name=>!Array.isArray(saved) || saved.includes(name))); $('[data-event-options]').innerHTML=data.series.map((s,i)=>``).join(''); $('[data-event-search]').value=''; drawEventChart(); } function bindEventChart() { $('[data-event-options]').addEventListener('change',event=>{ if (!event.target.matches('input[type="checkbox"]')) return; if (event.target.checked) eventSelection.add(event.target.value); else eventSelection.delete(event.target.value); drawEventChart(); }); for (const [selector,checked] of [['[data-events-all]',true],['[data-events-none]',false]]) { $(selector).addEventListener('click',()=>{ if (!eventChartData) return; eventSelection=new Set(checked ? eventChartData.series.map(s=>s.name) : []); $$('[data-event-options] input').forEach(input=>input.checked=checked); drawEventChart(); }); } $('[data-event-search]').addEventListener('input',event=>{ const query=event.target.value.trim().toLowerCase(); $$('[data-event-option]').forEach(label=>label.hidden=!label.textContent.toLowerCase().includes(query)); }); } 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=''+data.categories.map(c=>``).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)=>``).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));