84 lines
25 KiB
Python
84 lines
25 KiB
Python
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.')
|