from pathlib import Path ROOT=Path(__file__).resolve().parent.parent/'mockups' def metric(label,key,fmt='number',note=''): return f'
{label}
N/A
{note}
' def metrics(items): return '
'+''.join(metric(*item) for item in items)+'
' def chart(title,note='',key='trend'): return f'
{title}
{note}
N/A
' def table(title,heads,target,note=''): return f'
{title}
{note}
'+''.join('' for h in heads)+f'
'+h+'
N/A
' def heading(title,note): return f'

{title}

{note}
' def segments(): return '
'+''.join(f'' for k,v in [('all','All players'),('whales','Whales · top spend'),('playtime','Top playtime'),('first','First-time')])+'
' 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')])+ '
'+chart('Players and sessions','Grouped by UTC date')+chart('Top events','Recorded event counts','events')+'
'+table('Recent sessions',SESSION_HEAD,'data-session-rows','Latest 8 sessions across this game')+'
'+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')+'
' 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')+'
'+table('Recent sessions',SESSION_HEAD,'data-session-rows','Latest 50 game sessions; an unreported end time gives N/A duration')+'
' 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')])+'
Retention by cohort
First-session date and percentage returning on the exact day · UTC
'+chart('Return rate trend','N/A observations are gaps in the chart')+'
' pages['events']=heading('Events','Events belong to the game root and can optionally reference a player and session.')+''+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')+'
'+table('Recent event records',EVENT_HEAD,'data-event-rows','Latest 50 records; player ID is N/A for game-level events')+'
' 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')])+ '
Player stats
The selected sort value is the first stat column. Missing values always sort last.
'+segments()+'
Player
N/A
Player groups
N/A
' profile_tabs='' pages['player-info']='
← All players

N/A

N/A
Stored player records
'+profile_tabs+'
'+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')])+'
'+table('Latest saved values',['Field','Value'],'data-latest-value-rows','Fields absent from this player snapshot display N/A')+'
Value history
Stored snapshots, oldest to newest ? compare up to two values with separate scales
'+table('Snapshot history',['Captured (UTC)','Stored values'],'data-snapshot-rows','Full copies of the submitted player data')+'
'+table('Player sessions',SESSION_HEAD,'data-session-rows','Latest 50 sessions for this exact internal player ID')+'
'+table('Individual activity runs',['Activity','Started (UTC)','Duration','Steps','Outcome','Captured values'],'data-activity-rows','Underlying records are attached to the player; the activity page shows group averages')+'
'+table('Player event records',EVENT_HEAD,'data-event-rows','Latest 50 player-bound event records')+'
' activity_tabs='' pages['activities']=heading('Activity sessions','Aggregate activity duration and outcomes; individual runs are stored in player info.')+'
'+activity_tabs+'
'+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')])+'
Configured journey
Counts are derived from stored run counters and outcomes
'+chart('Average activity duration','Seconds by UTC date')+table('Captured values',['Field','Summary','Value'],'data-captured-rows','Only reported values are summarized')+'
'+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.')+'
Activity definition
Loaded from PostgreSQL. Editing remains a UI draft; changes are not saved.
Journey events
Captured properties
' pages['settings']=heading('Project settings','The internal game ID is the root of every dataset.')+'
Game root
Game metadata is read from PostgreSQL. API key management is not connected; ingestion credentials: N/A.
'+table('Game-level metric categories',METRIC_HEAD,'data-root-metric-rows','These values belong to the internal game root and have no player binding')+'
' pages['overview']+='
'+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')])+'
'+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')+'
' pages['sessions']+='
'+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')])+'
'+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')+'
' 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')])+'
'+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')+'
'+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')+'
'+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')+'
' pages['player-info']=pages['player-info'].replace('>Events','>EventsPurchases')+'
'+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')+'
' pages['settings']=''+pages['settings'] pages['player-info'] += '
' import re analysis_box='
Everything at a glanceSample analysisLoading analysis...
' health_boxes='
'+analysis_box+'
'+''.join(f'
{title}Sample analysisLoading report...
' for days,title in [(1,'Yesterday'),(7,'Past 7 days')])+'
' pages['overview']=pages['overview'].replace('
',health_boxes+'
',1) def record_controls(content,kind,target): at=content.index(target) end=content.index('',at) footer=f'' content=content[:end]+footer+content[end:] if kind in ('events','purchases'): start=content.rfind('
',0,at) picker=f'
Filter {kind}
' content=content[:start]+picker+content[start:] return content for kind,target in [('snapshots','data-snapshot-rows'),('sessions','data-session-rows'),('activities','data-activity-rows'),('events','data-event-rows'),('purchases','data-sale-rows')]: pages['player-info']=record_controls(pages['player-info'],kind,target) inventory='
'+table('Owned gamepasses',['Gamepass','Roblox pass ID','Purchased (UTC)'],'data-owned-pass-rows','Oldest purchase first. Unreported purchase dates display N/A.')+table('Products purchased',['Product','Units bought','Robux spent','First purchase (UTC)','Last purchase (UTC)'],'data-owned-product-rows','Lifetime totals for this player')+'
' pages['player-info']=pages['player-info'].replace('
',inventory+'
',1) pages['player-info']=pages['player-info'].replace('Latest 50','20 per page ·').replace('Full copies of the submitted player data','Newest snapshots first · 20 per page') hall='

