Add PostgreSQL analytics dashboard prototype and realistic sample data

This commit is contained in:
maxwes08
2026-11-03 09:13:00 +01:00
commit 70b94f8672
38 changed files with 2873 additions and 0 deletions

507
mockups/data-ui.js Normal file
View File

@@ -0,0 +1,507 @@
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
const params = new URLSearchParams(location.search);
const state = { game: null, days: Number(params.get('days')) || 14, page: 1, sort: 'level', direction: 'desc', segment: 'all', q: '', template: 'overview' };
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const missing = value => value === null || value === undefined || value === '';
const escape = value => String(value).replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
function format(value, kind = 'number') {
if (missing(value)) return 'N/A';
if (kind === 'text') return String(value);
if (kind === 'json') return JSON.stringify(value, (key,item)=>item === null ? 'N/A' : item);
if (kind === 'date') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? 'N/A' : new Intl.DateTimeFormat('en-GB', {timeZone:'UTC',dateStyle:'medium',timeStyle:'short'}).format(date);
}
if (kind === 'duration') {
if (!Number.isFinite(Number(value))) return 'N/A';
const seconds = Math.max(0,Math.round(Number(value)));
return seconds >= 3600 ? `${Math.floor(seconds/3600).toLocaleString()}h ${String(Math.floor(seconds%3600/60)).padStart(2,'0')}m` : `${Math.floor(seconds/60)}m ${String(seconds%60).padStart(2,'0')}s`;
}
if (typeof value === 'object') return JSON.stringify(value);
if (typeof value !== 'number') return String(value);
if (!Number.isFinite(value)) return 'N/A';
const number = new Intl.NumberFormat('en-US',{maximumFractionDigits:kind === 'decimal' ? 2 : 1}).format(value);
return kind === 'percent' ? `${number}%` : kind === 'robux' ? `R$ ${number}` : kind === 'currency' ? `$${number}` : number;
}
const cell = (value, kind) => escape(format(value, kind));
const chartStyle=getComputedStyle(document.documentElement);
const seriesColors=Array.from({length:12},(_,i)=>chartStyle.getPropertyValue(`--chart-${i+1}`).trim());
const seriesColor=index=>seriesColors[index] || `hsl(${index*137.508%360} 70% 70%)`;
function reveal(node) {
if (!node || reducedMotion.matches || !node.animate) return;
node.getAnimations().forEach(animation => animation.cancel());
node.animate([{opacity:.65},{opacity:1}],{duration:180,easing:'ease-out'});
}
function error(message) {
fillMetrics({});
$$('[data-chart]').forEach(node=>node.innerHTML='<div class="empty">N/A — data unavailable</div>');
$$('tbody').forEach(node=>node.innerHTML=`<tr><td colspan="${node.closest('table').querySelectorAll('th').length || 1}" class="empty">N/A — data unavailable</td></tr>`);
const banner=$('[data-error]');
banner.textContent=message; banner.hidden=false;
}
function clearError() { $('[data-error]').hidden=true; }
async function fetchJSON(url) {
const response=await fetch(url);
const data=await response.json();
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
return data;
}
function api(dataset='', query={}) {
const search=new URLSearchParams({days:state.days,...query});
return fetchJSON(`/api/games/${state.game.id}${dataset ? '/'+dataset : ''}?${search}`);
}
function fillMetrics(metrics={}) {
$$('[data-metric]').forEach(node => node.textContent=format(metrics[node.dataset.metric],node.dataset.format));
}
function table(selector, records) {
const node=$(selector); if (!node) return;
const span=node.closest('table').querySelectorAll('th').length || 1;
node.innerHTML=records.length ? records.map(record=>`<tr>${record.map(value=>`<td>${value}</td>`).join('')}</tr>`).join('') : `<tr><td colspan="${span}" class="empty">N/A — no records for this selection</td></tr>`;
reveal(node);
}
function playerLink(playerId, name, robloxId) {
if (missing(playerId)) return 'N/A';
const query=new URLSearchParams({game_id:state.game.id,player_id:playerId});
return `<a class="player-name-link" href="player-info-mockup.html?${query}">${cell(name)}</a><div class="card-note">Roblox ID ${cell(robloxId,'text')}</div>`;
}
function renderSessions(records=[]) {
table('[data-session-rows]',records.map(r=>[playerLink(r.player_id,r.display_name,r.roblox_user_id),cell(r.started_at,'date'),cell(r.duration,'duration'),`<button class="button join-session" data-join-session="${cell(r.id)}" ${r.joinAvailable ? '' : 'disabled'} title="${r.joinAvailable ? 'Join this session (placeholder)' : 'Session has ended or is too old to join'}">Join session</button>`,`<span class="pill ${r.ended_at ? 'good' : 'warn'}">${r.ended_at ? 'Completed' : 'End unreported'}</span>`]));
}
function renderEvents(records=[]) {
table('[data-event-rows]',records.map(r=>[`<code class="event-name">${cell(r.name)}</code>`,cell(r.category),cell(r.roblox_user_id),r.player_id === null ? 'Game' : 'Player',cell(r.occurred_at,'date'),`<code class="json-value">${cell(r.properties,'json')}</code>`]));
}
function renderRoot(records=[]) {
table('[data-root-metric-rows]',records.map(r=>[cell(r.category),cell(r.name),cell(r.value),cell(r.recorded_at,'date')]));
}
function renderSales(records=[]) {
table('[data-sale-rows]',records.map(r=>[cell(r.receipt_id),cell(r.name),cell(r.roblox_product_id),cell(r.roblox_user_id),cell(r.unit_price_robux,'robux'),cell(r.quantity),cell(r.robux,'robux'),cell(r.purchased_at,'date')]));
}
let chartTooltip;
function inspectChart(node, {width, height, left, right, top=0, bottom=0, count, pointX, inspect}) {
const svg=node.querySelector('svg');
if (!svg || !count) return;
svg.classList.add('interactive-chart');
svg.setAttribute('tabindex','0');
svg.setAttribute('aria-label',`${svg.getAttribute('aria-label')}. Move the pointer or use left and right arrow keys to inspect values.`);
svg.insertAdjacentHTML('beforeend','<g class="chart-inspection" aria-hidden="true" hidden></g>');
const overlay=svg.querySelector('.chart-inspection');
let selected=0;
const hide=()=>{
overlay.setAttribute('hidden','');
if (chartTooltip?.owner===svg) chartTooltip.hidden=true;
};
const show=(index, clientX, clientY)=>{
selected=Math.max(0,Math.min(count-1,index));
const detail=inspect(selected);
overlay.innerHTML=detail.overlay || '';
overlay.removeAttribute('hidden');
if (!chartTooltip) {
chartTooltip=document.createElement('div');
chartTooltip.className='chart-tooltip';
chartTooltip.setAttribute('role','tooltip');
document.body.append(chartTooltip);
const dismiss=()=>{
chartTooltip.hidden=true;
$$('.chart-inspection').forEach(item=>item.setAttribute('hidden',''));
};
document.addEventListener('scroll',dismiss,true);
window.addEventListener('resize',dismiss);
}
chartTooltip.owner=svg;
chartTooltip.innerHTML=`<div class="chart-tooltip-title">${cell(detail.label,'text')}</div>${detail.rows.map(row=>`<div class="chart-tooltip-row"><span><i class="dot" style="background:${row.color}"></i>${cell(row.name,'text')}</span><strong>${cell(row.value,row.format || 'decimal')}</strong></div>`).join('')}`;
chartTooltip.hidden=false;
const bounds=chartTooltip.getBoundingClientRect();
const x=clientX+16+bounds.width>innerWidth-8 ? clientX-bounds.width-16 : clientX+16;
chartTooltip.style.left=`${Math.max(8,Math.min(x,innerWidth-bounds.width-8))}px`;
chartTooltip.style.top=`${Math.max(8,Math.min(clientY+16,innerHeight-bounds.height-8))}px`;
const description=`${detail.label}: ${detail.rows.map(row=>`${row.name}: ${format(row.value,row.format || 'decimal')}`).join('; ')}`;
svg.setAttribute('aria-label',description);
};
svg.addEventListener('pointermove',event=>{
const rect=svg.getBoundingClientRect();
const x=(event.clientX-rect.left)*width/rect.width;
const y=(event.clientY-rect.top)*height/rect.height;
if (x<left || x>width-right || y<top || y>height-bottom) return hide();
let nearest=0;
for (let i=1;i<count;i++) if (Math.abs(pointX(i)-x)<Math.abs(pointX(nearest)-x)) nearest=i;
show(nearest,event.clientX,event.clientY);
});
const showSelected=()=>{
const rect=svg.getBoundingClientRect();
show(selected,rect.left+pointX(selected)/width*rect.width,rect.top+rect.height*.25);
};
svg.addEventListener('focus',showSelected);
svg.addEventListener('keydown',event=>{
if (event.key==='Escape') return hide();
if (!['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return;
event.preventDefault();
selected=event.key==='Home' ? 0 : event.key==='End' ? count-1 : selected+(event.key==='ArrowRight' ? 1 : -1);
selected=Math.max(0,Math.min(count-1,selected));
showSelected();
});
['pointerleave','pointercancel','blur'].forEach(name=>svg.addEventListener(name,hide));
// Replacing a chart or changing the page must not leave an old tooltip visible.
if (chartTooltip) chartTooltip.hidden=true;
}
function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
if (chartTooltip) chartTooltip.hidden=true;
const node=$(selector); if (!node) return;
const values=series.flatMap(s=>s.values).filter(v=>typeof v === 'number' && Number.isFinite(v));
if (!labels.length || !values.length) { node.innerHTML='<div class="empty">N/A — no reported values</div>'; return; }
const dual=separateAxes && series.length===2;
const colorFor=index=>series[index].color || seriesColor(index);
const width=760,height=230,left=48,right=dual ? 58 : 15,top=14,bottom=32;
const low=Math.min(0,...values),high=Math.max(...values)*1.1 || 1;
const scales=series.map(s=>{
const valid=s.values.filter(Number.isFinite);
return {low:Math.min(0,...valid),high:Math.max(0,...valid)*1.1 || 1};
});
const x=i=>left+i*(width-left-right)/Math.max(1,labels.length-1);
const y=(value,index=0)=>{
const range=dual ? scales[index] : {low,high};
return top+(range.high-value)*(height-top-bottom)/(range.high-range.low);
};
const grid=Array.from({length:4},(_,i)=>{
const yy=top+i*(height-top-bottom)/3;
const range=dual ? scales[0] : {low,high};
const tick=value=>new Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(value);
const rightTick=dual ? `<text style="fill:${seriesColor(1)}" x="${width-right+6}" y="${yy+3}">${escape(tick(scales[1].high-i*(scales[1].high-scales[1].low)/3))}</text>` : '';
return `<line class="gridline" x1="${left}" y1="${yy}" x2="${width-right}" y2="${yy}"/><text ${dual ? `style="fill:${seriesColor(0)}"` : ''} x="${left-6}" y="${yy+3}" text-anchor="end">${escape(tick(range.high-i*(range.high-range.low)/3))}</text>${rightTick}`;
}).join('');
const paths=series.map((s,index)=>{
const color=colorFor(index);
let connected=false;
const path=s.values.map((value,i)=>{
if (typeof value !== 'number' || !Number.isFinite(value)) { connected=false; return ''; }
const point=`${connected ? 'L' : 'M'}${x(i)},${y(value,index)}`; connected=true; return point;
}).join(' ');
const points=s.values.map((value,i)=>typeof value === 'number' && Number.isFinite(value) ? `<circle cx="${x(i)}" cy="${y(value,index)}" r="2" fill="${color}"><title>${escape(s.name)} · ${escape(labels[i])}: ${cell(value,s.format)}</title></circle>` : '').join('');
return `<path class="line ${index ? 'secondary' : ''}" style="stroke:${color}" pathLength="100" d="${path}"/>${points}`;
}).join('');
const ticks=labels.map((v,i)=>i % Math.max(1,Math.ceil(labels.length/6)) === 0 || i===labels.length-1 ? `<text x="${x(i)}" y="${height-8}" text-anchor="middle">${escape(v)}</text>` : '').join('');
node.innerHTML=`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escape(series.map(s=>s.name).join(', '))}">${grid}${paths}${ticks}</svg><div class="legend">${series.map((s,i)=>`<span><i class="dot" style="background:${colorFor(i)}"></i>${escape(s.name)}${dual ? ` (${i ? 'right' : 'left'} axis)` : ''}</span>`).join('')}</div>`;
inspectChart(node,{width,height,left,right,top,bottom,count:labels.length,pointX:x,inspect:i=>({
label:labels[i],rows:series.map((s,j)=>({name:s.name,value:s.values[i],format:s.format,color:colorFor(j)})),
overlay:`<line class="chart-crosshair" x1="${x(i)}" x2="${x(i)}" y1="${top}" y2="${height-bottom}"/>`+series.map((s,j)=>Number.isFinite(s.values[i]) ? `<circle class="chart-hover-point" cx="${x(i)}" cy="${y(s.values[i],j)}" r="4" style="stroke:${colorFor(j)}"/>` : '').join('')
})});
}
function barChart(selector, values=[], labels=[]) {
if (chartTooltip) chartTooltip.hidden=true;
const node=$(selector); if (!node) return;
if (!values.some(value=>typeof value === 'number')) { node.innerHTML='<div class="empty">N/A</div>'; return; }
const max=Math.max(...values.filter(v=>typeof v === 'number'),1),width=760,height=220,left=16,bottom=45;
const step=(width-left*2)/Math.max(1,values.length),barWidth=step*.7;
node.innerHTML=`<svg class="chart short" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escape(labels.join(', '))}">${values.map((v,i)=>{
if (typeof v !== 'number') return '';
const h=v/max*(height-bottom-15),x=left+i*step;
return `<rect class="bar" style="animation-delay:${Math.min(i,12)*22}ms" x="${x}" y="${height-bottom-h}" width="${barWidth}" height="${h}" rx="3"><title>${escape(labels[i])}: ${cell(v)}</title></rect><text x="${x+barWidth/2}" y="${height-20}" text-anchor="middle">${escape(labels[i].slice(0,15))}</text>`;
}).join('')}</svg>`;
inspectChart(node,{width,height,left,right:left,top:0,bottom,count:values.length,pointX:i=>left+step*(i+.5),inspect:i=>({
label:labels[i],rows:[{name:'Value',value:values[i],color:seriesColor(0)}],
overlay:`<rect class="chart-hover-band" x="${left+i*step}" y="0" width="${step}" height="${height-bottom}"/>`
})});
}
function seriesFromDaily(selector, daily, mapping, dateKey='day') {
const labels=daily.map(r=>String(r[dateKey]).slice(5,16).replace('T',' '));
lineChart(`[data-chart="${selector}"]`,mapping.map(([key,name])=>({name,format:name.includes('(%)') ? 'percent' : name.includes('(s)') ? 'duration' : 'decimal',values:daily.map(r=>r[key])})),labels);
}
function renderEngagement(data) {
if (!data) return;
const daily=data.daily;
seriesFromDaily('active-users',daily,[['dau','DAU'],['mau','Rolling MAU']]);
seriesFromDaily('new-returning',daily,[['new_users','New users'],['returning_users','Returning users']]);
seriesFromDaily('session-groups',daily,[['new_session_time','New-user average session (s)'],['returning_session_time','Returning-user average session (s)']]);
seriesFromDaily('first-five',daily,[['first_session_5min','First-session 5m retention (%)']]);
seriesFromDaily('playtime-total',daily,[['total_playtime','Total completed playtime (s)']]);
seriesFromDaily('playtime-average',daily,[['average_playtime','Playtime per DAU (s)']]);
}
function renderRetention(data) {
const node=$('[data-retention]'); if (!node) return;
const grid=data.retentionGrid;
node.innerHTML=['Cohort',...grid.columns].map(v=>`<div class="heat-label">${escape(v)}</div>`).join('');
grid.rows.forEach(row=>{
node.insertAdjacentHTML('beforeend',`<div class="heat-label">${escape(row.name)} · ${row.players}</div>${row.values.map(value=>`<div style="background:rgba(124,140,255,${missing(value) ? .03 : .08+value/100*.7})">${cell(value,'percent')}</div>`).join('')}`);
});
}
let playerData, playerRequest=0;
async function loadPlayers() {
const ticket=++playerRequest;
const data=await api('players',{page:state.page,sort:state.sort,direction:state.direction,segment:state.segment,q:state.q});
if (ticket!==playerRequest) return;
playerData=data; fillMetrics(data.metrics);
const template=$('[data-player-template]'),sort=$('[data-player-sort]');
if (!template.options.length) {
Object.entries(data.templates).forEach(([key,value])=>template.add(new Option(value.label,key)));
data.fields.forEach(field=>sort.add(new Option(field.label,field.key)));
template.value=state.template; sort.value=state.sort;
}
const selected=data.templates[state.template] || data.templates.all;
const keys=[state.sort,...selected.columns.filter(key=>key!==state.sort)];
const fields=keys.map(key=>data.fields.find(field=>field.key===key)).filter(Boolean);
$('[data-player-head]').innerHTML='<th>Player</th>'+fields.map(f=>`<th>${escape(f.label)}</th>`).join('');
table('[data-player-rows]',data.playerRows.map(p=>[playerLink(p.playerId,p.name,p.robloxUserId),...fields.map(f=>cell(p[f.key],f.format))]));
const page=data.pagination; state.page=page.page;
$('[data-page-summary]').textContent=`${page.total.toLocaleString()} matching players · ${page.pageSize} per page`;
$('[data-page-number]').textContent=`${page.page} / ${page.pages}`;
$('[data-prev]').disabled=page.page<=1; $('[data-next]').disabled=page.page>=page.pages;
$('[data-group-definitions]').textContent=Object.entries(data.groupDefinitions).map(([key,value])=>`${key}: ${value}`).join(' · ');
}
function bindPlayers() {
const refresh=()=>{state.page=1;loadPlayers().catch(e=>error(e.message));};
$('[data-player-template]').addEventListener('change',event=>{
state.template=event.target.value; state.sort=playerData.templates[state.template].columns[0];
$('[data-player-sort]').value=state.sort; refresh();
});
$('[data-player-sort]').addEventListener('change',e=>{state.sort=e.target.value;refresh();});
$('[data-player-direction]').addEventListener('change',e=>{state.direction=e.target.value;refresh();});
$$('[data-player-segment]').forEach(button=>button.addEventListener('click',()=>{
state.segment=button.dataset.playerSegment;
$$('[data-player-segment]').forEach(other=>other.classList.toggle('active',other===button)); refresh();
}));
let searchTimer;
$('[data-player-search]').addEventListener('input',event=>{clearTimeout(searchTimer);state.q=event.target.value;searchTimer=setTimeout(refresh,220);});
$('[data-prev]').addEventListener('click',()=>{state.page--;loadPlayers().catch(e=>error(e.message));});
$('[data-next]').addEventListener('click',()=>{state.page++;loadPlayers().catch(e=>error(e.message));});
}
function bindTabs(tabAttribute,panelAttribute) {
$$(`[${tabAttribute}]`).forEach(tab=>tab.addEventListener('click',event=>{
event.preventDefault();
$$(`[${tabAttribute}]`).forEach(other=>other.classList.toggle('active',other===tab));
$$(`[${panelAttribute}]`).forEach(panel=>panel.classList.toggle('active',panel.getAttribute(panelAttribute)===tab.getAttribute(tabAttribute)));
}));
}
let profileId=params.get('player_id'),profileRequest=0;
async function loadProfile() {
const ticket=++profileRequest;
if (!profileId) {
const list=await api('players',{page_size:1}); profileId=list.playerRows[0]?.playerId;
if (!profileId) throw new Error('N/A — this game has no players.');
}
const selected=$('[data-profile-value]').value || 'cash';
const second=$('[data-profile-value-second]');
const secondary=second.options.length ? second.value : 'robux';
const data=await api(`players/${profileId}`,{value:selected,value2:secondary});
if (ticket!==profileRequest) return;
const p=data.player;
$('[data-profile-name]').textContent=format(p.name);
$('[data-profile-id]').textContent=`Internal player ${p.playerId} · Roblox user ${p.robloxUserId} · First seen ${format(p.firstSeen,'date')} · Snapshot ${format(p.snapshotAt,'date')}`;
$('[data-profile-tags]').innerHTML=(data.groups || []).map(v=>`<span class="pill">${escape(v)}</span>`).join('');
fillMetrics(p);
const chooser=$('[data-profile-value]');
if (!chooser.options.length) data.historyFields.forEach(f=>chooser.add(new Option(f.label,f.key)));
if (!second.options.length) {
second.add(new Option('None',''));
data.historyFields.forEach(f=>second.add(new Option(f.label,f.key)));
}
chooser.value=selected;
second.value=secondary;
table('[data-latest-value-rows]',data.fields.map(f=>[`${escape(f.label)}<div class="card-note">${escape(f.source)}</div>`,cell(p[f.key],f.format)]));
table('[data-snapshot-rows]',data.snapshots.map(r=>[cell(r.captured_at,'date'),`<code class="json-value">${cell(r.data,'json')}</code>`]));
lineChart('[data-chart="profile"]',data.series,data.labels,{separateAxes:true});
renderSessions(data.sessions); renderEvents(data.events); renderSales(data.sales || []);
table('[data-activity-rows]',data.activities.map(r=>[cell(r.name),cell(r.started_at,'date'),cell(r.duration,'duration'),cell(r.step_count),cell(r.prompt_count),cell(r.outcome),`<code class="json-value">${cell(r.captured_values,'json')}</code>`]));
}
let activityData,activityId,toastTimer;
function toast(message) {
const node=$('[data-toast]'); node.textContent=message;node.classList.add('visible');
clearTimeout(toastTimer);toastTimer=setTimeout(()=>node.classList.remove('visible'),2600);
}
function eventEditor(name='') {
return `<div class="config-event"><select class="select"><option>Step</option><option>Prompt</option><option>Outcome</option></select><input class="filter-input" value="${escape(name)}" placeholder="event_name"><button class="icon-button" data-remove-row aria-label="Remove event">×</button></div>`;
}
function fieldEditor(name='') {
return `<div class="capture-field"><input class="filter-input" value="${escape(name)}" placeholder="property_name"><select class="select"><option>Latest value</option><option>First value</option><option>Change over run</option></select><button class="icon-button" data-remove-row aria-label="Remove value">×</button></div>`;
}
async function loadActivities() {
const query={segment:$('[data-activity-group]').value}; if (activityId) query.activity_id=activityId;
const data=await api('activities',query); activityData=data; fillMetrics(data.metrics);
const chooser=$('[data-activity-select]');
if (!chooser.options.length) data.definitions.forEach(d=>chooser.add(new Option(d.name,d.id)));
if (!data.definition) return;
chooser.value=data.definition.id; activityId=data.definition.id;
$('[data-activity-journey]').innerHTML=data.journey.map((step,i)=>`${i ? '<span class="flow-connector"></span>' : ''}<div class="flow-node"><span class="flow-kind">${cell(step.label)}</span><code>${cell(step.event)}</code><strong class="stage-reach">${cell(step.reachPercent,'percent')} reach</strong><small>${cell(step.runs)} runs</small>${i ? `<small class="stage-drop">${cell(step.dropoffPercent,'percent')} drop from prior stage</small>` : '<small>All activity starts</small>'}</div>`).join('');
table('[data-stage-rows]',data.journey.map((step,i)=>[`${i+1}. ${cell(step.event)}`,cell(step.runs),cell(step.reachPercent,'percent'),cell(step.conversionPercent,'percent'),cell(step.dropoffPercent,'percent'),cell(step.droppedRuns),cell(step.unknownRuns)]));
lineChart('[data-chart="trend"]',data.series,data.labels);
table('[data-captured-rows]',data.captured.map(r=>[cell(r.key),cell(r.summary),cell(r.value)]));
table('[data-outcome-rows]',data.outcomes.map(r=>[cell(r.outcome),cell(r.runs),cell(r.share,'percent'),cell(r.average,'duration'),cell(r.median,'duration')]));
$('[data-activity-name]').value=data.definition.name;
$('[data-start-event]').value=data.definition.start_event;
$('[data-end-event]').value=data.definition.end_event || 'N/A';
$('[data-config-events]').innerHTML=(data.definition.configuration.steps || []).map(eventEditor).join('');
$('[data-capture-fields]').innerHTML=(data.definition.configuration.fields || []).map(fieldEditor).join('');
}
function bindActivities() {
bindTabs('data-activity-tab','data-activity-panel');
$('[data-activity-select]').addEventListener('change',event=>{activityId=event.target.value;loadActivities().catch(e=>error(e.message));});
$('[data-activity-group]').addEventListener('change',()=>loadActivities().catch(e=>error(e.message)));
$('[data-add-step]').addEventListener('click',()=>$('[data-config-events]').insertAdjacentHTML('beforeend',eventEditor()));
$('[data-add-field]').addEventListener('click',()=>$('[data-capture-fields]').insertAdjacentHTML('beforeend',fieldEditor()));
document.addEventListener('click',event=>{if(event.target.closest('[data-remove-row]')) event.target.closest('.config-event,.capture-field').remove();});
$('[data-save-activity]').addEventListener('click',()=>toast('UI draft only — database definition is unchanged.'));
$('[data-new-activity]').addEventListener('click',()=>{
$('[data-activity-tab="configure"]').click();
$('[data-activity-name]').value=''; $('[data-start-event]').value=''; $('[data-end-event]').value='';
$('[data-config-events]').innerHTML='';$('[data-capture-fields]').innerHTML='';
$('[data-activity-name]').focus();toast('New UI draft. Ingestion and saving are not connected.');
});
}
function renderMonetization(data) {
fillMetrics(data.metrics);
seriesFromDaily('revenue-daily',data.daily,[['robux','Daily Robux spent']]);
seriesFromDaily('revenue-hourly',data.hourly,[['robux','Hourly Robux spent']],'hour');
seriesFromDaily('payers',data.daily,[['payers','Daily paying users']]);
seriesFromDaily('payer-conversion',data.daily,[['conversion','Payer conversion (%)']]);
seriesFromDaily('arpdau',data.daily,[['arpdau','Robux per DAU']]);
barChart('[data-chart="products"]',data.products.map(p=>p.robux),data.products.map(p=>format(p.name)));
table('[data-product-rows]',data.products.map(p=>[cell(p.name),cell(p.roblox_product_id),cell(p.current_price_robux,'robux'),cell(p.robux,'robux'),cell(p.quantity),cell(p.buyers),cell(p.perPayer,'decimal')]));
}
function renderGamepasses(data) {
fillMetrics(data.metrics);
const passes=data.gamepasses, colors=[seriesColor(0),seriesColor(1)];
const node=$('[data-chart="ownership"]');
if (!passes.length) node.innerHTML='<div class="empty">N/A — no gamepasses reported</div>';
else {
const width=760,height=260,left=40,top=18,bottom=45,plotHeight=height-top-bottom;
const step=(width-left-15)/passes.length,barWidth=Math.min(42,step*.28);
const grid=[0,25,50,75,100].map(v=>{const y=top+(100-v)/100*plotHeight;return `<line class="gridline" x1="${left}" y1="${y}" x2="745" y2="${y}"/><text x="33" y="${y+3}" text-anchor="end">${v}%</text>`;}).join('');
const bars=passes.map((pass,i)=>{
const x=left+step*i+step/2;
return ['playerPercent','payerPercent'].map((key,j)=>{
const value=pass[key],xx=x+(j ? 3 : -barWidth-3);
return missing(value) ? `<text x="${xx+barWidth/2}" y="${height-bottom-7}" text-anchor="middle">N/A</text>` : `<rect class="bar" style="fill:${colors[j]};animation-delay:${i*25}ms" x="${xx}" y="${top+(100-value)/100*plotHeight}" width="${barWidth}" height="${value/100*plotHeight}" rx="3"><title>${cell(pass.name)} · ${j ? 'Paying players' : 'All players'}: ${cell(value,'percent')}</title></rect>`;
}).join('')+`<text x="${x}" y="${height-17}" text-anchor="middle">${cell(pass.name)}</text>`;
}).join('');
node.innerHTML=`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Gamepass ownership percentages">${grid}${bars}</svg><div class="legend"><span><i class="dot" style="background:${colors[0]}"></i>All players</span><span><i class="dot" style="background:${colors[1]}"></i>Paying players</span></div>`;
inspectChart(node,{width,height,left,right:15,top,bottom,count:passes.length,pointX:i=>left+step*(i+.5),inspect:i=>({
label:passes[i].name,rows:[{name:'All players',value:passes[i].playerPercent,format:'percent',color:colors[0]},{name:'Paying players',value:passes[i].payerPercent,format:'percent',color:colors[1]}],
overlay:`<rect class="chart-hover-band" x="${left+i*step}" y="${top}" width="${step}" height="${plotHeight}"/>`
})});
}
table('[data-gamepass-rows]',passes.map(p=>[cell(p.name),cell(p.roblox_gamepass_id,'text'),cell(p.owners),cell(p.playerPercent,'percent'),cell(p.paying_owners),cell(p.payerPercent,'percent'),cell(p.coverage,'percent'),cell(p.newest_check,'date')]));
}
let eventChartData,eventSelection=new Set();
function drawEventChart() {
if (!eventChartData) return;
const series=eventChartData.series.map((item,index)=>({...item,color:seriesColor(index)})).filter(item=>eventSelection.has(item.name));
$('[data-event-selection-count]').textContent=`${series.length} of ${eventChartData.series.length}`;
if (series.length) lineChart('[data-chart="trend"]',series,eventChartData.labels);
else {
if (chartTooltip) chartTooltip.hidden=true;
$('[data-chart="trend"]').innerHTML='<div class="empty">Select events to graph.</div>';
}
try { localStorage.setItem(`eventChartSelection:${state.game.id}`,JSON.stringify([...eventSelection])); } catch {}
}
function renderEventChart(data) {
eventChartData=data;
let saved;
try { saved=JSON.parse(localStorage.getItem(`eventChartSelection:${state.game.id}`)); } catch {}
eventSelection=new Set(data.series.map(s=>s.name).filter(name=>!Array.isArray(saved) || saved.includes(name)));
$('[data-event-options]').innerHTML=data.series.map((s,i)=>`<label class="event-option" data-event-option><input type="checkbox" value="${cell(s.name,'text')}" ${eventSelection.has(s.name) ? 'checked' : ''}><i class="dot" style="background:${seriesColor(i)}"></i><span>${cell(s.name,'text')}</span></label>`).join('');
$('[data-event-search]').value='';
drawEventChart();
}
function bindEventChart() {
$('[data-event-options]').addEventListener('change',event=>{
if (!event.target.matches('input[type="checkbox"]')) return;
if (event.target.checked) eventSelection.add(event.target.value);
else eventSelection.delete(event.target.value);
drawEventChart();
});
for (const [selector,checked] of [['[data-events-all]',true],['[data-events-none]',false]]) {
$(selector).addEventListener('click',()=>{
if (!eventChartData) return;
eventSelection=new Set(checked ? eventChartData.series.map(s=>s.name) : []);
$$('[data-event-options] input').forEach(input=>input.checked=checked);
drawEventChart();
});
}
$('[data-event-search]').addEventListener('input',event=>{
const query=event.target.value.trim().toLowerCase();
$$('[data-event-option]').forEach(label=>label.hidden=!label.textContent.toLowerCase().includes(query));
});
}
async function loadCurrentPage() {
if (chartTooltip) chartTooltip.hidden=true;
clearError();
const page=document.body.dataset.page;
if (page==='players') return loadPlayers();
if (page==='player-info') return loadProfile();
if (page==='activities') return loadActivities();
if (page==='monetization') return renderMonetization(await api('monetization'));
if (page==='gamepasses') return renderGamepasses(await api('gamepasses'));
if (page==='settings') {
$$('[data-game-field]').forEach(input=>input.value=format(state.game[input.dataset.gameField],'text'));
return;
}
const data=await api(page,page==='sessions' ? {segment:$('[data-session-group]').value} : {});
fillMetrics(data.metrics);
if (page==='events') renderEventChart(data);
else lineChart('[data-chart="trend"]',data.series,data.labels);
barChart('[data-chart="events"]',data.eventValues,data.eventLabels);
if (data.sessions) renderSessions(data.sessions);
if (data.events) renderEvents(data.events);
if (data.eventSummary) table('[data-event-summary]',data.eventSummary.map(r=>[cell(r.name),cell(r.categories.join(', ')),cell(r.total),cell(r.average,'decimal'),cell(r.players),cell(r.latest,'date')]));
if (data.retentionGrid) renderRetention(data);
if (data.engagement) renderEngagement(data.engagement);
if (data.rootMetrics) {renderRoot(data.rootMetrics.rows);lineChart('[data-chart="ccu"]',data.rootMetrics.series,data.rootMetrics.labels);}
}
async function boot() {
const result=await fetchJSON('/api/games');
const requested=params.get('game_id') || localStorage.getItem('sampleGameId');
const chosen=result.games.find(g=>String(g.id)===requested);
if (params.has('game_id') && !chosen) throw new Error('Game not found. Open the dashboard without game_id to choose a game.');
state.game=chosen || result.games[0];
if (!state.game) throw new Error('No games available. Run the database seed.');
localStorage.setItem('sampleGameId',state.game.id);
$$('[data-game-select]').forEach(select=>{
select.replaceChildren(...result.games.map(game=>new Option(`${game.name} · #${game.id}`,game.id)));
select.value=state.game.id;
select.addEventListener('change',()=>{
localStorage.setItem('sampleGameId',select.value);
const url=new URL(location.href);url.searchParams.set('game_id',select.value);url.searchParams.delete('player_id');
location.href=url;
});
});
$('.breadcrumbs').textContent=`${state.game.name} / ${document.title.split(' — ')[0]}`;
$$('a[href*="-mockup.html"]').forEach(link=>{
const url=new URL(link.getAttribute('href'),location.href);url.searchParams.set('game_id',state.game.id);url.searchParams.set('days',state.days);link.href=url;
});
const date=$('[data-days]');
if (date) {
date.value=state.days;
if (['players','settings','gamepasses'].includes(document.body.dataset.page)) date.hidden=true;
date.addEventListener('change',()=>{
state.days=Number(date.value);
const url=new URL(location.href);url.searchParams.set('days',state.days);url.searchParams.set('game_id',state.game.id);history.replaceState(null,'',url);
$$('a[href*="-mockup.html"]').forEach(link=>{const target=new URL(link.href);target.searchParams.set('days',state.days);link.href=target;});
loadCurrentPage().catch(e=>error(e.message));
});
}
if (document.body.dataset.page==='players') bindPlayers();
if (document.body.dataset.page==='events') bindEventChart();
if (document.body.dataset.page==='player-info') {
bindTabs('data-profile-tab','data-profile-panel');
$('[data-profile-value]').addEventListener('change',()=>loadProfile().catch(e=>error(e.message)));
$('[data-profile-value-second]').addEventListener('change',()=>loadProfile().catch(e=>error(e.message)));
$('[data-session-rows]').addEventListener('click',event=>{
const button=event.target.closest('[data-join-session]');
if (button && !button.disabled) toast('Join session is a placeholder — Roblox joining is not connected yet.');
});
}
if (document.body.dataset.page==='activities') bindActivities();
if (document.body.dataset.page==='sessions') $('[data-session-group]').addEventListener('change',()=>loadCurrentPage().catch(e=>error(e.message)));
document.addEventListener('change',event=>{
if (event.target.matches('select.select') && !reducedMotion.matches && event.target.animate) event.target.animate([{boxShadow:'0 0 0 3px rgb(124 140 255 / 16%)'},{boxShadow:'0 0 0 3px transparent'}],{duration:280,easing:'ease-out'});
});
await loadCurrentPage();
}
boot().catch(e=>error(e.message));