Files
Stat-Dashboard/mockups/build_pages.py

84 lines
25 KiB
Python
Raw 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.
from pathlib import Path
ROOT=Path(__file__).resolve().parent.parent/'mockups'
def metric(label,key,fmt='number',note=''):
return f'<article class="card card-pad metric-card"><div class="metric-label">{label}</div><div class="metric-value" data-metric="{key}" data-format="{fmt}">N/A</div><div class="metric-foot">{note}</div></article>'
def metrics(items): return '<div class="grid metrics">'+''.join(metric(*item) for item in items)+'</div>'
def chart(title,note='',key='trend'):
return f'<article class="card"><div class="card-head"><div><div class="card-title">{title}</div><div class="card-note">{note}</div></div></div><div class="chart-wrap" data-chart="{key}"><div class="empty">N/A</div></div></article>'
def table(title,heads,target,note=''):
return f'<article class="card"><div class="card-head"><div><div class="card-title">{title}</div><div class="card-note">{note}</div></div></div><div class="table-wrap"><table><thead><tr>'+''.join('<th>'+h+'</th>' for h in heads)+f'</tr></thead><tbody {target}><tr><td colspan="{len(heads)}" class="empty">N/A</td></tr></tbody></table></div></article>'
def heading(title,note): return f'<div class="page-heading"><div><h1>{title}</h1><div class="subtitle">{note}</div></div><div class="page-controls"><select class="select" data-days aria-label="Date range"><option value="7">Last 7 days</option><option value="14" selected>Last 14 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></div></div>'
def segments():
return '<div class="player-segments" role="group" aria-label="Player groups">'+''.join(f'<button class="segment-button {"active" if k=="all" else ""}" data-player-segment="{k}">{v}</button>' for k,v in [('all','All players'),('whales','Whales · top spend'),('playtime','Top playtime'),('first','First-time')])+'</div>'
SESSION_HEAD=['Player','Started (UTC)','Duration','Join session','Status']
EVENT_HEAD=['Event','Category','Player ID','Scope','Time (UTC)','Properties']
METRIC_HEAD=['Category','Metric','Latest value','Reported (UTC)']
pages={}
pages['overview']=heading('Overview','Game-wide analytics from PostgreSQL. Missing or unreported values display N/A.')+metrics([
('Unique players','players','number','Players with a session in the selected period'),('Sessions','sessions','number','Session starts in the selected period'),
('Average session','avgSession','duration','Completed sessions only'),('Day 1 retention','retention','percent','Exact next-day return; mature cohorts'),
('Visits','visits','number','Game-level daily visit samples in the period'),('Latest CCU','ccu','number','Latest game-level report; unknown reports stay N/A'),
('Events','events','number','Player and game-level events'),('Latest event','latestEvent','date','Most recent event in selected period')])+ '<div class="grid two-col">'+chart('Players and sessions','Grouped by UTC date')+chart('Top events','Recorded event counts','events')+'</div><div class="section-gap">'+table('Recent sessions',SESSION_HEAD,'data-session-rows','Latest 8 sessions across this game')+'</div><div class="grid two-col section-gap">'+chart('Concurrent players','Daily average of reported CCU samples','ccu')+table('Game root metrics',METRIC_HEAD,'data-root-metric-rows','Game-level categories with no player binding')+'</div>'
pages['sessions']=heading('Sessions','Stored sessions linked to players within the selected game.')+metrics([
('Sessions','sessions','number','Starts in selected period'),('Average duration','avgSession','duration','Completed sessions only'),('Median duration','medianSession','duration','Completed sessions only'),('Open sessions (recent)','active','number','No end report and started within six hours')])+chart('Sessions and players','UTC daily totals')+'<div class="section-gap">'+table('Recent sessions',SESSION_HEAD,'data-session-rows','Latest 50 game sessions; an unreported end time gives N/A duration')+'</div>'
pages['retention']=heading('Retention','Exact-day return rates from stored player sessions; incomplete cohorts display N/A.')+metrics([
('Day 1 return rate','retention','percent','Weighted across mature cohorts'),('Day 7 return rate','day7','percent','Exact day 7, with a complete observation day'),('New players','newPlayers','number','First seen in selected period'),('Returning players','returningPlayers','number','First seen before selected period')])+'<div class="grid two-col"><article class="card"><div class="card-head"><div><div class="card-title">Retention by cohort</div><div class="card-note">First-session date and percentage returning on the exact day · UTC</div></div></div><div class="table-wrap"><div class="retention-grid" data-retention></div></div></article>'+chart('Return rate trend','N/A observations are gaps in the chart')+'</div>'
pages['events']=heading('Events','Events belong to the game root and can optionally reference a player and session.')+'<nav class="tabs"><a class="active" href="events-mockup.html">Explore & records</a><a href="activities-mockup.html">Activity sessions</a></nav>'+metrics([
('Events','events','number','Selected period'),('Event types','eventTypes','number','Distinct event names'),('Players','players','number','Players with sessions in period'),('Latest event','latestEvent','date','UTC')])+chart('Event volume','Player-bound and game-level events')+'<div class="section-gap">'+table('Recent event records',EVENT_HEAD,'data-event-rows','Latest 50 records; player ID is N/A for game-level events')+'</div>'
pages['players']=heading('Players','Inspect latest snapshots and session aggregates. Open a player for all stored records.')+metrics([
('Tracked players','playerCount','number','All players under the selected game'),('Active in last 7 days','activePlayers','number','Based on last seen'),('Average known level','averageLevel','decimal','Unavailable levels excluded'),('Completed playtime','totalPlaytime','duration','Sum of known completed-session durations')])+ '<article class="card"><div class="card-head"><div><div class="card-title">Player stats</div><div class="card-note">The selected sort value is the first stat column. Missing values always sort last.</div></div><input class="filter-input" data-player-search placeholder="Search name or Roblox user ID"></div>'+segments()+'<div class="filters player-controls"><label class="control-label">Columns<select class="select" data-player-template></select></label><label class="control-label">Sort by<select class="select" data-player-sort></select></label><label class="control-label">Direction<select class="select" data-player-direction><option value="desc">Highest first</option><option value="asc">Lowest first</option></select></label></div><div class="table-wrap player-table-wrap"><table><thead><tr data-player-head><th>Player</th></tr></thead><tbody data-player-rows><tr><td>N/A</td></tr></tbody></table></div><div class="table-footer"><span data-page-summary>N/A</span><div class="inline"><button class="button" data-prev>Previous</button><span data-page-number>N/A</span><button class="button" data-next>Next</button></div></div></article><div class="card card-pad section-gap"><div class="setting-title">Player groups</div><div class="setting-help" data-group-definitions>N/A</div></div>'
profile_tabs='<nav class="tabs">'+''.join(f'<a href="#{k}" class="{"active" if k=="overview" else ""}" data-profile-tab="{k}">{v}</a>' for k,v in [('overview','Overview'),('values','Values'),('sessions','Sessions'),('activities','Activity runs'),('events','Events')])+'</nav>'
pages['player-info']='<div class="page-heading"><div><a class="back-link" href="players-mockup.html">← All players</a><h1 data-profile-name>N/A</h1><div class="subtitle" data-profile-id>N/A</div><div class="player-tags" data-profile-tags></div></div><div class="page-controls"><span class="pill">Stored player records</span></div></div>'+profile_tabs+'<section class="player-info-panel active" data-profile-panel="overview">'+metrics([
('Lifetime completed playtime','playtime','duration','Unreported end times are excluded'),('Average session','avgSession','duration','Completed sessions'),('Sessions','sessions','number','Lifetime session count'),('Median session','medianSession','duration','Completed sessions')])+'<div class="grid two-col">'+table('Latest saved values',['Field','Value'],'data-latest-value-rows','Fields absent from this player snapshot display N/A')+'<article class="card"><div class="card-head"><div><div class="card-title">Value history</div><div class="card-note">Stored snapshots, oldest to newest ? compare up to two values with separate scales</div></div><div class="profile-history-controls"><label class="control-label">Left axis<select class="select" data-profile-value aria-label="First history value"></select></label><label class="control-label">Right axis<select class="select" data-profile-value-second aria-label="Second history value"></select></label></div></div><div class="chart-wrap" data-chart="profile"></div></article></div></section><section class="player-info-panel" data-profile-panel="values">'+table('Snapshot history',['Captured (UTC)','Stored values'],'data-snapshot-rows','Full copies of the submitted player data')+'</section><section class="player-info-panel" data-profile-panel="sessions">'+table('Player sessions',SESSION_HEAD,'data-session-rows','Latest 50 sessions for this exact internal player ID')+'</section><section class="player-info-panel" data-profile-panel="activities">'+table('Individual activity runs',['Activity','Started (UTC)','Duration','Steps','Prompts','Outcome','Captured values'],'data-activity-rows','Underlying records are attached to the player; the activity page shows group averages')+'</section><section class="player-info-panel" data-profile-panel="events">'+table('Player event records',EVENT_HEAD,'data-event-rows','Latest 50 player-bound event records')+'</section>'
activity_tabs='<nav class="tabs">'+''.join(f'<a class="{"active" if k=="insights" else ""}" href="#{k}" data-activity-tab="{k}">{v}</a>' for k,v in [('insights','Insights'),('records','Run analysis'),('configure','Configure')])+'</nav>'
pages['activities']=heading('Activity sessions','Aggregate activity duration and outcomes; individual runs are stored in player info.')+'<div class="activity-picker-row"><label class="control-label">Activity<select class="select" data-activity-select></select></label><label class="control-label">Player group<select class="select" data-activity-group><option value="all">All players</option><option value="whales">Whales</option><option value="playtime">Top playtime</option><option value="first">First-time</option></select></label><button class="button" data-new-activity>+ New draft</button></div>'+activity_tabs+'<section class="activity-panel active" data-activity-panel="insights">'+metrics([
('Activity runs','runs','number','Selected activity, group and period'),('Average time','average','duration','Runs with a reported end'),('Median time','median','duration','Runs with a reported end'),('Completed','completion','percent','Runs with end report'),('Prompt rate','promptRate','percent','Runs with a recorded prompt'),('Purchase rate','purchaseRate','percent','Runs with purchase outcome'),('Prompt to purchase','promptPurchaseRate','percent','Purchased among prompted runs')])+'<article class="card"><div class="card-head"><div><div class="card-title">Configured journey</div><div class="card-note">Counts are derived from stored run counters and outcomes</div></div></div><div class="activity-flow" data-activity-journey></div></article><div class="grid two-col section-gap">'+chart('Average activity duration','Seconds by UTC date')+table('Captured values',['Field','Summary','Value'],'data-captured-rows','Only reported values are summarized')+'</div></section><section class="activity-panel" data-activity-panel="records">'+table('Activity outcomes',['Outcome','Runs','Share','Average time','Median time'],'data-outcome-rows','Aggregate groups, not a list of players. Unknown outcomes and durations display N/A.')+'</section><section class="activity-panel" data-activity-panel="configure"><article class="card card-pad"><div class="card-title">Activity definition</div><div class="card-note">Loaded from PostgreSQL. Editing remains a UI draft; changes are not saved.</div><div class="config-grid"><label>Activity name<input class="filter-input" data-activity-name></label><label>Start event<input class="filter-input" data-start-event></label><label>End event<input class="filter-input" data-end-event></label><label>Timeout<select class="select"><option>N/A</option><option>30 minutes</option><option>1 hour</option></select></label></div><div class="setting-row"><div class="setting-title">Journey events</div><button class="button" data-add-step>+ Add event</button></div><div class="config-events" data-config-events></div><div class="setting-row"><div class="setting-title">Captured properties</div><button class="button" data-add-field>+ Add value</button></div><div class="capture-fields" data-capture-fields></div><button class="button primary" data-save-activity>Preview draft</button></article></section><div class="toast" data-toast role="status"></div>'
pages['settings']=heading('Project settings','The internal game ID is the root of every dataset.')+'<article class="card card-pad"><div class="card-title">Game root</div><div class="config-grid"><label>Internal database game ID<input class="filter-input" data-game-field="id" readonly></label><label>Name<input class="filter-input" data-game-field="name" readonly></label><label>Roblox experience ID<input class="filter-input" data-game-field="robloxExperienceId" readonly></label><label>Display timezone<input class="filter-input" data-game-field="timezone" readonly></label></div><div class="config-note">Game metadata is read from PostgreSQL. API key management is not connected; ingestion credentials: N/A.</div></article><div class="section-gap">'+table('Game-level metric categories',METRIC_HEAD,'data-root-metric-rows','These values belong to the internal game root and have no player binding')+'</div>'
pages['overview']+='<div class="section-gap">'+metrics([('DAU today','dau','number','Distinct active players today · UTC'),('Rolling MAU today','mau','number','Distinct active players in last 30 calendar days'),('New users today','newUsersToday','number','First seen today'),('Returning users today','returningUsersToday','number','Active today, first seen before today')])+'</div><div class="grid two-col">'+chart('Daily and monthly active users','MAU is calculated separately for every day','active-users')+chart('New and returning daily users','UTC daily counts','new-returning')+'</div>'
pages['sessions']+='<div class="section-gap">'+metrics([('DAU today','dau','number','Active today'),('Rolling MAU','mau','number','30 calendar days ending today'),('Total playtime','totalPlaytime','duration','Completed-session time inside selected days'),('Playtime per active player','averagePlaytime','duration','Known playtime divided by unique period actives'),('New-player session time','newSessionTime','duration','Average completed session on first-seen day'),('Returning-player session time','returningSessionTime','duration','Average completed session on a later day'),('First-session 5m retention','firstSession5min','percent','Completed first sessions lasting at least 300 seconds')])+'</div><div class="grid two-col">'+chart('New vs returning session duration','Average seconds for each daily user group','session-groups')+chart('First-session five-minute retention','Percentage of known first sessions reaching 300 seconds','first-five')+chart('Total daily playtime','Known completed-session seconds, split at UTC midnight','playtime-total')+chart('Average daily playtime','Seconds per daily active user','playtime-average')+chart('DAU and rolling MAU','Distinct players · calculated for every day','active-users')+chart('New and returning users','Daily active user groups','new-returning')+'</div>'
pages['monetization']=heading('Monetization','Reported product sales, actual sale prices, payer behavior, and revenue trends.')+metrics([
('Robux spent','revenue','robux','Sum of recorded unit price × quantity'),('Paying users','payingUsers','number','Distinct positive-price buyers in period'),('Payer conversion','payerConversion','percent','Paying users / unique active period users'),('Robux per paying user','arppu','robux','Period Robux / period paying users'),('Robux per daily user · ARPDAU','arpdau','decimal','Period Robux / total daily active user-days'),('Products per paying user','productsPerPayer','decimal','Paid units / all period payers'),('Reported sales','transactions','number','Unique receipts, including free sales'),('Unattributed sales','unattributedSales','number','Positive-price sales without a player binding')])+'<div class="grid two-col">'+chart('Daily Robux spent','UTC daily totals','revenue-daily')+chart('Hourly Robux spent','UTC hourly totals','revenue-hourly')+chart('Paying users per day','Distinct daily positive-price buyers','payers')+chart('Daily payer conversion','Daily payers / DAU','payer-conversion')+chart('Daily ARPDAU','Same measure as average Robux per daily active user','arpdau')+chart('Robux by product','Recorded sales at their actual reported prices','products')+'</div><div class="section-gap">'+table('Products',['Product','Roblox product ID','Current price','Robux spent','Paid units','Buyers','Units per paying user'],'data-product-rows','Each product average uses ALL period paying users; decimal counts are intentional')+'</div><div class="section-gap">'+table('Recent sale reports',['Receipt','Product','Roblox product ID','Player ID','Unit price','Quantity','Total','Purchased (UTC)'],'data-sale-rows','Latest 50 reports; sale price is stored on each report independently of the catalog price')+'</div>'
pages['player-info']=pages['player-info'].replace('>Events</a></nav>','>Events</a><a href="#sales" data-profile-tab="sales">Purchases</a></nav>')+'<section class="player-info-panel" data-profile-panel="sales">'+table('Player purchase history',['Receipt','Product','Roblox product ID','Player ID','Unit price','Quantity','Total','Purchased (UTC)'],'data-sale-rows','Recorded sales associated with this internal player ID')+'</section>'
pages['settings']='<nav class="mobile-more-links" aria-label="More analytics"><a class="button" href="retention-mockup.html">Retention</a><a class="button" href="activities-mockup.html">Activity sessions</a><a class="button" href="monetization-mockup.html">Monetization</a></nav>'+pages['settings']
pages['player-info'] += '<div class="toast" data-toast role="status"></div>'
import re
removed_titles = ['Recent sessions', 'Game root metrics', 'Game-level metric categories', 'Recent sale reports', 'Unattributed sales']
for key in ('overview', 'sessions', 'settings', 'monetization'):
pages[key] = re.sub(r'<article\b[^>]*>.*?</article>', lambda m: '' if any(f'>{title}</div>' in m.group(0) for title in removed_titles) else m.group(0), pages[key], flags=re.S)
pages[key] = pages[key].replace('<div class="section-gap"></div>', '')
group_picker = '<div class="filters section-gap"><label class="control-label">Player group<select class="select" data-session-group><option value="all">All players</option><option value="whales">Whales · top spend</option><option value="playtime">Top playtime</option><option value="first">First-time</option></select></label><span class="card-note">Whales and top playtime use the top 20% of eligible players; first-time means first seen within 24 hours.</span></div>'
pages['sessions'] = pages['sessions'].replace('<div class="grid metrics">', group_picker + '<div class="grid metrics">', 1)
pages['events'] = heading('Events', 'Daily event totals across the selected game. Individual records remain available in player info.') + metrics([
('Events', 'events', 'number', 'Selected period'), ('Event types', 'eventTypes', 'number', 'Recorded event names'),
('Players', 'players', 'number', 'Distinct players with events'), ('Latest event', 'latestEvent', 'date', 'UTC')
]) + chart('Events per day', 'One line per event · UTC daily counts') + '<div class="section-gap">' + table('Event summary', ['Event', 'Categories', 'Total', 'Daily average', 'Unique players', 'Last seen (UTC)'], 'data-event-summary', 'One row per event name in the selected period') + '</div>'
def monetization_tabs(active):
return '<nav class="tabs">' + ''.join(f'<a class="{"active" if key == active else ""}" href="{key}-mockup.html">{label}</a>' for key, label in [('monetization', 'Revenue'), ('gamepasses', 'Gamepasses')]) + '</nav>'
event_picker = '<div class="event-chart-controls"><details class="event-picker"><summary>Choose events <span class="card-note" data-event-selection-count></span></summary><div class="event-picker-panel"><div class="event-picker-toolbar"><input class="filter-input" data-event-search type="search" placeholder="Find an event" aria-label="Find events to graph"><button class="button" data-events-all type="button">All</button><button class="button" data-events-none type="button">None</button></div><div class="event-options" data-event-options role="group" aria-label="Events to graph"></div></div></details></div>'
pages['events'] = pages['events'].replace('<div class="chart-wrap" data-chart="trend">', event_picker + '<div class="chart-wrap" data-chart="trend">', 1)
pages['monetization'] = pages['monetization'].replace('<div class="grid metrics">', monetization_tabs('monetization') + '<div class="grid metrics">', 1)
pages['gamepasses'] = heading('Gamepasses', 'Ownership among all tracked players and among lifetime paying players.') + monetization_tabs('gamepasses') + metrics([
('Gamepasses', 'gamepasses', 'number', 'Passes in the game catalog'), ('Tracked players', 'trackedPlayers', 'number', 'All tracked players in this game'), ('Paying players', 'lifetimePayers', 'number', 'Players with a recorded positive-price purchase')
]) + chart('Gamepass ownership', 'Share of all players and share of paying players · incomplete ownership reports display N/A', 'ownership') + '<div class="section-gap">' + table('Ownership by gamepass', ['Gamepass', 'Roblox pass ID', 'Owners', '% of all players', 'Paying owners', '% of paying players', 'Reporting coverage', 'Last checked (UTC)'], 'data-gamepass-rows', 'Ownership is reported explicitly. Missing checks are unknown; purchases alone do not establish ownership.') + '</div>'
pages['activities'] = pages['activities'].replace('Counts are derived from stored run counters and outcomes', 'Ordered stages · reach is measured from all starts; drop-off is measured from the previous stage')
pages['activities'] = pages['activities'].replace('<div class="grid two-col section-gap">', '<div class="section-gap">' + table('Stage conversion', ['Stage', 'Runs', 'Reached from start', 'From previous stage', 'Drop-off', 'Dropped runs', 'Unknown runs'], 'data-stage-rows', 'Only sequentially reached stages count. Missing stage reports display N/A.') + '</div><div class="grid two-col section-gap">', 1)
pages['settings'] = pages['settings'].replace('>Game root</div>', '>Game details</div>')
nav=[('overview','◫','Overview'),('sessions','◷','Sessions'),('retention','↗','Retention'),('players','♙','Players'),('events','⌘','Events'),('activities','⟿','Activity sessions'),('monetization','◇','Monetization'),('settings','⚙','Project settings')]
for page,content in pages.items():
current='players' if page=='player-info' else 'monetization' if page=='gamepasses' else page
links=''.join(f'<a class="{"active" if k==current else ""}" href="{k}-mockup.html"><span class="nav-icon">{icon}</span>{name}</a>' for k,icon,name in nav)
mobile=''.join(f'<a class="{"active" if current==k or (current in ("sessions","retention") and k=="sessions") or (current=="activities" and k=="events") else ""}" href="{k}-mockup.html"><span class="nav-icon">{icon}</span>{name}</a>' for k,icon,name in [('overview','◫','Overview'),('sessions','◷','Analytics'),('players','♙','Players'),('events','⌘','Events'),('settings','···','More')])
title=dict((k,n) for k,_,n in nav).get(page,'Gamepasses' if page=='gamepasses' else 'Player info')
html=f'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{title} — Statboard</title><link rel="stylesheet" href="mockup.css"></head>
<body data-page="{page}"><div class="app"><aside class="sidebar"><a class="brand" href="overview-mockup.html"><span class="brand-mark">S</span>Statboard</a><div class="project-select"><div class="project-label">Current game</div><select class="select game-select" data-game-select aria-label="Game"><option>N/A</option></select></div><div class="nav-label">Workspace</div><nav class="nav">{links}</nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">{title}</div><select class="select mobile-game-select" data-game-select aria-label="Game"><option>N/A</option></select><div class="top-actions"><span class="status">PostgreSQL · UTC</span></div></header><section class="page"><div class="error-banner" data-error hidden></div>{content}</section></main></div><nav class="mobile-nav">{mobile}</nav><script src="mockup.js"></script></body></html>'''
(ROOT/f'{page}-mockup.html').write_text(html,encoding='utf-8')
print(f'Updated {len(pages)} data-driven mockup pages.')