Expand dashboard analytics and document production implementation
This commit is contained in:
@@ -29,11 +29,11 @@ pages['retention']=heading('Retention','Exact-day return rates from stored playe
|
||||
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>'
|
||||
('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><select class="select" data-player-page aria-label="Player page"></select><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>'
|
||||
('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','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([
|
||||
@@ -49,6 +49,28 @@ pages['settings']='<nav class="mobile-more-links" aria-label="More analytics"><a
|
||||
pages['player-info'] += '<div class="toast" data-toast role="status"></div>'
|
||||
|
||||
import re
|
||||
analysis_box='<article class="card card-pad overview-analysis"><details class="health-report-disclosure" open><summary class="health-report-summary"><span class="health-report-heading"><span class="health-report-title">Everything at a glance</span><span class="health-report-actions"><span class="pill">Sample analysis</span><svg class="health-report-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 5 7 7-7 7"/></svg></span></span><span class="card-note" data-analysis-dates>Loading analysis...</span></summary><div class="health-report-content health-report-body" data-overview-analysis></div><div class="card-note health-report-updated" data-analysis-updated></div></details></article>'
|
||||
health_boxes='<section class="health-reports section-gap">'+analysis_box+'<div class="grid health-report-grid section-gap">'+''.join(f'<article class="card card-pad health-report" data-health-report="{days}"><details class="health-report-disclosure" open><summary class="health-report-summary"><span class="health-report-heading"><span class="health-report-title">{title}</span><span class="health-report-actions"><span class="pill">Sample analysis</span><svg class="health-report-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 5 7 7-7 7"/></svg></span></span><span class="card-note" data-health-dates>Loading report...</span></summary><div class="health-report-content"><div class="health-report-body" data-health-body></div><div class="health-report-notes" data-health-notes></div><div class="card-note health-report-updated" data-health-updated></div></div></details></article>' for days,title in [(1,'Yesterday'),(7,'Past 7 days')])+'</div></section>'
|
||||
pages['overview']=pages['overview'].replace('<div class="grid metrics">',health_boxes+'<div class="grid metrics">',1)
|
||||
def record_controls(content,kind,target):
|
||||
at=content.index(target)
|
||||
end=content.index('</article>',at)
|
||||
footer=f'<div class="table-footer" data-record-pagination="{kind}"><span data-record-summary="{kind}">N/A</span><div class="inline"><button class="button" data-record-prev="{kind}" disabled>Previous</button><select class="select" data-record-page="{kind}" aria-label="{kind} page"></select><button class="button" data-record-next="{kind}" disabled>Next</button></div></div>'
|
||||
content=content[:end]+footer+content[end:]
|
||||
if kind in ('events','purchases'):
|
||||
start=content.rfind('<div class="table-wrap">',0,at)
|
||||
picker=f'<details class="record-filter"><summary>Filter {kind}</summary><div class="record-filter-options" data-record-filter="{kind}"></div><button class="button" data-record-filter-all="{kind}">Show all</button></details>'
|
||||
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='<div class="grid two-col section-gap">'+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')+'</div>'
|
||||
pages['player-info']=pages['player-info'].replace('</section><section class="player-info-panel" data-profile-panel="values">',inventory+'</section><section class="player-info-panel" data-profile-panel="values">',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='<section class="section-gap hall-of-fame"><div class="page-heading"><div><h2>Hall of Fame</h2><div class="card-note">Top five players per stat across the game</div></div><label class="control-label">Other ranking<select class="select" data-leaderboard-value></select></label></div><div class="grid two-col" data-leaderboards></div></section>'
|
||||
pages['players']=pages['players'].replace('<article class="card">',hall+'<article class="card section-gap">',1)
|
||||
pages['retention']+='<div class="section-gap">'+chart('First-week retention','Day 1–7 exact-day returns · same mature cohorts at every point','retention-week')+'</div>'
|
||||
pages['activities'] = re.sub(r'<article\b[^>]*>.*?</article>', 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'<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)
|
||||
@@ -60,24 +82,33 @@ pages['events'] = heading('Events', 'Daily event totals across the selected game
|
||||
('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>'
|
||||
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'), ('prompts','Product prompts'), ('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['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')+'<div class="section-gap">'+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.')+'</div>'
|
||||
source_picker='<div class="filters prompt-source-controls"><label class="control-label">Prompt source<select class="select" data-prompt-source><option value="all">All sources</option><option value="game">Game-induced</option><option value="player">Player-induced</option></select></label><span class="card-note">Game-induced: automatic offers or triggers. Player-induced: an explicit purchase action.</span></div>'
|
||||
pages['prompts']=pages['prompts'].replace('<div class="grid metrics">',source_picker+'<div class="grid metrics">',1)
|
||||
pages['prompts']+='<div class="section-gap">'+chart('Buy rate by prompt source','All sources for comparison, regardless of the source filter above','prompt-sources')+'</div><div class="section-gap">'+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.')+'</div>'
|
||||
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>'
|
||||
]) + chart('Gamepass ownership', 'Share of all players and share of paying players · reported ownership', 'ownership') + '<div class="section-gap">' + 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.') + '</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['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'], 'data-stage-rows', 'Only observed stages reached in sequence count.') + '</div><div class="grid two-col section-gap">', 1)
|
||||
pages['settings'] = pages['settings'].replace('>Game root</div>', '>Game details</div>')
|
||||
pages['settings'] += '<section class="section-gap"><div class="page-heading"><div><h2>Data quality log</h2><div class="card-note">Unresolved or incomplete records for this game. Original data is retained; resolved results drive the main analytics.</div></div><label class="control-label">Category<select class="select" data-quality-filter><option value="all">All issues</option></select></label></div>'+table('Unresolved data',['Issue','Record type','Record ID','Details','Reported (UTC)'],'data-quality-rows')+'<div class="table-footer"><span data-quality-summary>N/A</span><div class="inline"><button class="button" data-quality-prev disabled>Previous</button><select class="select" data-quality-page aria-label="Data quality page"></select><button class="button" data-quality-next disabled>Next</button></div></div></section>'
|
||||
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
|
||||
current='players' if page=='player-info' else 'monetization' if page in ('gamepasses','prompts') 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')
|
||||
title=dict((k,n) for k,_,n in nav).get(page,{'gamepasses':'Gamepasses','prompts':'Product prompts'}.get(page,'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.')
|
||||
|
||||
(ROOT/'summaries-mockup.html').write_text('<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Overview</title><script>location.replace("overview-mockup.html"+location.search);</script></head><body><a href="overview-mockup.html">Open Overview</a></body></html>',encoding='utf-8')
|
||||
|
||||
Reference in New Issue
Block a user