Hall of Fame

Top five players per stat across the game
' pages['players']=pages['players'].replace('
',hall+'
',1) pages['retention']+='
'+chart('First-week retention','Day 1–7 exact-day returns · same mature cohorts at every point','retention-week')+'
' pages['activities'] = re.sub(r']*>.*?
', lambda m: '' if any(f'data-metric="{key}"' in m.group(0) for key in ('promptRate','purchaseRate','promptPurchaseRate','completion')) else m.group(0), pages['activities'], flags=re.S) 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']*>.*?
', lambda m: '' if any(f'>{title}
' in m.group(0) for title in removed_titles) else m.group(0), pages[key], flags=re.S) pages[key] = pages[key].replace('
', '') group_picker = '
Whales and top playtime use the top 20% of eligible players; first-time means first seen within 24 hours.
' pages['sessions'] = pages['sessions'].replace('
', group_picker + '
', 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') + '
' + 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') + '
' def monetization_tabs(active): return '' event_picker = '
Choose events
' pages['events'] = pages['events'].replace('
', event_picker + '
', 1) pages['monetization'] = pages['monetization'].replace('
', monetization_tabs('monetization') + '
', 1) pages['prompts']=heading('Product prompts','Lightweight prompt interactions, separate from activity sessions.')+monetization_tabs('prompts')+metrics([ ('Resolved prompts','prompts','number','Bought or closed prompts in the period'),('Buy rate','conversion','percent','Bought / all resolved prompts'), ('Time to buy','buy_time','duration','Average resolved purchase time'),('Time to close','close_time','duration','Average resolved dismissal time')])+chart('Daily prompt buy rate','One line per product · days with no prompts show N/A')+'
'+table('Prompted products',['Product','Prompts','Bought','Closed','Buy rate','Time to buy','Time to close'],'data-prompt-product-rows','Resolved prompt results; timing starts when the purchase prompt appears.')+'
' source_picker='
Game-induced: automatic offers or triggers. Player-induced: an explicit purchase action.
' pages['prompts']=pages['prompts'].replace('
',source_picker+'
',1) pages['prompts']+='
'+chart('Buy rate by prompt source','All sources for comparison, regardless of the source filter above','prompt-sources')+'
'+table('Prompt source comparison',['Source','Prompts','Bought','Closed','Buy rate','Time to buy','Time to close'],'data-prompt-source-rows','All sources in the selected period. Conversion differences show association; audiences may differ.')+'
' 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 · reported ownership', 'ownership') + '
' + table('Ownership by gamepass', ['Gamepass', 'Roblox pass ID', 'Owners', '% of all players', 'Paying owners', '% of paying players', 'Last checked (UTC)'], 'data-gamepass-rows', 'Reported ownership across tracked players.') + '
' 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('
', '
' + table('Stage conversion', ['Stage', 'Runs', 'Reached from start', 'From previous stage', 'Drop-off', 'Dropped runs'], 'data-stage-rows', 'Only observed stages reached in sequence count.') + '
', 1) pages['settings'] = pages['settings'].replace('>Game root
', '>Game details
') pages['settings'] += '

Data quality log

Unresolved or incomplete records for this game. Original data is retained; resolved results drive the main analytics.
'+table('Unresolved data',['Issue','Record type','Record ID','Details','Reported (UTC)'],'data-quality-rows')+'
' 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 in ('gamepasses','prompts') else page links=''.join(f'{icon}{name}' for k,icon,name in nav) mobile=''.join(f'{icon}{name}' 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':'Gamepasses','prompts':'Product prompts'}.get(page,'Player info')) html=f''' {title} — Statboard
PostgreSQL · UTC
{content}
''' (ROOT/f'{page}-mockup.html').write_text(html,encoding='utf-8') print(f'Updated {len(pages)} data-driven mockup pages.') (ROOT/'summaries-mockup.html').write_text('OverviewOpen Overview',encoding='utf-8')