Expand dashboard analytics and document production implementation
This commit is contained in:
@@ -43,9 +43,27 @@ Each sale row records its product, price **at sale time**, quantity, receipt ide
|
||||
|
||||
## Gamepass ownership and activity stages
|
||||
|
||||
The Summaries page reuses the two daily report snapshots. Its combined overview includes CCU, active players/DAU, rolling MAU, new/returning period actives, sessions/playtime, first-session retention, mature D1/D7/D30 returns, revenue/payers/conversion, ARPPU/ARPDAU, leading products, prompt-source conversion, and the largest observed ordered-stage loss. Rolling MAU covers the 30 complete days ending on the report's final day. D7/D30 use acquisition cohorts whose exact observation day falls within the report window, avoiding immature cohorts. Product totals, payer concentration, prompt timing, and activity funnels are independently scoped to the same game and complete UTC window. Suggestions are mockup editorial text; observed differences are not causal claims. Older cached prototype payloads are upgraded once to include the added summaries, then remain stable for that report date.
|
||||
|
||||
Game Health reports use yesterday `[UTC today-1, today)` and the last seven complete days `[today-7, today)`, compared with the immediately preceding equal-length window. Yesterday also compares with the same weekday seven days earlier. The latest day in a report is always complete; chart date selection does not change these windows.
|
||||
|
||||
Reports count unique period-active players and mean daily DAU separately. New/returning period actives are split by whether first seen falls in the window or earlier. Sessions and average completed-session duration use starts in the window; known playtime is split across midnight and clipped to the window. CCU averages/peaks use only known hourly reports, with coverage notes; a zero baseline displays "up from zero" rather than an infinite growth percentage. First-session five-minute retention excludes unknown first-session ends. Report D1 covers acquisition cohorts whose complete next-day observation falls inside the report window, so yesterday's new users are not incorrectly counted as failed returns. Revenue uses historical recorded prices; missing payer attribution makes payer conversion N/A.
|
||||
|
||||
`daily_health_reports` stores two snapshots per game/report date. A background task checks for missing reports every minute and builds new snapshots after UTC rollover, with catch-up on startup/first request. Existing daily snapshots remain unchanged. The prototype uses measured, deterministic sample narratives, not an external AI service.
|
||||
|
||||
Prompt `trigger_source` is explicitly reported as `game`, `player`, or `unknown`. Game-induced means an automatic offer or game trigger; player-induced means an explicit purchase action such as clicking a buy button. These identify the cause of the purchase prompt, not merely whether the shop was opened. Missing source remains unknown in storage and is listed in the settings log. The source filter affects prompt totals, product rows, and product trend lines; the source comparison table and graph always show both known sources within the selected game/date window. Conversion and timing formulas are unchanged. Differences are observational, not evidence that a source caused higher conversion. The bundled data uses synthetic source labels with differing response patterns; no real source is inferred from whether a purchase occurred.
|
||||
|
||||
Graphs with more than two available series expose searchable line selection and All/None controls. Two-line and single-line charts are unchanged. Selecting fewer lines does not remove the picker. Empty selection remains reversible, hidden lines do not affect the axis or tooltip, and original colors stay fixed.
|
||||
|
||||
Player ownership lists use explicit `purchased_at`, sorted oldest first with unknown dates last. `checked_at` is not treated as a purchase date. The bundled synthetic observations include generated purchase dates; real missing dates remain N/A. Product inventory counts all reported units for the player and lists lifetime spending separately from individual receipts.
|
||||
|
||||
Product prompts are stored in `interaction_events` (`kind=product_prompt`) rather than activity runs. Each prompt records its shown time, resolved time when available, product, bought/closed outcome, and optional player/session/receipt. Buy rate is bought prompts / resolved bought-or-closed prompts with a known game/player source. Unresolved outcomes and unknown sources are excluded from regular prompt analytics. Average buy/close time includes only prompts with that reported outcome and a known end. Unresolved diagnostics are listed in Project settings; empty denominators or unavailable durations show N/A. Daily conversion uses the same definition per product. The generic interaction dataset supports other small timed events through `kind` and `properties`.
|
||||
|
||||
The Day 1–7 retention curve uses only cohorts within the selected period with all seven observation days complete. All seven points share the same denominator. The existing cohort grid and acquisition-date trend remain available; exact-day curves can fluctuate rather than decline perfectly at every point.
|
||||
|
||||
The Monetization page has Revenue and Gamepasses tabs. Gamepass ownership uses explicit per-player true/false observations, independent of purchase reports. The all-player percentage divides owners by all tracked players in this game. The paying-player percentage divides paying owners by lifetime players with a recorded positive-price purchase. An incomplete set of ownership checks displays N/A for the affected percentage; reporting coverage is displayed separately. No paying players means N/A for the paying-player percentage. These ownership percentages are current snapshots and do not use the selected revenue date range.
|
||||
|
||||
Activity stages use ordered `stage_progress` event observations. Stage reach is the percentage of all activity starts reaching that stage in sequence. Conversion divides stage reach count by the previous stage count; drop-off is its complement. A later event does not count if a required earlier stage was skipped. Unknown stage observations make subsequent percentages N/A rather than fabricated drop-offs.
|
||||
Activity stages use ordered `stage_progress` event observations. Stage reach is the percentage of all activity starts reaching that stage in sequence. Conversion divides stage reach count by the previous stage count; drop-off is its complement. A later event does not count if a required earlier stage was skipped. Unknown stage observations are excluded from stage reach/drop-off denominators and listed in the settings log, rather than becoming fabricated drop-offs.
|
||||
|
||||
Events show one daily graph line and one summary row per event name. Global recent-event, recent-session, and recent-sale tables are removed; individual records remain in player info. Game-root metric categories remain stored but have no overview or project-settings table. Session aggregates and charts accept the same player groups as activity analytics.
|
||||
|
||||
@@ -56,3 +74,9 @@ Player history accepts `value` and optional `value2` and plots them at stored sn
|
||||
The Events chart filters lines using a searchable checkbox picker with All/None controls. Selection is stored locally per internal game ID. Hidden series are excluded from hover tooltips and axis scaling, while summary totals remain unchanged.
|
||||
|
||||
Whales are the top 20% of players with known positive lifetime recorded Robux spending. Top playtime is the top 20% with known positive completed-session playtime. First-time players were first seen in the last 24 hours. These are sample group definitions, not fixed business rules; the intended product should allow configurable thresholds and periods.
|
||||
|
||||
## Data quality presentation
|
||||
|
||||
Project settings has a game-scoped diagnostic log at `/api/games/{game_id}/data-quality?page=1&issue=all`. It uses retained source records to show unresolved prompt outcomes/sources, stale session ends (older than six hours), incomplete activity outcomes/stages, null metric reports, missing sale attribution, missing ownership checks, and missing pass purchase dates. Categories filter before counting and 20-row pagination. It is a live issue view, not a separate permanent audit trail; corrected records stop appearing.
|
||||
|
||||
Main analytics omit unresolved counters, unknown-source prompt rows, coverage columns, and report warning notes. Prompt ratios use only resolved outcomes with known sources; activity result analytics exclude unknown end/outcome, and stage funnels use observed stage lists only. Missing actual metrics still show N/A, including ownership percentages whose all-player denominator cannot be established. The reports cache is refreshed once for this presentation/calculation revision. Raw records and report evidence remain stored.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# PostgreSQL-backed development mockups
|
||||
|
||||
This existing mockup is the reference product to port into production. See [PRODUCTION_IMPLEMENTATION.md](../PRODUCTION_IMPLEMENTATION.md) for the porting inventory, endpoints, Roblox payload/schedule contract, AI prompts, and production acceptance gates.
|
||||
|
||||
The HTML prototype now reads PostgreSQL data. The temporary Python service is a read-only development API; the planned production stack remains Lapis and React/TypeScript.
|
||||
|
||||
## Run on Windows
|
||||
|
||||
Double-click **`start-dashboard.bat`** in the repository root, or run `python start_dashboard.py`. The launcher sets up missing dependencies on first run, starts PostgreSQL and the backend, shows backend output in its terminal, and opens the dashboard in your browser. Close that terminal or press Ctrl+C to stop the backend and local PostgreSQL together. Windows process ownership also prevents background children being left behind if the terminal is closed abruptly. An already running local sample database is restarted under the launcher's ownership. A `DATABASE_URL` server is external and is never stopped. Browser tabs opened in an existing browser can be closed normally.
|
||||
|
||||
Use `start-dashboard.bat --no-browser` to keep the browser from opening. Only one launcher per checkout can run at a time. If another backend already occupies port 8011, close that backend's terminal first; the launcher reports the conflict.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
@@ -63,12 +69,23 @@ Start with `GET /api/games`, then use the returned **internal** ID:
|
||||
/api/games/{game_id}/monetization
|
||||
```
|
||||
|
||||
Analytics accepts `days=7|14|30|90` (any integer from 1 to 90 is supported). Players supports `page`, `page_size` (maximum 100), `sort`, `direction=asc|desc`, `q`, and `segment=all|whales|playtime|first`. Sorting and pagination happen in the backend; unknown values sort last in either direction. Activity analytics accepts `activity_id` and the same segment names. Player detail accepts a snapshot field `value` for its history graph.
|
||||
Analytics accepts `days=7|14|30|90` (any integer from 1 to 90 is supported). Players supports `page`, `page_size` (default and maximum 20), `sort`, `direction=asc|desc`, `q`, `ranking` for the fourth Hall of Fame stat, and `segment=all|whales|playtime|first`. Sorting and pagination happen in the backend; unknown values sort last in either direction. Activity analytics accepts `activity_id` and the same segment names. Player detail accepts `value` and `value2` for its comparison graph.
|
||||
|
||||
Player records use `/api/games/{game_id}/players/{player_id}/{snapshots|sessions|activities|events|purchases}?page=1`, always 20 per page and newest first. Repeated `event` parameters filter event names; repeated `product` parameters filter internal product IDs. Filters apply before pagination. The graph uses chronological snapshot timestamps independently of the table page. Other aggregate tables also have 20-row page controls.
|
||||
|
||||
`/api/games/{game_id}/prompts` supplies the Product prompts monetization tab. Prompt interactions are independent of activity runs. Owned gamepasses and product totals appear in separate player-overview cards. The Players Hall of Fame shows four top-five rankings with avatar placeholders; production avatar loading is not connected.
|
||||
|
||||
Prompt analytics accepts `source=all|game|player`. New interactions default to unknown unless an explicit `trigger_source` is reported. The prompt-source comparison always shows all sources for context, while product metrics follow the filter. Charts with more than two lines support search, per-line toggles, and All/None controls.
|
||||
|
||||
All API values are queried from PostgreSQL; there is no in-memory sample-data fallback. Database errors return HTTP 503, and the UI shows an error and `N/A`. The HTTP server serves only HTML/CSS/JS assets, not credentials, database files, Python, or SQL source.
|
||||
|
||||
## Pages
|
||||
|
||||
Overview now starts with a broad **Everything at a glance** analysis, with daily/weekly numerical reports underneath and no Game health section header. It interprets audience/concurrency/spending relationships, engagement strengths, onboarding weaknesses, activity/progression friction, payer concentration, offer placement, and priorities. Matched saved-player values add economy/progression context. The former Summaries navigation/page is removed; old `summaries-mockup.html` links redirect to Overview preserving their query string. Structured topic evidence remains available internally in the report payload.
|
||||
|
||||
|
||||
Overview includes daily Game Health sample-analysis cards for yesterday and the past seven complete UTC days. Each game has separate text and evidence. `/api/games/{game_id}/reports` returns the current pair; PostgreSQL caches them by game/date/window, and the backend checks for a new UTC day every minute. Reports catch up after downtime. No AI key or model call is needed for these sample narratives. The public API remains read-only for ingestion/settings; report-cache writes are internal server maintenance.
|
||||
|
||||
Overview, Sessions, Retention, Events, Players, Player info, Activity sessions, Monetization, and Project settings follow the `pagename-mockup.html` convention. Login remains a visual prototype. All pages share `mockup.css`; `mockup.js` loads the shared `data-ui.js` module. Activity definition editing remains a UI draft; this service does not expose an ingestion or settings-write API.
|
||||
|
||||
See [METRICS.md](METRICS.md) for calculation definitions, denominators, and handling of unavailable data.
|
||||
@@ -91,3 +108,5 @@ To stop only the local sample database:
|
||||
```powershell
|
||||
.local/pgsql/bin/pg_ctl.exe -D .local/pgdata -m fast stop
|
||||
```
|
||||
|
||||
Project settings includes a Data quality log with category filters and pages of 20. Missing/unresolved records remain stored and visible there while regular prompt/activity analytics use known results. Report warning notes, unresolved columns, and unknown-source prompt comparisons are omitted from the main views. Recent open sessions remain visible; stale unended sessions appear in the diagnostic log.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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')
|
||||
|
||||
@@ -36,7 +36,12 @@ function reveal(node) {
|
||||
}
|
||||
function error(message) {
|
||||
fillMetrics({});
|
||||
$$('[data-chart]').forEach(node=>node.innerHTML='<div class="empty">N/A — data unavailable</div>');
|
||||
$$('[data-overview-analysis]').forEach(node=>node.textContent='N/A — analysis unavailable');
|
||||
$$('[data-analysis-dates], [data-analysis-updated]').forEach(node=>node.textContent='');
|
||||
$$('[data-health-body]').forEach(node=>node.textContent='N/A — report unavailable');
|
||||
$$('[data-health-dates], [data-health-notes], [data-health-updated]').forEach(node=>node.textContent='');
|
||||
$$('[data-summary-executive]').forEach(node=>node.textContent='N/A — summaries unavailable');
|
||||
$$('[data-chart]').forEach(node=>{node._chartDraw=null;node.innerHTML='<div class="empty">N/A — data unavailable</div>';});
|
||||
$$('tbody').forEach(node=>node.innerHTML=`<tr><td colspan="${node.closest('table').querySelectorAll('th').length || 1}" class="empty">N/A — data unavailable</td></tr>`);
|
||||
const banner=$('[data-error]');
|
||||
banner.textContent=message; banner.hidden=false;
|
||||
@@ -58,8 +63,30 @@ function fillMetrics(metrics={}) {
|
||||
function table(selector, records) {
|
||||
const node=$(selector); if (!node) return;
|
||||
const span=node.closest('table').querySelectorAll('th').length || 1;
|
||||
node.innerHTML=records.length ? records.map(record=>`<tr>${record.map(value=>`<td>${value}</td>`).join('')}</tr>`).join('') : `<tr><td colspan="${span}" class="empty">N/A — no records for this selection</td></tr>`;
|
||||
reveal(node);
|
||||
const card=node.closest('.card');
|
||||
let pager=card?.querySelector('[data-table-pagination]');
|
||||
if (pager) pager.remove();
|
||||
let page=1;
|
||||
const pages=Math.max(1,Math.ceil(records.length/20));
|
||||
const draw=()=>{
|
||||
node.innerHTML=records.length ? records.slice((page-1)*20,page*20).map(record=>`<tr>${record.map(value=>`<td>${value}</td>`).join('')}</tr>`).join('') : `<tr><td colspan="${span}" class="empty">N/A — no records for this selection</td></tr>`;
|
||||
if (pager) {
|
||||
pager.querySelector('[data-prev]').disabled=page===1;
|
||||
pager.querySelector('[data-next]').disabled=page===pages;
|
||||
pager.querySelector('select').value=page;
|
||||
pager.querySelector('span').textContent=`${records.length} records · 20 per page`;
|
||||
}
|
||||
reveal(node);
|
||||
};
|
||||
if (pages>1 && card) {
|
||||
pager=document.createElement('div');pager.className='table-footer';pager.dataset.tablePagination='';
|
||||
pager.innerHTML=`<span></span><div class="inline"><button class="button" data-prev>Previous</button><select class="select" aria-label="Table page">${Array.from({length:pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${pages}</option>`).join('')}</select><button class="button" data-next>Next</button></div>`;
|
||||
card.append(pager);
|
||||
pager.querySelector('[data-prev]').addEventListener('click',()=>{page--;draw();});
|
||||
pager.querySelector('[data-next]').addEventListener('click',()=>{page++;draw();});
|
||||
pager.querySelector('select').addEventListener('change',e=>{page=Number(e.target.value);draw();});
|
||||
}
|
||||
draw();
|
||||
}
|
||||
function playerLink(playerId, name, robloxId) {
|
||||
if (missing(playerId)) return 'N/A';
|
||||
@@ -67,7 +94,7 @@ function playerLink(playerId, name, robloxId) {
|
||||
return `<a class="player-name-link" href="player-info-mockup.html?${query}">${cell(name)}</a><div class="card-note">Roblox ID ${cell(robloxId,'text')}</div>`;
|
||||
}
|
||||
function renderSessions(records=[]) {
|
||||
table('[data-session-rows]',records.map(r=>[playerLink(r.player_id,r.display_name,r.roblox_user_id),cell(r.started_at,'date'),cell(r.duration,'duration'),`<button class="button join-session" data-join-session="${cell(r.id)}" ${r.joinAvailable ? '' : 'disabled'} title="${r.joinAvailable ? 'Join this session (placeholder)' : 'Session has ended or is too old to join'}">Join session</button>`,`<span class="pill ${r.ended_at ? 'good' : 'warn'}">${r.ended_at ? 'Completed' : 'End unreported'}</span>`]));
|
||||
table('[data-session-rows]',records.map(r=>[playerLink(r.player_id,r.display_name,r.roblox_user_id),cell(r.started_at,'date'),cell(r.duration,'duration'),`<button class="button join-session" data-join-session="${cell(r.id)}" ${r.joinAvailable ? '' : 'disabled'} title="${r.joinAvailable ? 'Join this session (placeholder)' : 'Session has ended or is too old to join'}">Join session</button>`,`<span class="pill ${r.ended_at ? 'good' : 'warn'}">${r.ended_at ? 'Completed' : 'Open'}</span>`]));
|
||||
}
|
||||
function renderEvents(records=[]) {
|
||||
table('[data-event-rows]',records.map(r=>[`<code class="event-name">${cell(r.name)}</code>`,cell(r.category),cell(r.roblox_user_id),r.player_id === null ? 'Game' : 'Player',cell(r.occurred_at,'date'),`<code class="json-value">${cell(r.properties,'json')}</code>`]));
|
||||
@@ -147,14 +174,68 @@ function inspectChart(node, {width, height, left, right, top=0, bottom=0, count,
|
||||
if (chartTooltip) chartTooltip.hidden=true;
|
||||
}
|
||||
|
||||
const chartResizeObserver=typeof ResizeObserver==='undefined' ? null : new ResizeObserver(entries=>{
|
||||
for (const {target} of entries) {
|
||||
const width=target.getBoundingClientRect().width;
|
||||
if (Math.abs(width-target._chartWidth)>1) {target._chartWidth=width;target._chartDraw?.();}
|
||||
}
|
||||
});
|
||||
function watchChart(node,draw) {
|
||||
node._chartDraw=draw;node._chartWidth=node.getBoundingClientRect().width;
|
||||
if (!node._chartObserved) {chartResizeObserver?.observe(node);node._chartObserved=true;}
|
||||
}
|
||||
function chartLayout(node,short=false,minimumWidth=0) {
|
||||
const style=getComputedStyle(node);
|
||||
const available=node.clientWidth-(parseFloat(style.paddingLeft)||0)-(parseFloat(style.paddingRight)||0);
|
||||
return {width:Math.max(220,minimumWidth,Math.round(available)||760),height:short ? 170 : parseFloat(style.getPropertyValue('--chart-height'))||230};
|
||||
}
|
||||
function axisLabel(value,maxChars) {
|
||||
let text=String(value);
|
||||
if (text.endsWith(' UTC')) text=text.slice(0,6);
|
||||
return text.length>maxChars ? text.slice(0,Math.max(1,maxChars-1))+'…' : text;
|
||||
}
|
||||
const chartSeriesSelections=new Map();
|
||||
function bindLinePicker(node,selection,redraw) {
|
||||
const picker=node.querySelector('[data-series-picker]');if (!picker) return;
|
||||
picker.addEventListener('change',event=>{
|
||||
if (!event.target.matches('[data-series-name]')) return;
|
||||
if (event.target.checked) selection.visible.add(event.target.value);
|
||||
else selection.visible.delete(event.target.value);
|
||||
redraw();
|
||||
});
|
||||
picker.querySelector('[data-series-all]').addEventListener('click',()=>{selection.visible=new Set(selection.known);redraw();});
|
||||
picker.querySelector('[data-series-none]').addEventListener('click',()=>{selection.visible.clear();redraw();});
|
||||
const search=picker.querySelector('[data-series-search]');
|
||||
const applySearch=()=>picker.querySelectorAll('[data-series-option]').forEach(label=>label.hidden=!label.textContent.toLowerCase().includes((node._seriesSearch || '').toLowerCase()));
|
||||
search.value=node._seriesSearch || '';
|
||||
search.addEventListener('input',()=>{node._seriesSearch=search.value;applySearch();});
|
||||
applySearch();
|
||||
}
|
||||
function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
|
||||
if (chartTooltip) chartTooltip.hidden=true;
|
||||
const node=$(selector); if (!node) return;
|
||||
node._chartDraw=null;
|
||||
const allSeries=series,redraw=()=>lineChart(selector,allSeries,labels,{separateAxes});
|
||||
const needsPicker=allSeries.length>2 && !(document.body.dataset.page==='events' && selector==='[data-chart="trend"]');
|
||||
let pickerMarkup='',selection;
|
||||
if (needsPicker) {
|
||||
const key=`${state.game?.id}:${selector}`;
|
||||
if (!chartSeriesSelections.has(key)) chartSeriesSelections.set(key,{known:new Set(),visible:new Set()});
|
||||
selection=chartSeriesSelections.get(key);
|
||||
allSeries.forEach(item=>{if (!selection.known.has(item.name)) {selection.known.add(item.name);selection.visible.add(item.name);}});
|
||||
series=allSeries.map((item,i)=>({...item,color:item.color || seriesColor(i)})).filter(item=>selection.visible.has(item.name));
|
||||
const open=node.querySelector('[data-series-picker]')?.open;
|
||||
pickerMarkup=`<details class="event-picker chart-series-picker" data-series-picker ${open ? 'open' : ''}><summary>Choose lines <span class="card-note">${series.length} of ${allSeries.length}</span></summary><div class="event-picker-panel"><div class="event-picker-toolbar"><input class="filter-input" type="search" data-series-search aria-label="Find graph lines" placeholder="Find a line"><button class="button" data-series-all>All</button><button class="button" data-series-none>None</button></div><div class="event-options">${allSeries.map((item,i)=>`<label class="event-option" data-series-option><input type="checkbox" data-series-name value="${cell(item.name,'text')}" ${selection.visible.has(item.name) ? 'checked' : ''}><i class="dot" style="background:${item.color || seriesColor(i)}"></i><span>${cell(item.name,'text')}</span></label>`).join('')}</div></div></details>`;
|
||||
}
|
||||
const values=series.flatMap(s=>s.values).filter(v=>typeof v === 'number' && Number.isFinite(v));
|
||||
if (!labels.length || !values.length) { node.innerHTML='<div class="empty">N/A — no reported values</div>'; return; }
|
||||
if (!labels.length || !values.length) {
|
||||
node.innerHTML=pickerMarkup+`<div class="empty">${needsPicker && !series.length ? 'Select lines to graph.' : 'N/A — no reported values'}</div>`;
|
||||
if (needsPicker) {bindLinePicker(node,selection,redraw);watchChart(node,redraw);}
|
||||
return;
|
||||
}
|
||||
const dual=separateAxes && series.length===2;
|
||||
const colorFor=index=>series[index].color || seriesColor(index);
|
||||
const width=760,height=230,left=48,right=dual ? 58 : 15,top=14,bottom=32;
|
||||
const {width,height}=chartLayout(node),left=48,right=dual ? 58 : 15,top=14,bottom=32;
|
||||
const low=Math.min(0,...values),high=Math.max(...values)*1.1 || 1;
|
||||
const scales=series.map(s=>{
|
||||
const valid=s.values.filter(Number.isFinite);
|
||||
@@ -182,8 +263,12 @@ function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
|
||||
const points=s.values.map((value,i)=>typeof value === 'number' && Number.isFinite(value) ? `<circle cx="${x(i)}" cy="${y(value,index)}" r="2" fill="${color}"><title>${escape(s.name)} · ${escape(labels[i])}: ${cell(value,s.format)}</title></circle>` : '').join('');
|
||||
return `<path class="line ${index ? 'secondary' : ''}" style="stroke:${color}" pathLength="100" d="${path}"/>${points}`;
|
||||
}).join('');
|
||||
const ticks=labels.map((v,i)=>i % Math.max(1,Math.ceil(labels.length/6)) === 0 || i===labels.length-1 ? `<text x="${x(i)}" y="${height-8}" text-anchor="middle">${escape(v)}</text>` : '').join('');
|
||||
node.innerHTML=`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escape(series.map(s=>s.name).join(', '))}">${grid}${paths}${ticks}</svg><div class="legend">${series.map((s,i)=>`<span><i class="dot" style="background:${colorFor(i)}"></i>${escape(s.name)}${dual ? ` (${i ? 'right' : 'left'} axis)` : ''}</span>`).join('')}</div>`;
|
||||
const tickCount=Math.min(labels.length,Math.max(2,Math.floor((width-left-right)/110)));
|
||||
const tickIndices=new Set(Array.from({length:tickCount},(_,i)=>Math.round(i*(labels.length-1)/Math.max(1,tickCount-1))));
|
||||
const ticks=labels.map((v,i)=>tickIndices.has(i) ? `<text x="${x(i)}" y="${height-8}" text-anchor="${i===0 ? 'start' : i===labels.length-1 ? 'end' : 'middle'}">${escape(axisLabel(v,14))}</text>` : '').join('');
|
||||
node.innerHTML=pickerMarkup+`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${escape(series.map(s=>s.name).join(', '))}">${grid}${paths}${ticks}</svg><div class="legend">${series.map((s,i)=>`<span><i class="dot" style="background:${colorFor(i)}"></i>${escape(s.name)}${dual ? ` (${i ? 'right' : 'left'} axis)` : ''}</span>`).join('')}</div>`;
|
||||
if (needsPicker) bindLinePicker(node,selection,redraw);
|
||||
watchChart(node,redraw);
|
||||
inspectChart(node,{width,height,left,right,top,bottom,count:labels.length,pointX:x,inspect:i=>({
|
||||
label:labels[i],rows:series.map((s,j)=>({name:s.name,value:s.values[i],format:s.format,color:colorFor(j)})),
|
||||
overlay:`<line class="chart-crosshair" x1="${x(i)}" x2="${x(i)}" y1="${top}" y2="${height-bottom}"/>`+series.map((s,j)=>Number.isFinite(s.values[i]) ? `<circle class="chart-hover-point" cx="${x(i)}" cy="${y(s.values[i],j)}" r="4" style="stroke:${colorFor(j)}"/>` : '').join('')
|
||||
@@ -192,14 +277,16 @@ function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
|
||||
function barChart(selector, values=[], labels=[]) {
|
||||
if (chartTooltip) chartTooltip.hidden=true;
|
||||
const node=$(selector); if (!node) return;
|
||||
node._chartDraw=null;
|
||||
if (!values.some(value=>typeof value === 'number')) { node.innerHTML='<div class="empty">N/A</div>'; return; }
|
||||
const max=Math.max(...values.filter(v=>typeof v === 'number'),1),width=760,height=220,left=16,bottom=45;
|
||||
const max=Math.max(...values.filter(v=>typeof v === 'number'),1),{width,height}=chartLayout(node,true,labels.length*90),left=16,bottom=40;
|
||||
const step=(width-left*2)/Math.max(1,values.length),barWidth=step*.7;
|
||||
node.innerHTML=`<svg class="chart short" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escape(labels.join(', '))}">${values.map((v,i)=>{
|
||||
node.innerHTML=`<svg class="chart short" style="min-width:${width}px" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${escape(labels.join(', '))}">${values.map((v,i)=>{
|
||||
if (typeof v !== 'number') return '';
|
||||
const h=v/max*(height-bottom-15),x=left+i*step;
|
||||
return `<rect class="bar" style="animation-delay:${Math.min(i,12)*22}ms" x="${x}" y="${height-bottom-h}" width="${barWidth}" height="${h}" rx="3"><title>${escape(labels[i])}: ${cell(v)}</title></rect><text x="${x+barWidth/2}" y="${height-20}" text-anchor="middle">${escape(labels[i].slice(0,15))}</text>`;
|
||||
return `<rect class="bar" style="animation-delay:${Math.min(i,12)*22}ms" x="${x}" y="${height-bottom-h}" width="${barWidth}" height="${h}" rx="3"><title>${escape(labels[i])}: ${cell(v)}</title></rect><text x="${x+barWidth/2}" y="${height-16}" text-anchor="middle">${escape(axisLabel(labels[i],Math.floor(step/7)))}</text>`;
|
||||
}).join('')}</svg>`;
|
||||
watchChart(node,()=>barChart(selector,values,labels));
|
||||
inspectChart(node,{width,height,left,right:left,top:0,bottom,count:values.length,pointX:i=>left+step*(i+.5),inspect:i=>({
|
||||
label:labels[i],rows:[{name:'Value',value:values[i],color:seriesColor(0)}],
|
||||
overlay:`<rect class="chart-hover-band" x="${left+i*step}" y="0" width="${step}" height="${height-bottom}"/>`
|
||||
@@ -219,9 +306,29 @@ function renderEngagement(data) {
|
||||
seriesFromDaily('playtime-total',daily,[['total_playtime','Total completed playtime (s)']]);
|
||||
seriesFromDaily('playtime-average',daily,[['average_playtime','Playtime per DAU (s)']]);
|
||||
}
|
||||
function renderHealthReports(reports) {
|
||||
const date=value=>new Intl.DateTimeFormat('en-GB',{timeZone:'UTC',dateStyle:'medium'}).format(new Date(value+'T00:00:00Z'));
|
||||
for (const report of reports) {
|
||||
const node=$(`[data-health-report="${report.periodDays}"]`);if (!node) continue;
|
||||
const covered=report.startDate===report.endDate ? date(report.startDate) : `${date(report.startDate)} – ${date(report.endDate)}`;
|
||||
node.querySelector('[data-health-dates]').textContent=`${report.gameName} · ${covered} · UTC`;
|
||||
node.querySelector('[data-health-body]').innerHTML=report.paragraphs.map(p=>`<p><strong>${cell(p.title,'text')}</strong> ${cell(p.text,'text')}</p>`).join('')+(report.weekdayComparison ? `<p class="health-weekday">${cell(report.weekdayComparison,'text')}</p>` : '');
|
||||
node.querySelector('[data-health-notes]').textContent='';
|
||||
node.querySelector('[data-health-updated]').textContent=`Updated ${format(report.generatedAt,'date')} UTC · Compared with ${date(report.comparisonStart)} – ${date(report.comparisonEnd)}`;
|
||||
}
|
||||
}
|
||||
function renderOverviewAnalysis(reports) {
|
||||
const report=reports.find(r=>r.periodDays===7);
|
||||
if (!report?.executive) return;
|
||||
const date=value=>new Intl.DateTimeFormat('en-GB',{timeZone:'UTC',dateStyle:'medium'}).format(new Date(value+'T00:00:00Z'));
|
||||
$('[data-analysis-dates]').textContent=`${report.gameName} | Trends through ${date(report.endDate)} UTC`;
|
||||
$('[data-overview-analysis]').innerHTML=report.executive.paragraphs.map(p=>`<p><strong>${cell(p.title,'text')}</strong> ${cell(p.text,'text')}</p>`).join('');
|
||||
$('[data-analysis-updated]').textContent=`Seven-day trends versus the preceding week, with latest-day context | Updated ${format(report.generatedAt,'date')} UTC`;
|
||||
}
|
||||
function renderRetention(data) {
|
||||
const node=$('[data-retention]'); if (!node) return;
|
||||
const grid=data.retentionGrid;
|
||||
if (data.weekRetention) lineChart('[data-chart="retention-week"]',data.weekRetention.series,data.weekRetention.labels);
|
||||
node.innerHTML=['Cohort',...grid.columns].map(v=>`<div class="heat-label">${escape(v)}</div>`).join('');
|
||||
grid.rows.forEach(row=>{
|
||||
node.insertAdjacentHTML('beforeend',`<div class="heat-label">${escape(row.name)} · ${row.players}</div>${row.values.map(value=>`<div style="background:rgba(124,140,255,${missing(value) ? .03 : .08+value/100*.7})">${cell(value,'percent')}</div>`).join('')}`);
|
||||
@@ -231,9 +338,15 @@ function renderRetention(data) {
|
||||
let playerData, playerRequest=0;
|
||||
async function loadPlayers() {
|
||||
const ticket=++playerRequest;
|
||||
const data=await api('players',{page:state.page,sort:state.sort,direction:state.direction,segment:state.segment,q:state.q});
|
||||
const data=await api('players',{page:state.page,sort:state.sort,direction:state.direction,segment:state.segment,q:state.q,ranking:$('[data-leaderboard-value]').value || 'level'});
|
||||
if (ticket!==playerRequest) return;
|
||||
playerData=data; fillMetrics(data.metrics);
|
||||
const ranking=$('[data-leaderboard-value]');
|
||||
if (!ranking.options.length) {
|
||||
data.fields.filter(f=>!['robux','playtime','cash'].includes(f.key)).forEach(f=>ranking.add(new Option(f.label,f.key)));
|
||||
ranking.value='level';
|
||||
}
|
||||
$('[data-leaderboards]').innerHTML=data.leaderboards.map(board=>`<article class="card card-pad"><div class="card-title">${cell(board.label,'text')}</div><ol class="fame-list">${board.players.map((p,i)=>`<li><span class="fame-rank">${i+1}</span><div class="avatar-placeholder" role="img" aria-label="Avatar placeholder for ${cell(p.name,'text')}"><span>${cell((p.name || '?').slice(0,2).toUpperCase(),'text')}</span></div><div class="fame-player">${playerLink(p.playerId,p.name,p.robloxUserId)}</div><strong>${cell(p.value,board.format)}</strong></li>`).join('') || '<li class="empty">N/A</li>'}</ol></article>`).join('');
|
||||
const template=$('[data-player-template]'),sort=$('[data-player-sort]');
|
||||
if (!template.options.length) {
|
||||
Object.entries(data.templates).forEach(([key,value])=>template.add(new Option(value.label,key)));
|
||||
@@ -247,12 +360,14 @@ async function loadPlayers() {
|
||||
table('[data-player-rows]',data.playerRows.map(p=>[playerLink(p.playerId,p.name,p.robloxUserId),...fields.map(f=>cell(p[f.key],f.format))]));
|
||||
const page=data.pagination; state.page=page.page;
|
||||
$('[data-page-summary]').textContent=`${page.total.toLocaleString()} matching players · ${page.pageSize} per page`;
|
||||
$('[data-page-number]').textContent=`${page.page} / ${page.pages}`;
|
||||
const pageChooser=$('[data-player-page]');
|
||||
pageChooser.innerHTML=Array.from({length:page.pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${page.pages}</option>`).join('');pageChooser.value=page.page;
|
||||
$('[data-prev]').disabled=page.page<=1; $('[data-next]').disabled=page.page>=page.pages;
|
||||
$('[data-group-definitions]').textContent=Object.entries(data.groupDefinitions).map(([key,value])=>`${key}: ${value}`).join(' · ');
|
||||
}
|
||||
function bindPlayers() {
|
||||
const refresh=()=>{state.page=1;loadPlayers().catch(e=>error(e.message));};
|
||||
$('[data-leaderboard-value]').addEventListener('change',refresh);
|
||||
$('[data-player-template]').addEventListener('change',event=>{
|
||||
state.template=event.target.value; state.sort=playerData.templates[state.template].columns[0];
|
||||
$('[data-player-sort]').value=state.sort; refresh();
|
||||
@@ -265,6 +380,7 @@ function bindPlayers() {
|
||||
}));
|
||||
let searchTimer;
|
||||
$('[data-player-search]').addEventListener('input',event=>{clearTimeout(searchTimer);state.q=event.target.value;searchTimer=setTimeout(refresh,220);});
|
||||
$('[data-player-page]').addEventListener('change',e=>{state.page=Number(e.target.value);loadPlayers().catch(e=>error(e.message));});
|
||||
$('[data-prev]').addEventListener('click',()=>{state.page--;loadPlayers().catch(e=>error(e.message));});
|
||||
$('[data-next]').addEventListener('click',()=>{state.page++;loadPlayers().catch(e=>error(e.message));});
|
||||
}
|
||||
@@ -275,6 +391,58 @@ function bindTabs(tabAttribute,panelAttribute) {
|
||||
$$(`[${panelAttribute}]`).forEach(panel=>panel.classList.toggle('active',panel.getAttribute(panelAttribute)===tab.getAttribute(tabAttribute)));
|
||||
}));
|
||||
}
|
||||
const profileRecords={};
|
||||
let profileRecordsReady=false;
|
||||
function renderRecordPage(kind,data) {
|
||||
const entry=profileRecords[kind];entry.pagination=data.pagination;
|
||||
const records=data.records;
|
||||
if (kind==='snapshots') table('[data-snapshot-rows]',records.map(r=>[cell(r.captured_at,'date'),`<code class="json-value">${cell(r.data,'json')}</code>`]));
|
||||
if (kind==='sessions') renderSessions(records);
|
||||
if (kind==='events') renderEvents(records);
|
||||
if (kind==='purchases') renderSales(records);
|
||||
if (kind==='activities') table('[data-activity-rows]',records.map(r=>[cell(r.name),cell(r.started_at,'date'),cell(r.duration,'duration'),cell(r.step_count),cell(r.outcome),`<code class="json-value">${cell(r.captured_values,'json')}</code>`]));
|
||||
const p=data.pagination,chooser=$(`[data-record-page="${kind}"]`);
|
||||
chooser.innerHTML=Array.from({length:p.pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${p.pages}</option>`).join('');
|
||||
chooser.value=p.page;
|
||||
$(`[data-record-summary="${kind}"]`).textContent=`${p.total.toLocaleString()} records · 20 per page`;
|
||||
$(`[data-record-prev="${kind}"]`).disabled=p.page<=1;
|
||||
$(`[data-record-next="${kind}"]`).disabled=p.page>=p.pages;
|
||||
}
|
||||
async function fetchRecordPage(kind,page) {
|
||||
const entry=profileRecords[kind],ticket=++entry.request;
|
||||
const query=new URLSearchParams({page});
|
||||
for (const value of entry.filters) query.append(kind==='events' ? 'event' : 'product',value);
|
||||
$(`[data-record-prev="${kind}"]`).disabled=true;
|
||||
$(`[data-record-next="${kind}"]`).disabled=true;
|
||||
const data=await fetchJSON(`/api/games/${state.game.id}/players/${profileId}/${kind}?${query}`);
|
||||
if (ticket===entry.request) renderRecordPage(kind,data);
|
||||
}
|
||||
function initProfileRecords(data) {
|
||||
for (const [kind,page] of Object.entries(data.recordPages)) {
|
||||
profileRecords[kind]={filters:new Set(),request:0,pagination:page.pagination};
|
||||
renderRecordPage(kind,page);
|
||||
const refresh=page=>fetchRecordPage(kind,page).catch(e=>error(e.message));
|
||||
$(`[data-record-prev="${kind}"]`).addEventListener('click',()=>refresh(profileRecords[kind].pagination.page-1));
|
||||
$(`[data-record-next="${kind}"]`).addEventListener('click',()=>refresh(profileRecords[kind].pagination.page+1));
|
||||
$(`[data-record-page="${kind}"]`).addEventListener('change',e=>refresh(e.target.value));
|
||||
if (!['events','purchases'].includes(kind)) continue;
|
||||
const options=data.recordFilters[kind],node=$(`[data-record-filter="${kind}"]`);
|
||||
node.innerHTML='<p class="card-note">No selection shows all records.</p>'+options.map(option=>{
|
||||
const name=kind==='events' ? option : option.name,value=kind==='events' ? option : option.id;
|
||||
return `<label class="event-option"><input type="checkbox" value="${cell(value,'text')}"><span>${cell(name,'text')}</span></label>`;
|
||||
}).join('');
|
||||
node.addEventListener('change',e=>{
|
||||
if (!e.target.matches('input')) return;
|
||||
if (e.target.checked) profileRecords[kind].filters.add(e.target.value);
|
||||
else profileRecords[kind].filters.delete(e.target.value);
|
||||
refresh(1);
|
||||
});
|
||||
$(`[data-record-filter-all="${kind}"]`).addEventListener('click',()=>{
|
||||
profileRecords[kind].filters.clear();node.querySelectorAll('input').forEach(input=>input.checked=false);refresh(1);
|
||||
});
|
||||
}
|
||||
profileRecordsReady=true;
|
||||
}
|
||||
let profileId=params.get('player_id'),profileRequest=0;
|
||||
async function loadProfile() {
|
||||
const ticket=++profileRequest;
|
||||
@@ -301,10 +469,10 @@ async function loadProfile() {
|
||||
chooser.value=selected;
|
||||
second.value=secondary;
|
||||
table('[data-latest-value-rows]',data.fields.map(f=>[`${escape(f.label)}<div class="card-note">${escape(f.source)}</div>`,cell(p[f.key],f.format)]));
|
||||
table('[data-snapshot-rows]',data.snapshots.map(r=>[cell(r.captured_at,'date'),`<code class="json-value">${cell(r.data,'json')}</code>`]));
|
||||
lineChart('[data-chart="profile"]',data.series,data.labels,{separateAxes:true});
|
||||
renderSessions(data.sessions); renderEvents(data.events); renderSales(data.sales || []);
|
||||
table('[data-activity-rows]',data.activities.map(r=>[cell(r.name),cell(r.started_at,'date'),cell(r.duration,'duration'),cell(r.step_count),cell(r.prompt_count),cell(r.outcome),`<code class="json-value">${cell(r.captured_values,'json')}</code>`]));
|
||||
if (!profileRecordsReady) initProfileRecords(data);
|
||||
table('[data-owned-pass-rows]',data.ownedGamepasses.map(r=>[cell(r.name),cell(r.roblox_gamepass_id,'text'),cell(r.purchased_at,'date')]));
|
||||
table('[data-owned-product-rows]',data.purchasedProducts.map(r=>[cell(r.name),cell(r.quantity),cell(r.robux,'robux'),cell(r.first_purchase,'date'),cell(r.last_purchase,'date')]));
|
||||
}
|
||||
|
||||
let activityData,activityId,toastTimer;
|
||||
@@ -326,7 +494,7 @@ async function loadActivities() {
|
||||
if (!data.definition) return;
|
||||
chooser.value=data.definition.id; activityId=data.definition.id;
|
||||
$('[data-activity-journey]').innerHTML=data.journey.map((step,i)=>`${i ? '<span class="flow-connector"></span>' : ''}<div class="flow-node"><span class="flow-kind">${cell(step.label)}</span><code>${cell(step.event)}</code><strong class="stage-reach">${cell(step.reachPercent,'percent')} reach</strong><small>${cell(step.runs)} runs</small>${i ? `<small class="stage-drop">${cell(step.dropoffPercent,'percent')} drop from prior stage</small>` : '<small>All activity starts</small>'}</div>`).join('');
|
||||
table('[data-stage-rows]',data.journey.map((step,i)=>[`${i+1}. ${cell(step.event)}`,cell(step.runs),cell(step.reachPercent,'percent'),cell(step.conversionPercent,'percent'),cell(step.dropoffPercent,'percent'),cell(step.droppedRuns),cell(step.unknownRuns)]));
|
||||
table('[data-stage-rows]',data.journey.map((step,i)=>[`${i+1}. ${cell(step.event)}`,cell(step.runs),cell(step.reachPercent,'percent'),cell(step.conversionPercent,'percent'),cell(step.dropoffPercent,'percent'),cell(step.droppedRuns)]));
|
||||
lineChart('[data-chart="trend"]',data.series,data.labels);
|
||||
table('[data-captured-rows]',data.captured.map(r=>[cell(r.key),cell(r.summary),cell(r.value)]));
|
||||
table('[data-outcome-rows]',data.outcomes.map(r=>[cell(r.outcome),cell(r.runs),cell(r.share,'percent'),cell(r.average,'duration'),cell(r.median,'duration')]));
|
||||
@@ -366,25 +534,27 @@ function renderGamepasses(data) {
|
||||
fillMetrics(data.metrics);
|
||||
const passes=data.gamepasses, colors=[seriesColor(0),seriesColor(1)];
|
||||
const node=$('[data-chart="ownership"]');
|
||||
node._chartDraw=null;
|
||||
if (!passes.length) node.innerHTML='<div class="empty">N/A — no gamepasses reported</div>';
|
||||
else {
|
||||
const width=760,height=260,left=40,top=18,bottom=45,plotHeight=height-top-bottom;
|
||||
const {width,height}=chartLayout(node,false,passes.length*110),left=40,top=18,bottom=45,plotHeight=height-top-bottom;
|
||||
const step=(width-left-15)/passes.length,barWidth=Math.min(42,step*.28);
|
||||
const grid=[0,25,50,75,100].map(v=>{const y=top+(100-v)/100*plotHeight;return `<line class="gridline" x1="${left}" y1="${y}" x2="745" y2="${y}"/><text x="33" y="${y+3}" text-anchor="end">${v}%</text>`;}).join('');
|
||||
const grid=[0,25,50,75,100].map(v=>{const y=top+(100-v)/100*plotHeight;return `<line class="gridline" x1="${left}" y1="${y}" x2="${width-15}" y2="${y}"/><text x="33" y="${y+3}" text-anchor="end">${v}%</text>`;}).join('');
|
||||
const bars=passes.map((pass,i)=>{
|
||||
const x=left+step*i+step/2;
|
||||
return ['playerPercent','payerPercent'].map((key,j)=>{
|
||||
const value=pass[key],xx=x+(j ? 3 : -barWidth-3);
|
||||
return missing(value) ? `<text x="${xx+barWidth/2}" y="${height-bottom-7}" text-anchor="middle">N/A</text>` : `<rect class="bar" style="fill:${colors[j]};animation-delay:${i*25}ms" x="${xx}" y="${top+(100-value)/100*plotHeight}" width="${barWidth}" height="${value/100*plotHeight}" rx="3"><title>${cell(pass.name)} · ${j ? 'Paying players' : 'All players'}: ${cell(value,'percent')}</title></rect>`;
|
||||
}).join('')+`<text x="${x}" y="${height-17}" text-anchor="middle">${cell(pass.name)}</text>`;
|
||||
}).join('')+`<text x="${x}" y="${height-17}" text-anchor="middle">${cell(axisLabel(pass.name,Math.floor(step/7)),'text')}</text>`;
|
||||
}).join('');
|
||||
node.innerHTML=`<svg class="chart" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Gamepass ownership percentages">${grid}${bars}</svg><div class="legend"><span><i class="dot" style="background:${colors[0]}"></i>All players</span><span><i class="dot" style="background:${colors[1]}"></i>Paying players</span></div>`;
|
||||
node.innerHTML=`<svg class="chart" style="min-width:${width}px" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Gamepass ownership percentages">${grid}${bars}</svg><div class="legend"><span><i class="dot" style="background:${colors[0]}"></i>All players</span><span><i class="dot" style="background:${colors[1]}"></i>Paying players</span></div>`;
|
||||
watchChart(node,()=>renderGamepasses(data));
|
||||
inspectChart(node,{width,height,left,right:15,top,bottom,count:passes.length,pointX:i=>left+step*(i+.5),inspect:i=>({
|
||||
label:passes[i].name,rows:[{name:'All players',value:passes[i].playerPercent,format:'percent',color:colors[0]},{name:'Paying players',value:passes[i].payerPercent,format:'percent',color:colors[1]}],
|
||||
overlay:`<rect class="chart-hover-band" x="${left+i*step}" y="${top}" width="${step}" height="${plotHeight}"/>`
|
||||
})});
|
||||
}
|
||||
table('[data-gamepass-rows]',passes.map(p=>[cell(p.name),cell(p.roblox_gamepass_id,'text'),cell(p.owners),cell(p.playerPercent,'percent'),cell(p.paying_owners),cell(p.payerPercent,'percent'),cell(p.coverage,'percent'),cell(p.newest_check,'date')]));
|
||||
table('[data-gamepass-rows]',passes.map(p=>[cell(p.name),cell(p.roblox_gamepass_id,'text'),cell(p.owners),cell(p.playerPercent,'percent'),cell(p.paying_owners),cell(p.payerPercent,'percent'),cell(p.newest_check,'date')]));
|
||||
}
|
||||
|
||||
let eventChartData,eventSelection=new Set();
|
||||
@@ -395,7 +565,7 @@ function drawEventChart() {
|
||||
if (series.length) lineChart('[data-chart="trend"]',series,eventChartData.labels);
|
||||
else {
|
||||
if (chartTooltip) chartTooltip.hidden=true;
|
||||
$('[data-chart="trend"]').innerHTML='<div class="empty">Select events to graph.</div>';
|
||||
const node=$('[data-chart="trend"]');node._chartDraw=null;node.innerHTML='<div class="empty">Select events to graph.</div>';
|
||||
}
|
||||
try { localStorage.setItem(`eventChartSelection:${state.game.id}`,JSON.stringify([...eventSelection])); } catch {}
|
||||
}
|
||||
@@ -429,6 +599,22 @@ function bindEventChart() {
|
||||
});
|
||||
}
|
||||
|
||||
let qualityPagination={page:1,pages:1},qualityRequest=0;
|
||||
async function loadQualityLog(page=1) {
|
||||
const ticket=++qualityRequest,filter=$('[data-quality-filter]');
|
||||
const data=await api('data-quality',{page,issue:filter.value || 'all'});
|
||||
if (ticket!==qualityRequest) return;
|
||||
const selected=filter.value || 'all';
|
||||
filter.innerHTML='<option value="all">All issues</option>'+data.categories.map(c=>`<option value="${cell(c.issue,'text')}">${cell(c.title,'text')} (${cell(c.count)})</option>`).join('');
|
||||
filter.value=selected;
|
||||
table('[data-quality-rows]',data.records.map(r=>[cell(r.title),cell(r.record_type),cell(r.record_id,'text'),cell(r.details),cell(r.observed_at,'date')]));
|
||||
const p=data.pagination;qualityPagination=p;
|
||||
$('[data-quality-summary]').textContent=`${p.total.toLocaleString()} diagnostic entries · 20 per page`;
|
||||
const chooser=$('[data-quality-page]');
|
||||
chooser.innerHTML=Array.from({length:p.pages},(_,i)=>`<option value="${i+1}">Page ${i+1} of ${p.pages}</option>`).join('');chooser.value=p.page;
|
||||
$('[data-quality-prev]').disabled=p.page<=1;$('[data-quality-next]').disabled=p.page>=p.pages;
|
||||
}
|
||||
|
||||
async function loadCurrentPage() {
|
||||
if (chartTooltip) chartTooltip.hidden=true;
|
||||
clearError();
|
||||
@@ -437,10 +623,16 @@ async function loadCurrentPage() {
|
||||
if (page==='player-info') return loadProfile();
|
||||
if (page==='activities') return loadActivities();
|
||||
if (page==='monetization') return renderMonetization(await api('monetization'));
|
||||
if (page==='prompts') {
|
||||
const data=await api('prompts',{source:$('[data-prompt-source]').value || 'all'});fillMetrics(data.metrics);lineChart('[data-chart="trend"]',data.series,data.labels);
|
||||
lineChart('[data-chart="prompt-sources"]',data.sourceSeries,data.labels);
|
||||
table('[data-prompt-source-rows]',data.sourceComparison.map(r=>[cell(r.name),cell(r.prompts),cell(r.bought),cell(r.closed),cell(r.conversion,'percent'),cell(r.buy_time,'duration'),cell(r.close_time,'duration')]));
|
||||
return table('[data-prompt-product-rows]',data.products.map(r=>[cell(r.name),cell(r.prompts),cell(r.bought),cell(r.closed),cell(r.conversion,'percent'),cell(r.buy_time,'duration'),cell(r.close_time,'duration')]));
|
||||
}
|
||||
if (page==='gamepasses') return renderGamepasses(await api('gamepasses'));
|
||||
if (page==='settings') {
|
||||
$$('[data-game-field]').forEach(input=>input.value=format(state.game[input.dataset.gameField],'text'));
|
||||
return;
|
||||
return loadQualityLog();
|
||||
}
|
||||
const data=await api(page,page==='sessions' ? {segment:$('[data-session-group]').value} : {});
|
||||
fillMetrics(data.metrics);
|
||||
@@ -452,6 +644,7 @@ async function loadCurrentPage() {
|
||||
if (data.eventSummary) table('[data-event-summary]',data.eventSummary.map(r=>[cell(r.name),cell(r.categories.join(', ')),cell(r.total),cell(r.average,'decimal'),cell(r.players),cell(r.latest,'date')]));
|
||||
if (data.retentionGrid) renderRetention(data);
|
||||
if (data.engagement) renderEngagement(data.engagement);
|
||||
if (data.healthReports) {renderOverviewAnalysis(data.healthReports);renderHealthReports(data.healthReports);}
|
||||
if (data.rootMetrics) {renderRoot(data.rootMetrics.rows);lineChart('[data-chart="ccu"]',data.rootMetrics.series,data.rootMetrics.labels);}
|
||||
}
|
||||
async function boot() {
|
||||
@@ -487,7 +680,14 @@ async function boot() {
|
||||
});
|
||||
}
|
||||
if (document.body.dataset.page==='players') bindPlayers();
|
||||
if (document.body.dataset.page==='settings') {
|
||||
$('[data-quality-filter]').addEventListener('change',()=>loadQualityLog(1).catch(e=>error(e.message)));
|
||||
$('[data-quality-prev]').addEventListener('click',()=>loadQualityLog(qualityPagination.page-1).catch(e=>error(e.message)));
|
||||
$('[data-quality-next]').addEventListener('click',()=>loadQualityLog(qualityPagination.page+1).catch(e=>error(e.message)));
|
||||
$('[data-quality-page]').addEventListener('change',e=>loadQualityLog(e.target.value).catch(e=>error(e.message)));
|
||||
}
|
||||
if (document.body.dataset.page==='events') bindEventChart();
|
||||
if (document.body.dataset.page==='prompts') $('[data-prompt-source]').addEventListener('change',()=>loadCurrentPage().catch(e=>error(e.message)));
|
||||
if (document.body.dataset.page==='player-info') {
|
||||
bindTabs('data-profile-tab','data-profile-panel');
|
||||
$('[data-profile-value]').addEventListener('change',()=>loadProfile().catch(e=>error(e.message)));
|
||||
|
||||
56
mockups/data_quality.py
Normal file
56
mockups/data_quality.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Paginated diagnostic log derived from original stored records; nothing deleted."""
|
||||
import math
|
||||
|
||||
CATEGORIES={
|
||||
'prompt_outcome':'Product prompt outcome missing','prompt_source':'Product prompt source missing',
|
||||
'session_end':'Stale session end missing','activity_result':'Activity result missing',
|
||||
'activity_stages':'Activity stages missing','metric_value':'Game metric value missing',
|
||||
'sale_player':'Sale attribution missing','ownership_check':'Gamepass ownership check missing',
|
||||
'purchase_date':'Gamepass purchase date missing'}
|
||||
|
||||
ISSUES='''WITH issues AS (
|
||||
SELECT 'prompt_outcome' issue,'Product prompt outcome missing' title,'interaction' record_type,id::text record_id,
|
||||
started_at observed_at,'Prompt has no resolved buy/close result.' details FROM interaction_events
|
||||
WHERE game_id=%(gid)s AND kind='product_prompt' AND (outcome IS NULL OR ended_at IS NULL)
|
||||
UNION ALL SELECT 'prompt_source','Product prompt source missing','interaction',id::text,started_at,
|
||||
'Source is unknown; excluded from game/player prompt comparisons.' FROM interaction_events
|
||||
WHERE game_id=%(gid)s AND kind='product_prompt' AND trigger_source='unknown'
|
||||
UNION ALL SELECT 'session_end','Stale session end missing','session',id::text,started_at,
|
||||
'No end report after six hours; completed duration remains unknown.' FROM sessions
|
||||
WHERE game_id=%(gid)s AND ended_at IS NULL AND started_at<=now()-interval '6 hours'
|
||||
UNION ALL SELECT 'activity_result','Activity result missing','activity',id::text,started_at,
|
||||
'End time or outcome is missing; excluded from activity outcome analytics.' FROM activity_runs
|
||||
WHERE game_id=%(gid)s AND (ended_at IS NULL OR outcome IS NULL)
|
||||
UNION ALL SELECT 'activity_stages','Activity stages missing','activity',id::text,started_at,
|
||||
'Ordered stage observations are missing; excluded from stage funnels.' FROM activity_runs
|
||||
WHERE game_id=%(gid)s AND stage_progress IS NULL
|
||||
UNION ALL SELECT 'metric_value','Game metric value missing','metric',id::text,recorded_at,
|
||||
category||' / '||name||' has no reported value.' FROM game_metrics WHERE game_id=%(gid)s AND value IS NULL
|
||||
UNION ALL SELECT 'sale_player','Sale attribution missing','sale',id::text,purchased_at,
|
||||
'Positive-price sale has no player reference.' FROM product_sales
|
||||
WHERE game_id=%(gid)s AND player_id IS NULL AND unit_price_robux>0
|
||||
UNION ALL SELECT 'ownership_check','Gamepass ownership check missing','player / pass',p.id::text||' / '||gp.id::text,
|
||||
NULL::timestamptz,'Ownership not reported for '||coalesce(gp.name,gp.id::text)||'.' FROM players p
|
||||
JOIN gamepasses gp ON gp.game_id=p.game_id LEFT JOIN gamepass_ownership o
|
||||
ON o.game_id=p.game_id AND o.player_id=p.id AND o.gamepass_id=gp.id
|
||||
WHERE p.game_id=%(gid)s AND o.player_id IS NULL
|
||||
UNION ALL SELECT 'purchase_date','Gamepass purchase date missing','player / pass',o.player_id::text||' / '||o.gamepass_id::text,
|
||||
o.checked_at,'Ownership is known, but purchase date is not reported.' FROM gamepass_ownership o
|
||||
WHERE o.game_id=%(gid)s AND o.is_owned AND o.purchased_at IS NULL
|
||||
) '''
|
||||
|
||||
|
||||
def quality_log(conn,gid,params):
|
||||
from sample_api import integer,ApiError
|
||||
scope={'gid':gid}
|
||||
counted=conn.execute(ISSUES+'SELECT issue,count(*)::int count FROM issues GROUP BY issue',scope).fetchall()
|
||||
counts={r['issue']:r['count'] for r in counted}
|
||||
categories=[{'issue':key,'title':title,'count':counts.get(key,0)} for key,title in sorted(CATEGORIES.items(),key=lambda item:item[1])]
|
||||
issue=params.get('issue',['all'])[0]
|
||||
if issue!='all' and issue not in CATEGORIES: raise ApiError(400,'Unknown data-quality category.')
|
||||
scope['issue']=issue
|
||||
total=conn.execute(ISSUES+"SELECT count(*) total FROM issues WHERE %(issue)s='all' OR issue=%(issue)s",scope).fetchone()['total']
|
||||
pages=max(1,math.ceil(total/20));page=min(integer(params,'page',1),pages)
|
||||
scope['offset']=(page-1)*20
|
||||
records=conn.execute(ISSUES+"SELECT * FROM issues WHERE %(issue)s='all' OR issue=%(issue)s ORDER BY observed_at DESC NULLS LAST,issue,record_id LIMIT 20 OFFSET %(offset)s",scope).fetchall()
|
||||
return {'records':records,'categories':categories,'pagination':{'total':total,'page':page,'pages':pages,'pageSize':20}}
|
||||
30
mockups/db/004_player_records_and_interactions.sql
Normal file
30
mockups/db/004_player_records_and_interactions.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
SET search_path TO analytics, public;
|
||||
ALTER TABLE gamepass_ownership ADD COLUMN purchased_at timestamptz;
|
||||
CREATE TABLE interaction_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
game_id bigint NOT NULL REFERENCES games(id),
|
||||
kind text NOT NULL,
|
||||
report_key text NOT NULL,
|
||||
player_id bigint,
|
||||
session_id bigint,
|
||||
product_id bigint,
|
||||
receipt_id text,
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz,
|
||||
outcome text,
|
||||
properties jsonb NOT NULL DEFAULT '{}',
|
||||
UNIQUE(game_id,kind,report_key),
|
||||
FOREIGN KEY(game_id,player_id) REFERENCES players(game_id,id),
|
||||
FOREIGN KEY(game_id,player_id,session_id) REFERENCES sessions(game_id,player_id,id),
|
||||
FOREIGN KEY(game_id,product_id) REFERENCES products(game_id,id),
|
||||
FOREIGN KEY(game_id,receipt_id) REFERENCES product_sales(game_id,receipt_id),
|
||||
CHECK(session_id IS NULL OR player_id IS NOT NULL),
|
||||
CHECK(ended_at IS NULL OR ended_at>=started_at),
|
||||
CHECK(kind<>'product_prompt' OR product_id IS NOT NULL),
|
||||
CHECK(kind<>'product_prompt' OR outcome IS NULL OR outcome IN ('bought','closed')),
|
||||
CHECK(outcome IS NULL OR ended_at IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX interactions_game_time ON interaction_events(game_id,kind,started_at DESC);
|
||||
CREATE INDEX interactions_player_time ON interaction_events(game_id,player_id,started_at DESC);
|
||||
CREATE INDEX interactions_session ON interaction_events(game_id,player_id,session_id);
|
||||
INSERT INTO schema_migrations(version) VALUES('004_player_records_and_interactions');
|
||||
5
mockups/db/005_prompt_sources.sql
Normal file
5
mockups/db/005_prompt_sources.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
SET search_path TO analytics, public;
|
||||
ALTER TABLE interaction_events ADD COLUMN trigger_source text NOT NULL DEFAULT 'unknown';
|
||||
ALTER TABLE interaction_events ADD CONSTRAINT interaction_trigger_source CHECK(trigger_source IN ('game','player','unknown'));
|
||||
CREATE INDEX prompt_source_time ON interaction_events(game_id,kind,trigger_source,started_at DESC);
|
||||
INSERT INTO schema_migrations(version) VALUES('005_prompt_sources');
|
||||
10
mockups/db/006_daily_health_reports.sql
Normal file
10
mockups/db/006_daily_health_reports.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
SET search_path TO analytics, public;
|
||||
CREATE TABLE daily_health_reports (
|
||||
game_id bigint NOT NULL REFERENCES games(id),
|
||||
report_date date NOT NULL,
|
||||
period_days integer NOT NULL CHECK(period_days IN (1,7)),
|
||||
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||
payload jsonb NOT NULL,
|
||||
PRIMARY KEY(game_id,report_date,period_days)
|
||||
);
|
||||
INSERT INTO schema_migrations(version) VALUES('006_daily_health_reports');
|
||||
@@ -9,7 +9,7 @@ ON CONFLICT(sample_key) DO NOTHING;
|
||||
|
||||
INSERT INTO activity_definitions(game_id,key,name,start_event,end_event,configuration)
|
||||
SELECT g.id,d.key,d.name,d.start_event,d.end_event,d.config::jsonb FROM games g CROSS JOIN (VALUES
|
||||
('shop','Shop visit','shop_opened','shop_closed','{"steps":["product_viewed","buy_prompt_shown","purchase_completed"],"fields":["product_id","purchase_amount","currency_balance"]}'),
|
||||
('shop','Shop visit','shop_opened','shop_closed','{"steps":["product_viewed"],"fields":["currency_balance"]}'),
|
||||
('minigame','Minigame run','minigame_started','minigame_ended','{"steps":["round_started","checkpoint_reached","round_ended"],"fields":["score","rounds"]}'),
|
||||
('quest','Quest attempt','quest_started','quest_ended','{"steps":["objective_started","objective_completed"],"fields":["quest_id","reward"]}')
|
||||
) d(key,name,start_event,end_event,config) WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1');
|
||||
|
||||
35
mockups/db/seed_interactions.sql
Normal file
35
mockups/db/seed_interactions.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
SET search_path TO analytics, public;
|
||||
-- Only enrich the bundled synthetic histories; unknown real purchase dates stay NULL.
|
||||
UPDATE gamepass_ownership o SET purchased_at=p.first_seen+(p.last_seen-p.first_seen)*((p.id*31+o.gamepass_id)%100/100.0)
|
||||
FROM players p,games g WHERE p.id=o.player_id AND p.game_id=o.game_id AND g.id=o.game_id
|
||||
AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND o.is_owned AND o.purchased_at IS NULL;
|
||||
INSERT INTO interaction_events(game_id,kind,report_key,player_id,session_id,product_id,receipt_id,started_at,ended_at,outcome,properties)
|
||||
SELECT s.game_id,'product_prompt','receipt-'||s.receipt_id,s.player_id,s.session_id,s.product_id,s.receipt_id,
|
||||
greatest(se.started_at,s.purchased_at-(3+s.id%29)*interval '1 second'),s.purchased_at,'bought','{"synthetic":true}'
|
||||
FROM product_sales s JOIN sessions se ON se.game_id=s.game_id AND se.id=s.session_id JOIN games g ON g.id=s.game_id
|
||||
WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO interaction_events(game_id,kind,report_key,player_id,session_id,product_id,started_at,ended_at,outcome,properties)
|
||||
SELECT s.game_id,'product_prompt','closed-session-'||s.id,s.player_id,s.id,p.id,
|
||||
s.started_at+(s.ended_at-s.started_at)*.3,
|
||||
CASE WHEN s.id%97=0 THEN NULL ELSE least(s.ended_at,s.started_at+(s.ended_at-s.started_at)*.3+(1+s.id%21)*interval '1 second') END,
|
||||
CASE WHEN s.id%97=0 THEN NULL ELSE 'closed' END,'{"synthetic":true}'
|
||||
FROM sessions s JOIN games g ON g.id=s.game_id JOIN products p ON p.game_id=s.game_id
|
||||
AND p.roblox_product_id=8000000+s.game_id*100+1+s.id%4
|
||||
WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND s.ended_at-s.started_at>interval '1 minute' AND s.id%3=0
|
||||
ON CONFLICT DO NOTHING;
|
||||
-- Product prompt telemetry is separate from the longer shop/activity journey.
|
||||
-- Source labels below are synthetic examples, never inferred for real reports.
|
||||
UPDATE interaction_events i SET trigger_source=CASE WHEN i.id%100<5 THEN 'unknown'
|
||||
WHEN i.outcome='bought' AND i.id%100<85 THEN 'player'
|
||||
WHEN i.outcome IS DISTINCT FROM 'bought' AND i.id%100<30 THEN 'player' ELSE 'game' END,
|
||||
properties=i.properties||'{"sourceSeeded":true}'::jsonb
|
||||
FROM games g WHERE i.game_id=g.id AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1')
|
||||
AND i.properties->>'synthetic'='true' AND NOT(i.properties ? 'sourceSeeded');
|
||||
UPDATE activity_runs a SET prompt_count=0,outcome=CASE WHEN outcome='purchased' THEN 'closed' ELSE outcome END,
|
||||
step_count=CASE WHEN stage_progress ? 'product_viewed' THEN 1 ELSE 0 END,
|
||||
captured_values=captured_values-'product_id'-'purchase_amount',
|
||||
stage_progress=(SELECT coalesce(jsonb_agg(e),'[]'::jsonb) FROM jsonb_array_elements(a.stage_progress) e WHERE e NOT IN ('"buy_prompt_shown"'::jsonb,'"purchase_completed"'::jsonb))
|
||||
FROM activity_definitions d,games g WHERE d.id=a.definition_id AND d.game_id=a.game_id AND g.id=a.game_id
|
||||
AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND d.key='shop';
|
||||
UPDATE activity_definitions d SET configuration=jsonb_build_object('steps',jsonb_build_array('product_viewed'),'fields',jsonb_build_array('currency_balance'))
|
||||
FROM games g WHERE d.game_id=g.id AND g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1') AND d.key='shop';
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gamepasses — Statboard</title><link rel="stylesheet" href="mockup.css"></head>
|
||||
<body data-page="gamepasses"><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"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Sessions</a><a class="" href="retention-mockup.html"><span class="nav-icon">↗</span>Retention</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="activities-mockup.html"><span class="nav-icon">⟿</span>Activity sessions</a><a class="active" href="monetization-mockup.html"><span class="nav-icon">◇</span>Monetization</a><a class="" href="settings-mockup.html"><span class="nav-icon">⚙</span>Project settings</a></nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">Gamepasses</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><div class="page-heading"><div><h1>Gamepasses</h1><div class="subtitle">Ownership among all tracked players and among lifetime paying players.</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><nav class="tabs"><a class="" href="monetization-mockup.html">Revenue</a><a class="active" href="gamepasses-mockup.html">Gamepasses</a></nav><div class="grid metrics"><article class="card card-pad metric-card"><div class="metric-label">Gamepasses</div><div class="metric-value" data-metric="gamepasses" data-format="number">N/A</div><div class="metric-foot">Passes in the game catalog</div></article><article class="card card-pad metric-card"><div class="metric-label">Tracked players</div><div class="metric-value" data-metric="trackedPlayers" data-format="number">N/A</div><div class="metric-foot">All tracked players in this game</div></article><article class="card card-pad metric-card"><div class="metric-label">Paying players</div><div class="metric-value" data-metric="lifetimePayers" data-format="number">N/A</div><div class="metric-foot">Players with a recorded positive-price purchase</div></article></div><article class="card"><div class="card-head"><div><div class="card-title">Gamepass ownership</div><div class="card-note">Share of all players and share of paying players · incomplete ownership reports display N/A</div></div></div><div class="chart-wrap" data-chart="ownership"><div class="empty">N/A</div></div></article><div class="section-gap"><article class="card"><div class="card-head"><div><div class="card-title">Ownership by gamepass</div><div class="card-note">Ownership is reported explicitly. Missing checks are unknown; purchases alone do not establish ownership.</div></div></div><div class="table-wrap"><table><thead><tr><th>Gamepass</th><th>Roblox pass ID</th><th>Owners</th><th>% of all players</th><th>Paying owners</th><th>% of paying players</th><th>Reporting coverage</th><th>Last checked (UTC)</th></tr></thead><tbody data-gamepass-rows><tr><td colspan="8" class="empty">N/A</td></tr></tbody></table></div></article></div></section></main></div><nav class="mobile-nav"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Analytics</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="settings-mockup.html"><span class="nav-icon">···</span>More</a></nav><script src="mockup.js"></script></body></html>
|
||||
<body data-page="gamepasses"><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"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Sessions</a><a class="" href="retention-mockup.html"><span class="nav-icon">↗</span>Retention</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="activities-mockup.html"><span class="nav-icon">⟿</span>Activity sessions</a><a class="active" href="monetization-mockup.html"><span class="nav-icon">◇</span>Monetization</a><a class="" href="settings-mockup.html"><span class="nav-icon">⚙</span>Project settings</a></nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">Gamepasses</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><div class="page-heading"><div><h1>Gamepasses</h1><div class="subtitle">Ownership among all tracked players and among lifetime paying players.</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><nav class="tabs"><a class="" href="monetization-mockup.html">Revenue</a><a class="" href="prompts-mockup.html">Product prompts</a><a class="active" href="gamepasses-mockup.html">Gamepasses</a></nav><div class="grid metrics"><article class="card card-pad metric-card"><div class="metric-label">Gamepasses</div><div class="metric-value" data-metric="gamepasses" data-format="number">N/A</div><div class="metric-foot">Passes in the game catalog</div></article><article class="card card-pad metric-card"><div class="metric-label">Tracked players</div><div class="metric-value" data-metric="trackedPlayers" data-format="number">N/A</div><div class="metric-foot">All tracked players in this game</div></article><article class="card card-pad metric-card"><div class="metric-label">Paying players</div><div class="metric-value" data-metric="lifetimePayers" data-format="number">N/A</div><div class="metric-foot">Players with a recorded positive-price purchase</div></article></div><article class="card"><div class="card-head"><div><div class="card-title">Gamepass ownership</div><div class="card-note">Share of all players and share of paying players · reported ownership</div></div></div><div class="chart-wrap" data-chart="ownership"><div class="empty">N/A</div></div></article><div class="section-gap"><article class="card"><div class="card-head"><div><div class="card-title">Ownership by gamepass</div><div class="card-note">Reported ownership across tracked players.</div></div></div><div class="table-wrap"><table><thead><tr><th>Gamepass</th><th>Roblox pass ID</th><th>Owners</th><th>% of all players</th><th>Paying owners</th><th>% of paying players</th><th>Last checked (UTC)</th></tr></thead><tbody data-gamepass-rows><tr><td colspan="7" class="empty">N/A</td></tr></tbody></table></div></article></div></section></main></div><nav class="mobile-nav"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Analytics</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="settings-mockup.html"><span class="nav-icon">···</span>More</a></nav><script src="mockup.js"></script></body></html>
|
||||
122
mockups/health_reports.py
Normal file
122
mockups/health_reports.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Daily cached sample narratives computed from each game's actual stored metrics.
|
||||
|
||||
This prototype uses deterministic text, not an AI provider. Completed UTC windows
|
||||
and structured evidence can later be supplied to the production report generator.
|
||||
"""
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import logging
|
||||
import threading
|
||||
from psycopg.types.json import Jsonb
|
||||
from database import connect
|
||||
|
||||
REPORT_LOCK=threading.Lock()
|
||||
|
||||
|
||||
def window_metrics(conn,gid,start,end):
|
||||
scope={'gid':gid,'start':start,'end':end}
|
||||
session=conn.execute('''SELECT count(*)::int sessions,count(ended_at)::int completed,
|
||||
avg(extract(epoch FROM ended_at-started_at))::double precision average_session
|
||||
FROM sessions WHERE game_id=%(gid)s AND started_at>=%(start)s::date AND started_at<%(end)s::date''',scope).fetchone()
|
||||
audience=conn.execute('''WITH active AS(SELECT DISTINCT player_id FROM player_active_days
|
||||
WHERE game_id=%(gid)s AND active_day>=%(start)s::date AND active_day<%(end)s::date)
|
||||
SELECT count(*)::int active_players,count(*) FILTER(WHERE p.first_seen>=%(start)s::date)::int new_players,
|
||||
count(*) FILTER(WHERE p.first_seen<%(start)s::date)::int returning_players FROM active a
|
||||
JOIN players p ON p.game_id=%(gid)s AND p.id=a.player_id''',scope).fetchone()
|
||||
dau=conn.execute('''SELECT count(*)::double precision/(%(end)s::date-%(start)s::date) average_dau
|
||||
FROM player_active_days WHERE game_id=%(gid)s AND active_day>=%(start)s::date AND active_day<%(end)s::date''',scope).fetchone()
|
||||
playtime=conn.execute('''SELECT sum(playtime)::double precision total_playtime FROM session_day_playtime
|
||||
WHERE game_id=%(gid)s AND active_day>=%(start)s::date AND active_day<%(end)s::date''',scope).fetchone()
|
||||
ccu=conn.execute('''SELECT avg(value)::double precision average_ccu,max(value)::double precision peak_ccu,
|
||||
count(value)::int known_ccu,count(*)::int reported_ccu FROM game_metrics WHERE game_id=%(gid)s
|
||||
AND category='ccu' AND name='concurrent_players' AND recorded_at>=%(start)s::date AND recorded_at<%(end)s::date''',scope).fetchone()
|
||||
revenue=conn.execute('''SELECT coalesce(sum(unit_price_robux*quantity),0)::double precision robux,
|
||||
count(DISTINCT player_id) FILTER(WHERE unit_price_robux>0)::int payers,
|
||||
count(*) FILTER(WHERE player_id IS NULL AND unit_price_robux>0)::int unattributed
|
||||
FROM product_sales WHERE game_id=%(gid)s AND purchased_at>=%(start)s::date AND purchased_at<%(end)s::date''',scope).fetchone()
|
||||
retention=conn.execute('''SELECT count(*)::int retention_cohort,
|
||||
count(*) FILTER(WHERE EXISTS(SELECT 1 FROM sessions s WHERE s.game_id=p.game_id AND s.player_id=p.id
|
||||
AND s.started_at>=(p.first_seen AT TIME ZONE 'UTC')::date+1
|
||||
AND s.started_at<(p.first_seen AT TIME ZONE 'UTC')::date+2))::int retained
|
||||
FROM players p WHERE game_id=%(gid)s AND first_seen>=%(start)s::date-1 AND first_seen<%(end)s::date-1''',scope).fetchone()
|
||||
first=conn.execute('''WITH firsts AS(SELECT DISTINCT ON(player_id) started_at,ended_at FROM sessions
|
||||
WHERE game_id=%(gid)s ORDER BY player_id,started_at,id)
|
||||
SELECT count(ended_at)::int first_known,count(*) FILTER(WHERE ended_at-started_at>=interval '5 minutes')::int first_five
|
||||
FROM firsts WHERE started_at>=%(start)s::date AND started_at<%(end)s::date''',scope).fetchone()
|
||||
result=dict(session)|dict(audience)|dict(dau)|dict(playtime)|dict(ccu)|dict(revenue)|dict(retention)|dict(first)
|
||||
result['retention']=100*result['retained']/result['retention_cohort'] if result['retention_cohort'] else None
|
||||
result['first_five_rate']=100*result['first_five']/result['first_known'] if result['first_known'] else None
|
||||
result['payer_conversion']=100*result['payers']/result['active_players'] if result['active_players'] and not result['unattributed'] else None
|
||||
return result
|
||||
|
||||
|
||||
def number(value,decimals=1):
|
||||
if value is None: return 'N/A'
|
||||
return f'{value:,.{decimals}f}'.rstrip('0').rstrip('.') if decimals else f'{value:,.0f}'
|
||||
|
||||
|
||||
def change(current,previous):
|
||||
if current is None or previous is None: return 'comparison N/A'
|
||||
if previous==0: return 'unchanged at zero' if current==0 else 'up from zero'
|
||||
delta=100*(current-previous)/previous
|
||||
return f'{delta:+.1f}%'
|
||||
|
||||
|
||||
def build_report(conn,gid,days,as_of):
|
||||
start=as_of-timedelta(days=days)
|
||||
previous_start=start-timedelta(days=days)
|
||||
current=window_metrics(conn,gid,start,as_of)
|
||||
previous=window_metrics(conn,gid,previous_start,start)
|
||||
game=conn.execute('SELECT name FROM games WHERE id=%s',(gid,)).fetchone()
|
||||
comparison='previous day' if days==1 else 'preceding seven days'
|
||||
summary=[
|
||||
{'title':'CCU & audience','text':f"{game['name']} averaged {number(current['average_ccu'])} CCU ({change(current['average_ccu'],previous['average_ccu'])} vs {comparison}), with a sampled peak of {number(current['peak_ccu'],0)}. {number(current['active_players'],0)} players were active ({change(current['active_players'],previous['active_players'])}), starting {number(current['sessions'],0)} sessions. Average DAU was {number(current['average_dau'])}."},
|
||||
{'title':'Engagement','text':f"Known playtime totaled {number(current['total_playtime']/3600 if current['total_playtime'] is not None else None)} hours. Completed sessions averaged {number(current['average_session']/60 if current['average_session'] is not None else None)} minutes ({change(current['average_session'],previous['average_session'])}). There were {number(current['new_players'],0)} new players and {number(current['returning_players'],0)} returning players; {number(current['first_five_rate']) + '%' if current['first_five_rate'] is not None else 'N/A'} of first sessions with a reported end reached five minutes."},
|
||||
{'title':'Retention & spending','text':f"D1 return was {number(current['retention']) + '%' if current['retention'] is not None else 'N/A'}, based on {number(current['retention_cohort'],0)} players whose next-day observation fell in this period. Reported spending was R$ {number(current['robux'],0)} ({change(current['robux'],previous['robux'])}), with {number(current['payers'],0) if not current['unattributed'] else 'N/A'} paying players and {number(current['payer_conversion']) + '%' if current['payer_conversion'] is not None else 'N/A'} payer conversion."}
|
||||
]
|
||||
weekday=None
|
||||
if days==1:
|
||||
weekday_metrics=window_metrics(conn,gid,start-timedelta(days=7),as_of-timedelta(days=7))
|
||||
weekday=f"Same weekday last week: CCU {change(current['average_ccu'],weekday_metrics['average_ccu'])}; active players {change(current['active_players'],weekday_metrics['active_players'])}."
|
||||
notes=[]
|
||||
if current['known_ccu']<days*24: notes.append(f"CCU uses {current['known_ccu']} known hourly samples out of {days*24} expected; peaks are sampled, not continuous.")
|
||||
if current['completed']<current['sessions']: notes.append('Unreported session ends are excluded from duration averages and known playtime.')
|
||||
if current['retention_cohort'] and current['retention_cohort']<30: notes.append('The D1 cohort is small; daily percentages may vary considerably.')
|
||||
report={'gameId':gid,'gameName':game['name'],'periodDays':days,'startDate':start.isoformat(),
|
||||
'endDate':(as_of-timedelta(days=1)).isoformat(),'comparisonStart':previous_start.isoformat(),
|
||||
'comparisonEnd':(start-timedelta(days=1)).isoformat(),'paragraphs':summary,'weekdayComparison':weekday,
|
||||
'notes':notes,'metrics':current,'previousMetrics':previous,'mode':'sample'}
|
||||
from summary_reports import extend_report
|
||||
return report|extend_report(conn,report,start,as_of)
|
||||
|
||||
|
||||
def reports_for_game(conn,gid,as_of=None):
|
||||
as_of=as_of or datetime.now(timezone.utc).date()
|
||||
with REPORT_LOCK:
|
||||
saved=conn.execute('SELECT period_days,payload,generated_at FROM daily_health_reports WHERE game_id=%s AND report_date=%s ORDER BY period_days',(gid,as_of)).fetchall()
|
||||
known={r['period_days'] for r in saved if r['payload'].get('summaryVersion')==4}
|
||||
for days in (1,7):
|
||||
if days in known: continue
|
||||
payload=build_report(conn,gid,days,as_of)
|
||||
conn.execute('''INSERT INTO daily_health_reports(game_id,report_date,period_days,payload)
|
||||
VALUES(%s,%s,%s,%s) ON CONFLICT(game_id,report_date,period_days)
|
||||
DO UPDATE SET payload=excluded.payload,generated_at=now()''',(gid,as_of,days,Jsonb(payload)))
|
||||
saved=conn.execute('SELECT period_days,payload,generated_at FROM daily_health_reports WHERE game_id=%s AND report_date=%s ORDER BY period_days',(gid,as_of)).fetchall()
|
||||
return {'reports':[r['payload']|{'generatedAt':r['generated_at']} for r in saved]}
|
||||
|
||||
|
||||
def refresh_all_reports():
|
||||
with connect() as conn:
|
||||
gids=[r['id'] for r in conn.execute('SELECT id FROM games')]
|
||||
for gid in gids:
|
||||
with connect() as conn: reports_for_game(conn,gid)
|
||||
|
||||
|
||||
def start_daily_reports():
|
||||
def worker():
|
||||
while True:
|
||||
try: refresh_all_reports()
|
||||
except Exception: logging.exception('Daily health report refresh failed')
|
||||
threading.Event().wait(60)
|
||||
thread=threading.Thread(target=worker,name='daily-health-reports',daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
@@ -42,6 +42,7 @@ def seed():
|
||||
conn.execute("SET LOCAL statement_timeout=0")
|
||||
populate(conn)
|
||||
print("Seeded three games with 90 days of sample data.")
|
||||
conn.execute((DB_FILES/'seed_interactions.sql').read_text(encoding='utf-8'))
|
||||
with connect() as conn:
|
||||
conn.execute("ANALYZE")
|
||||
status()
|
||||
@@ -52,6 +53,7 @@ def reseed():
|
||||
conn.execute('SET LOCAL statement_timeout=0')
|
||||
reset_sample(conn)
|
||||
populate(conn)
|
||||
conn.execute((DB_FILES/'seed_interactions.sql').read_text(encoding='utf-8'))
|
||||
with connect() as conn:
|
||||
conn.execute('ANALYZE')
|
||||
status()
|
||||
@@ -60,7 +62,7 @@ def reseed():
|
||||
def status():
|
||||
with connect() as conn:
|
||||
counts = {}
|
||||
for table in ("games", "players", "sessions", "player_snapshots", "events", "game_metrics", "activity_runs", "products", "product_sales", "gamepasses", "gamepass_ownership"):
|
||||
for table in ("games", "players", "sessions", "player_snapshots", "events", "game_metrics", "activity_runs", "products", "product_sales", "gamepasses", "gamepass_ownership", "interaction_events"):
|
||||
counts[table] = conn.execute(sql.SQL("SELECT count(*) AS count FROM {}").format(sql.Identifier(table))).fetchone()["count"]
|
||||
print("PostgreSQL sample records: " + ", ".join(f"{k}={v:,}" for k, v in counts.items()))
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
--chart-10: #a8aaa5;
|
||||
--chart-11: #ef7793;
|
||||
--chart-12: #67c7b5;
|
||||
--chart-height: 230px;
|
||||
--radius: 12px;
|
||||
--sidebar-width: 232px;
|
||||
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
@@ -94,10 +95,10 @@ h1 { margin: 0; font-size: 23px; font-weight: 650; letter-spacing: -.035em; }
|
||||
.card-title { font-size: 13px; font-weight: 600; }
|
||||
.card-note { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.card-body { padding: 14px 16px 16px; }
|
||||
.chart { width: 100%; height: 230px; overflow: visible; }
|
||||
.chart { display: block; width: 100%; height: var(--chart-height); overflow: visible; }
|
||||
.chart.short { height: 170px; }
|
||||
.chart.tall { height: 300px; }
|
||||
.chart text { fill: var(--muted); font-family: var(--font); font-size: 10px; }
|
||||
.chart text { fill: var(--muted); font-family: var(--font); font-size: 12px; }
|
||||
.chart .gridline { stroke: var(--border); stroke-dasharray: 3 5; }
|
||||
.chart .axis { stroke: var(--border); }
|
||||
.chart .line { fill: none; stroke: var(--accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.5; }
|
||||
@@ -105,7 +106,7 @@ h1 { margin: 0; font-size: 23px; font-weight: 650; letter-spacing: -.035em; }
|
||||
.chart .area { fill: url(#areaFill); }
|
||||
.chart .bar { fill: #7889ff; opacity: .8; }
|
||||
.chart .bar:hover { opacity: 1; }
|
||||
.chart-wrap { padding: 0 16px 10px; }
|
||||
.chart-wrap { padding: 0 16px 10px; overflow-x: auto; }
|
||||
.interactive-chart { cursor: crosshair; touch-action: pan-y; }
|
||||
.interactive-chart:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius); }
|
||||
.chart-inspection { pointer-events: none; }
|
||||
@@ -410,3 +411,65 @@ tbody tr:hover { background: rgb(255 255 255 / 2%); }
|
||||
.event-option input { accent-color: var(--accent); }
|
||||
.event-option span { overflow-wrap: anywhere; }
|
||||
.event-option[hidden] { display: none; }
|
||||
.chart-series-picker { margin: 12px 0; }
|
||||
.prompt-source-controls { align-items: center; }
|
||||
.record-filter { margin: 12px 18px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.record-filter summary { cursor: pointer; font-size: 12px; }
|
||||
.record-filter-options { display: grid; grid-template-columns: repeat(auto-fit,minmax(180px,1fr)); gap: 5px 12px; max-height: 240px; overflow-y: auto; padding: 8px 0; }
|
||||
.record-filter-options > p { grid-column: 1 / -1; }
|
||||
.table-footer { flex-wrap: wrap; gap: 10px; }
|
||||
.table-footer .select { min-width: 115px; }
|
||||
.hall-of-fame h2 { margin: 0 0 6px; font-size: 18px; }
|
||||
.health-reports h2 { margin: 0 0 6px; font-size: 18px; }
|
||||
.health-report-grid { grid-template-columns: repeat(2,minmax(0,1fr)); align-items: start; margin-bottom: 20px; }
|
||||
.health-report-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.health-report-title { font-size: 15px; font-weight: 600; }
|
||||
.health-report-summary { display: block; cursor: pointer; list-style: none; border-radius: 6px; }
|
||||
.health-report-summary::-webkit-details-marker { display: none; }
|
||||
.health-report-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 6px; }
|
||||
.health-report-summary > .card-note { display: block; }
|
||||
.health-report-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.health-report-chevron { width: 18px; height: 18px; fill: none; stroke: var(--muted); stroke-width: 2; transition: transform var(--motion-fast) ease; }
|
||||
.health-report-disclosure[open] .health-report-chevron { transform: rotate(90deg); }
|
||||
.health-report-disclosure[open] .health-report-content { animation: data-fade var(--motion-fast) ease-out; }
|
||||
.health-report-body { font-size: 13px; line-height: 1.65; }
|
||||
.health-report-body p { margin: 14px 0 0; color: var(--muted); }
|
||||
.health-report-body strong { color: var(--text); }
|
||||
.health-report-body .health-weekday { font-size: 12px; }
|
||||
.health-report-notes { margin-top: 12px; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
.health-report-notes:empty { display: none; }
|
||||
.health-report-updated { border-top: 1px solid var(--border-soft); margin-top: 14px; padding-top: 10px; line-height: 1.6; }
|
||||
.summary-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-bottom: 10px; }
|
||||
.summary-toolbar .player-segments { margin: 0; padding: 0; }
|
||||
.summary-period-note { margin-bottom: 16px; line-height: 1.6; }
|
||||
.summary-executive { border-color: var(--accent); }
|
||||
.summary-executive .health-report-title { font-size: 18px; }
|
||||
.overview-analysis { border-color: var(--accent); }
|
||||
.overview-analysis .health-report-title { font-size: 18px; }
|
||||
.overview-analysis .health-report-body { column-count: 2; column-gap: 30px; }
|
||||
.overview-analysis .health-report-body p { break-inside: avoid; margin-bottom: 16px; }
|
||||
@media (max-width: 1050px) { .overview-analysis .health-report-body { column-count: 1; } }
|
||||
.summaries-grid { grid-template-columns: repeat(2,minmax(0,1fr)); align-items: start; }
|
||||
.summary-detail-link { display: inline-block; margin-top: 16px; color: var(--accent); font-size: 12px; }
|
||||
@media (max-width: 900px) { .summaries-grid { grid-template-columns: minmax(0,1fr); } }
|
||||
@media (max-width: 1050px) { .health-report-grid { grid-template-columns: minmax(0,1fr); } }
|
||||
.hall-of-fame [data-leaderboards] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.hall-of-fame [data-leaderboards] > .card { min-width: 0; }
|
||||
@media (max-width: 700px) {
|
||||
.hall-of-fame [data-leaderboards] { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.fame-list { list-style: none; margin: 14px 0 0; padding: 0; }
|
||||
.fame-list li { display: flex; align-items: center; gap: 10px; padding: 11px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.fame-list li:last-child { border-bottom: 0; }
|
||||
.fame-rank { width: 16px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.fame-list li:first-child .fame-rank { color: var(--amber); }
|
||||
.avatar-placeholder { display: grid; place-items: center; flex: 0 0 40px; width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-raised); color: var(--muted); font-size: 12px; }
|
||||
.fame-player { flex: 1; min-width: 0; }
|
||||
.fame-player .player-name-link { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fame-player .card-note { font-size: 10px; overflow-wrap: anywhere; }
|
||||
.fame-list strong { font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 640px) {
|
||||
:root { --chart-height: 210px; }
|
||||
.record-filter-options { grid-template-columns: 1fr; }
|
||||
.table-footer .inline { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
128
mockups/overview_analysis.py
Normal file
128
mockups/overview_analysis.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Interpret trends across systems without repeating the period report scorecards."""
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def direction(current,previous):
|
||||
if current is None or previous is None: return None
|
||||
if previous==0: return 1 if current>0 else 0
|
||||
change=(current-previous)/abs(previous)
|
||||
return 1 if change>.03 else -1 if change<-.03 else 0
|
||||
|
||||
|
||||
def economy_trends(conn,gid,start,end):
|
||||
return conn.execute('''WITH paired AS (
|
||||
SELECT l.data latest,b.data baseline FROM players p
|
||||
CROSS JOIN LATERAL(SELECT data FROM player_snapshots WHERE game_id=p.game_id AND player_id=p.id
|
||||
AND captured_at>=%(start)s::date AND captured_at<%(end)s::date ORDER BY captured_at DESC,id DESC LIMIT 1) l
|
||||
CROSS JOIN LATERAL(SELECT data FROM player_snapshots WHERE game_id=p.game_id AND player_id=p.id
|
||||
AND captured_at<%(start)s::date ORDER BY captured_at DESC,id DESC LIMIT 1) b
|
||||
WHERE p.game_id=%(gid)s), deltas AS (
|
||||
SELECT CASE WHEN jsonb_typeof(latest->'level')='number' AND jsonb_typeof(baseline->'level')='number'
|
||||
THEN (latest->>'level')::double precision-(baseline->>'level')::double precision END levels,
|
||||
CASE WHEN jsonb_typeof(latest->'cashEarned')='number' AND jsonb_typeof(baseline->'cashEarned')='number'
|
||||
THEN (latest->>'cashEarned')::double precision-(baseline->>'cashEarned')::double precision END earned,
|
||||
CASE WHEN jsonb_typeof(latest->'cashSpent')='number' AND jsonb_typeof(baseline->'cashSpent')='number'
|
||||
THEN (latest->>'cashSpent')::double precision-(baseline->>'cashSpent')::double precision END spent FROM paired)
|
||||
SELECT count(*)::int matched_players,percentile_cont(.5) WITHIN GROUP(ORDER BY levels) median_level_gain,
|
||||
count(*) FILTER(WHERE earned>=0 AND spent>=0)::int economy_players,
|
||||
sum(earned) FILTER(WHERE earned>=0 AND spent>=0)::double precision earned,
|
||||
sum(spent) FILTER(WHERE earned>=0 AND spent>=0)::double precision spent FROM deltas''',
|
||||
{'gid':gid,'start':start,'end':end}).fetchone()
|
||||
|
||||
|
||||
def deep_analysis(conn,report,start,end,evidence):
|
||||
from health_reports import number,window_metrics
|
||||
m=report['metrics'];p=report['previousMetrics'];name=report['gameName']
|
||||
reach=direction(m['active_players'],p['active_players'])
|
||||
ccu=direction(m['average_ccu'],p['average_ccu'])
|
||||
spending=direction(m['robux'],p['robux'])
|
||||
duration=direction(m['average_session'],p['average_session'])
|
||||
visits=lambda r:r['sessions']/r['active_players'] if r['active_players'] else None
|
||||
repeat=direction(visits(m),visits(p))
|
||||
recent=m if report['periodDays']==1 else window_metrics(conn,report['gameId'],end-timedelta(days=1),end)
|
||||
weekday=window_metrics(conn,report['gameId'],end-timedelta(days=8),end-timedelta(days=7))
|
||||
momentum=direction(recent['active_players'],weekday['active_players'])
|
||||
if reach==-1 and ccu==1:
|
||||
overall=f"{name} is drawing more concurrent presence from a smaller active audience. That suggests more intense use among remaining players; check longer visits and repeat use before treating the CCU rise as acquisition growth."
|
||||
elif reach==-1 and spending==1:
|
||||
overall=f"{name} is monetizing a narrower audience more heavily. Revenue resilience is useful, but it can mask weakening player renewal; the next improvement should broaden engagement rather than rely only on extracting more from existing buyers."
|
||||
elif reach==1 and spending==-1:
|
||||
overall=f"{name} is expanding reach without a matching improvement in spending. That separates the acquisition opportunity from the commercial weakness: first check whether the added audience reaches useful progression and purchase moments."
|
||||
elif reach==1 and ccu in (0,1):
|
||||
overall=f"{name} has a broader audience and a stable or growing concurrency base. The question is whether those arrivals establish a repeat-play habit; traffic growth by itself does not show that the first-session experience is improving."
|
||||
elif reach==-1:
|
||||
overall=f"{name} is losing audience breadth. Better monetization or longer sessions among the players who remain should be read separately from that loss; the main health question is how reliably new visitors become repeat players."
|
||||
else:
|
||||
overall=f"{name}'s recent movement is mixed rather than a clear change in overall health. Judge reach, attention, and spending together, and prioritize the weakest transition between arriving, engaging, returning, and buying."
|
||||
if momentum==-1:
|
||||
overall+=' The latest complete day was also below the same weekday last week for active reach, so the softness is visible beyond a simple weekday/weekend comparison.'
|
||||
elif momentum==1 and reach==-1:
|
||||
overall+=' The latest complete day beat its same-weekday audience comparison, offering an early recovery signal that still needs several more days of follow-through.'
|
||||
elif momentum==1:
|
||||
overall+=' The latest same-weekday comparison supports the broader reach improvement.'
|
||||
|
||||
strengths=[]
|
||||
if duration==1:
|
||||
strengths.append('The players who engage are sustaining longer sessions, so there is useful attention to build on once someone enters the core loop.')
|
||||
if repeat==1:
|
||||
strengths.append('Session starts per active player also increased, consistent with a more frequently returning or more intensely engaged remaining audience.')
|
||||
new_time=evidence.get('newSession');returning_time=evidence.get('returningSession')
|
||||
if returning_time and new_time and returning_time>new_time*1.25:
|
||||
strengths.append('Returning players stay meaningfully longer than first-day players, suggesting the established loop has value for those who get past the initial experience.')
|
||||
sources=evidence.get('promptSources',{});player=sources.get('player',{});game=sources.get('game',{})
|
||||
if player.get('conversion') is not None and player.get('bought',0)>0:
|
||||
strengths.append('Players who explicitly seek a purchase do convert, providing a concrete signal of demand to build around.')
|
||||
if m['retention'] is not None and p['retention'] is not None and m['retention']>p['retention']+1:
|
||||
strengths.append('Mature next-day return improved as well, so the positive signals are not confined to the current session.')
|
||||
if not strengths:
|
||||
strengths.append('No broad improvement is established across these measures in this window. Use the completed activity and purchase observations to identify one repeatable success before scaling changes.')
|
||||
|
||||
angle={
|
||||
'willow-v1':'For an exploration-and-collection loop, make the first discovery or collection reward obvious, then give players a visible next goal and a reason to revisit it.',
|
||||
'orbit-v1':'For a racing loop, inspect time to the first controllable race, the clarity of early checkpoints, and how much friction sits between finishing and starting another race.',
|
||||
'dungeon-v1':'For a crafting-and-quest loop, inspect how soon players receive a useful reward and whether the next upgrade or quest gives them a clear short-term objective.'}
|
||||
key=conn.execute('SELECT sample_key FROM games WHERE id=%s',(report['gameId'],)).fetchone()['sample_key']
|
||||
context=angle.get(key,'Inspect time to the first meaningful action and how clearly the game presents the next achievable goal.')
|
||||
if m['first_five_rate'] is not None and m['first_five_rate']<50:
|
||||
weakness='The clearest audience weakness is early establishment: most first sessions with a known end finish before five minutes. Longer sessions among established players do not solve that initial loss. '
|
||||
elif m['retention'] is not None and m['first_five_rate'] is not None and m['retention']<m['first_five_rate']/3:
|
||||
weakness='Keeping players occupied in the first visit is not translating into the same strength of next-day return. Inspect the ending of the first visit and the reason to come back, alongside onboarding. '
|
||||
else:
|
||||
weakness='Focus the audience review on the handoff from the first enjoyable action to the next visit, using first-session and mature return cohorts together. '
|
||||
weakness+=context
|
||||
|
||||
drop=evidence.get('largestDrop')
|
||||
if drop and drop['previousRuns']>=20:
|
||||
friction=f"The most visible activity friction is in {drop['activity']}, between {drop['from']} and {drop['to']}. The observed loss is {number(drop['dropoff'])}% of runs reaching the preceding stage. Check whether the next action is understood, accessible, and worth its reward; the stage counts locate the loss but do not identify its cause."
|
||||
else:
|
||||
friction='There is not a sufficiently populated observed transition here to justify naming a single activity bottleneck. Review completed journeys before choosing a stage to redesign.'
|
||||
economy=dict(economy_trends(conn,report['gameId'],start,end))
|
||||
if economy['economy_players']>=10 and economy['spent'] and economy['earned']>economy['spent']*1.25:
|
||||
friction+=' Matched returning-player snapshots show recorded currency earnings outpacing spending. Check whether useful upgrades and currency sinks keep pace with rewards; this pattern alone does not establish that prices are wrong.'
|
||||
elif economy['matched_players']>=10 and economy['median_level_gain'] and economy['median_level_gain']>0:
|
||||
friction+=' Matched returning players are still gaining levels. Preserve that progression payoff while checking whether the next unlock remains clear and worthwhile.'
|
||||
|
||||
largest=evidence.get('largestPayerShare')
|
||||
if largest is not None and largest>=35:
|
||||
commercial=f"Revenue is concentrated: the largest payer supplied {number(largest)}% of the period's recorded spending. Recheck the trend with that account removed before concluding that monetization improved broadly, and track the spread of paying players rather than just the total."
|
||||
elif largest is not None:
|
||||
commercial='No single payer supplied more than roughly a third of recorded spending in this window. Still compare payer conversion and spend per payer separately so that changes in audience size do not hide a weakening purchase experience.'
|
||||
else:
|
||||
commercial='A broad monetization conclusion is not established from attributed purchases in this window. Start with the observed purchase journey and the distribution of successful buyers.'
|
||||
if player.get('conversion') is not None and game.get('conversion') is not None:
|
||||
gap=player['conversion']-game['conversion']
|
||||
if gap>10:
|
||||
commercial+=' Explicit purchase actions outperform automatic offers. Test game-triggered offers nearer a relevant need or reward, using matched products and player groups to separate placement from existing purchase intent.'
|
||||
elif gap<-10:
|
||||
commercial+=' Automatic offers currently convert better than explicit purchase actions. Check whether the product mix and player groups differ before increasing offer frequency.'
|
||||
else:
|
||||
commercial+=' Prompt-source conversion is relatively close; product relevance and decision friction are better next comparisons than assuming one source is inherently stronger.'
|
||||
priorities='Start with one measurable first-session improvement, then test the identified activity transition or progression handoff. Review the effect on five-minute engagement and mature next-day return before expanding the change. Separately test offer placement, judging success by payer breadth and revenue concentration as well as total Robux.'
|
||||
return {'title':'Everything at a glance','paragraphs':[
|
||||
{'title':'Direction of travel','text':overall},
|
||||
{'title':"What's working",'text':' '.join(strengths[:3])},
|
||||
{'title':'Main player-experience weakness','text':weakness},
|
||||
{'title':'Activity & progression friction','text':friction},
|
||||
{'title':'Quality of monetization','text':commercial},
|
||||
{'title':'What to prioritize','text':priorities}],
|
||||
'economyEvidence':economy}
|
||||
File diff suppressed because one or more lines are too long
50
mockups/player_records.py
Normal file
50
mockups/player_records.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Game-scoped player records with stable newest-first pages of twenty."""
|
||||
import math
|
||||
|
||||
|
||||
def record_page(conn,gid,pid,kind,params):
|
||||
from sample_api import ApiError, integer
|
||||
if not conn.execute('SELECT 1 FROM players WHERE game_id=%s AND id=%s',(gid,pid)).fetchone():
|
||||
raise ApiError(404,'Player not found in this game.')
|
||||
specs={
|
||||
'snapshots':('player_snapshots r','r.id,r.captured_at,r.data','captured_at'),
|
||||
'sessions':('sessions r JOIN players p ON p.game_id=r.game_id AND p.id=r.player_id',
|
||||
'''r.*,p.display_name,p.roblox_user_id,extract(epoch FROM r.ended_at-r.started_at)::double precision duration,
|
||||
(r.ended_at IS NULL AND r.started_at>now()-interval '6 hours' AND r.started_at<=now()) AS "joinAvailable"''','started_at'),
|
||||
'activities':('activity_runs r JOIN activity_definitions d ON d.game_id=r.game_id AND d.id=r.definition_id',
|
||||
'r.*,d.name,extract(epoch FROM r.ended_at-r.started_at)::double precision duration','started_at'),
|
||||
'events':('events r LEFT JOIN players p ON p.game_id=r.game_id AND p.id=r.player_id','r.*,p.roblox_user_id','occurred_at'),
|
||||
'purchases':('product_sales r JOIN products c ON c.game_id=r.game_id AND c.id=r.product_id JOIN players p ON p.game_id=r.game_id AND p.id=r.player_id',
|
||||
'r.*,p.roblox_user_id,c.name,c.roblox_product_id,(r.unit_price_robux*r.quantity)::double precision robux','purchased_at')}
|
||||
if kind not in specs: raise ApiError(404,'Unknown player record dataset.')
|
||||
source,columns,time=specs[kind]
|
||||
where='r.game_id=%s AND r.player_id=%s'
|
||||
args=[gid,pid]
|
||||
if kind=='sessions': where+=" AND (r.ended_at IS NOT NULL OR r.started_at>now()-interval '6 hours')"
|
||||
if kind=='activities': where+=' AND r.ended_at IS NOT NULL AND r.outcome IS NOT NULL'
|
||||
if kind=='events' and params.get('event'):
|
||||
where+=' AND r.name=ANY(%s)';args.append(params['event'])
|
||||
if kind=='purchases' and params.get('product'):
|
||||
try:
|
||||
ids=[int(v) for v in params['product']]
|
||||
if any(v<=0 for v in ids): raise ValueError
|
||||
except (ValueError,TypeError): raise ApiError(400,'Invalid product filter.')
|
||||
where+=' AND r.product_id=ANY(%s)';args.append(ids)
|
||||
total=conn.execute(f'SELECT count(*) total FROM {source} WHERE {where}',args).fetchone()['total']
|
||||
pages=max(1,math.ceil(total/20))
|
||||
page=min(integer(params,'page',1),pages)
|
||||
records=conn.execute(f'SELECT {columns} FROM {source} WHERE {where} ORDER BY r.{time} DESC,r.id DESC LIMIT 20 OFFSET %s',args+[(page-1)*20]).fetchall()
|
||||
return {'records':records,'pagination':{'page':page,'pages':pages,'pageSize':20,'total':total}}
|
||||
|
||||
|
||||
def player_inventory(conn,gid,pid):
|
||||
passes=conn.execute('''SELECT gp.id,gp.name,gp.roblox_gamepass_id,o.purchased_at FROM gamepass_ownership o
|
||||
JOIN gamepasses gp ON gp.game_id=o.game_id AND gp.id=o.gamepass_id
|
||||
WHERE o.game_id=%s AND o.player_id=%s AND o.is_owned ORDER BY o.purchased_at ASC NULLS LAST,gp.id''',(gid,pid)).fetchall()
|
||||
products=conn.execute('''SELECT p.id,p.name,p.roblox_product_id,sum(s.quantity)::int quantity,
|
||||
sum(s.quantity*s.unit_price_robux)::double precision robux,min(s.purchased_at) first_purchase,max(s.purchased_at) last_purchase
|
||||
FROM product_sales s JOIN products p ON p.game_id=s.game_id AND p.id=s.product_id
|
||||
WHERE s.game_id=%s AND s.player_id=%s GROUP BY p.id ORDER BY first_purchase,p.id''',(gid,pid)).fetchall()
|
||||
event_names=conn.execute('SELECT DISTINCT name FROM events WHERE game_id=%s AND player_id=%s ORDER BY name',(gid,pid)).fetchall()
|
||||
return {'ownedGamepasses':passes,'purchasedProducts':products,
|
||||
'recordFilters':{'events':[r['name'] for r in event_names],'purchases':[{'id':r['id'],'name':r['name']} for r in products]}}
|
||||
File diff suppressed because one or more lines are too long
70
mockups/prompt_metrics.py
Normal file
70
mockups/prompt_metrics.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Lightweight product-prompt measurements, separate from activity sessions."""
|
||||
def prompt_analytics(conn,gid,days,source='all'):
|
||||
if source not in ('all','game','player'):
|
||||
from sample_api import ApiError
|
||||
raise ApiError(400,'Unknown prompt source.')
|
||||
products=conn.execute('''SELECT p.id,p.name,p.roblox_product_id,count(i.id)::int prompts,
|
||||
count(i.id) FILTER(WHERE i.outcome='bought')::int bought,
|
||||
count(i.id) FILTER(WHERE i.outcome='closed')::int closed,
|
||||
count(i.id) FILTER(WHERE i.outcome IS NULL)::int unresolved,
|
||||
avg(extract(epoch FROM i.ended_at-i.started_at)) FILTER(WHERE i.outcome='bought')::double precision buy_time,
|
||||
avg(extract(epoch FROM i.ended_at-i.started_at)) FILTER(WHERE i.outcome='closed')::double precision close_time
|
||||
FROM products p LEFT JOIN interaction_events i ON i.game_id=p.game_id AND i.product_id=p.id AND i.kind='product_prompt'
|
||||
AND i.outcome IN ('bought','closed') AND i.ended_at IS NOT NULL AND i.trigger_source IN ('game','player')
|
||||
AND i.started_at>=current_date-(%s-1) AND (%s='all' OR i.trigger_source=%s)
|
||||
WHERE p.game_id=%s GROUP BY p.id ORDER BY p.id''',(days,source,source,gid)).fetchall()
|
||||
for row in products: row['conversion']=100*row['bought']/row['prompts'] if row['prompts'] else None
|
||||
daily=conn.execute('''SELECT (started_at AT TIME ZONE 'UTC')::date AS day,product_id,count(*)::int prompts,
|
||||
count(*) FILTER(WHERE outcome='bought')::int bought,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='bought')::double precision buy_time,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='closed')::double precision close_time
|
||||
FROM interaction_events WHERE game_id=%s AND kind='product_prompt'
|
||||
AND outcome IN ('bought','closed') AND ended_at IS NOT NULL AND trigger_source IN ('game','player') AND started_at>=current_date-(%s-1)
|
||||
AND (%s='all' OR trigger_source=%s) GROUP BY day,product_id ORDER BY day''',(gid,days,source,source)).fetchall()
|
||||
totals=conn.execute('''SELECT count(*)::int prompts,count(*) FILTER(WHERE outcome='bought')::int bought,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='bought')::double precision buy_time,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='closed')::double precision close_time
|
||||
FROM interaction_events WHERE game_id=%s AND kind='product_prompt'
|
||||
AND outcome IN ('bought','closed') AND ended_at IS NOT NULL AND trigger_source IN ('game','player') AND started_at>=current_date-(%s-1)
|
||||
AND (%s='all' OR trigger_source=%s)''',(gid,days,source,source)).fetchone()
|
||||
totals['conversion']=100*totals['bought']/totals['prompts'] if totals['prompts'] else None
|
||||
from datetime import datetime,timedelta,timezone
|
||||
dates=[datetime.now(timezone.utc).date()-timedelta(days=i) for i in reversed(range(days))]
|
||||
lookup={(r['product_id'],r['day']):r for r in daily}
|
||||
series=[]
|
||||
for p in products:
|
||||
values=[]
|
||||
for day in dates:
|
||||
row=lookup.get((p['id'],day))
|
||||
values.append(100*row['bought']/row['prompts'] if row and row['prompts'] else None)
|
||||
series.append({'name':p['name'],'format':'percent','values':values})
|
||||
grouped=conn.execute('''SELECT trigger_source source,count(*)::int prompts,
|
||||
count(*) FILTER(WHERE outcome='bought')::int bought,count(*) FILTER(WHERE outcome='closed')::int closed,
|
||||
count(*) FILTER(WHERE outcome IS NULL)::int unresolved,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='bought')::double precision buy_time,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='closed')::double precision close_time
|
||||
FROM interaction_events WHERE game_id=%s AND kind='product_prompt'
|
||||
AND outcome IN ('bought','closed') AND ended_at IS NOT NULL AND trigger_source IN ('game','player') AND started_at>=current_date-(%s-1)
|
||||
GROUP BY trigger_source''',(gid,days)).fetchall()
|
||||
labels={'game':'Game-induced','player':'Player-induced'}
|
||||
by_source={r['source']:r for r in grouped}
|
||||
comparison=[]
|
||||
for key,name in labels.items():
|
||||
row=by_source.get(key,{'source':key,'prompts':0,'bought':0,'closed':0,'unresolved':0,'buy_time':None,'close_time':None})
|
||||
row['name']=name;row['conversion']=100*row['bought']/row['prompts'] if row['prompts'] else None
|
||||
comparison.append(row)
|
||||
source_days=conn.execute('''SELECT (started_at AT TIME ZONE 'UTC')::date AS day,trigger_source source,
|
||||
count(*)::int prompts,count(*) FILTER(WHERE outcome='bought')::int bought
|
||||
FROM interaction_events WHERE game_id=%s AND kind='product_prompt'
|
||||
AND outcome IN ('bought','closed') AND ended_at IS NOT NULL AND trigger_source IN ('game','player') AND started_at>=current_date-(%s-1)
|
||||
GROUP BY day,trigger_source''',(gid,days)).fetchall()
|
||||
source_lookup={(r['source'],r['day']):r for r in source_days}
|
||||
source_series=[]
|
||||
for key,name in labels.items():
|
||||
values=[]
|
||||
for day in dates:
|
||||
row=source_lookup.get((key,day))
|
||||
values.append(100*row['bought']/row['prompts'] if row and row['prompts'] else None)
|
||||
source_series.append({'name':name,'format':'percent','values':values})
|
||||
return {'metrics':totals,'products':products,'source':source,'sourceComparison':comparison,
|
||||
'sourceSeries':source_series,'series':series,'labels':[d.strftime('%d %b') for d in dates]}
|
||||
3
mockups/prompts-mockup.html
Normal file
3
mockups/prompts-mockup.html
Normal file
File diff suppressed because one or more lines are too long
@@ -29,7 +29,7 @@ def copy_rows(conn, table, columns, records):
|
||||
def reset_sample(conn):
|
||||
"""Remove only the bundled samples; keep games and unrelated projects intact."""
|
||||
gids = [r['id'] for r in conn.execute('SELECT id FROM games WHERE sample_key=ANY(%s)', (list(SAMPLE_KEYS),))]
|
||||
for table in ('gamepass_ownership', 'product_sales', 'activity_runs', 'events',
|
||||
for table in ('daily_health_reports', 'interaction_events', 'gamepass_ownership', 'product_sales', 'activity_runs', 'events',
|
||||
'player_snapshots', 'sessions', 'game_metrics', 'players',
|
||||
'gamepasses', 'products', 'activity_definitions', 'column_templates', 'player_fields'):
|
||||
conn.execute(sql.SQL('DELETE FROM {} WHERE game_id=ANY(%s)').format(sql.Identifier(table)), (gids,))
|
||||
@@ -176,17 +176,13 @@ def populate(conn, players_per_game=6000):
|
||||
spent[pid] += price*quantity
|
||||
units[pid] += quantity
|
||||
buys.append((product,quantity,price,at))
|
||||
for product,quantity,price,at in buys:
|
||||
if seconds>60 and (buys or rng.random()<.23):
|
||||
definition=defs['shop']
|
||||
activity('shop',pid,sid,at-timedelta(seconds=10),at+timedelta(seconds=5),'purchased',
|
||||
[definition['start_event']]+definition['configuration']['steps']+[definition['end_event']],
|
||||
{'product_id':product['roblox_product_id'],'purchase_amount':price*quantity,'currency_balance':balances[pid]},1)
|
||||
if not buys and seconds>60 and rng.random()<.23:
|
||||
definition=defs['shop']
|
||||
count=rng.choices([0,1,2], [.15,.65,.2])[0]
|
||||
progress=[definition['start_event']]+definition['configuration']['steps'][:count]
|
||||
progress=[definition['start_event']]
|
||||
if buys or rng.random()<.85:
|
||||
progress.append('product_viewed')
|
||||
activity('shop',pid,sid,start+(end-start)*.15,start+(end-start)*.3,'closed',
|
||||
progress+[definition['end_event']],{'currency_balance':balances[pid]},int(count==2))
|
||||
progress+[definition['end_event']],{'currency_balance':balances[pid]})
|
||||
for key, chance in [('minigame',.6),('quest',.35)]:
|
||||
if seconds<180 or rng.random()>chance:
|
||||
continue
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Retention — Statboard</title><link rel="stylesheet" href="mockup.css"></head>
|
||||
<body data-page="retention"><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"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Sessions</a><a class="active" href="retention-mockup.html"><span class="nav-icon">↗</span>Retention</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="activities-mockup.html"><span class="nav-icon">⟿</span>Activity sessions</a><a class="" href="monetization-mockup.html"><span class="nav-icon">◇</span>Monetization</a><a class="" href="settings-mockup.html"><span class="nav-icon">⚙</span>Project settings</a></nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">Retention</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><div class="page-heading"><div><h1>Retention</h1><div class="subtitle">Exact-day return rates from stored player sessions; incomplete cohorts display N/A.</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><div class="grid metrics"><article class="card card-pad metric-card"><div class="metric-label">Day 1 return rate</div><div class="metric-value" data-metric="retention" data-format="percent">N/A</div><div class="metric-foot">Weighted across mature cohorts</div></article><article class="card card-pad metric-card"><div class="metric-label">Day 7 return rate</div><div class="metric-value" data-metric="day7" data-format="percent">N/A</div><div class="metric-foot">Exact day 7, with a complete observation day</div></article><article class="card card-pad metric-card"><div class="metric-label">New players</div><div class="metric-value" data-metric="newPlayers" data-format="number">N/A</div><div class="metric-foot">First seen in selected period</div></article><article class="card card-pad metric-card"><div class="metric-label">Returning players</div><div class="metric-value" data-metric="returningPlayers" data-format="number">N/A</div><div class="metric-foot">First seen before selected period</div></article></div><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><article class="card"><div class="card-head"><div><div class="card-title">Return rate trend</div><div class="card-note">N/A observations are gaps in the chart</div></div></div><div class="chart-wrap" data-chart="trend"><div class="empty">N/A</div></div></article></div></section></main></div><nav class="mobile-nav"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="active" href="sessions-mockup.html"><span class="nav-icon">◷</span>Analytics</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="settings-mockup.html"><span class="nav-icon">···</span>More</a></nav><script src="mockup.js"></script></body></html>
|
||||
<body data-page="retention"><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"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Sessions</a><a class="active" href="retention-mockup.html"><span class="nav-icon">↗</span>Retention</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="activities-mockup.html"><span class="nav-icon">⟿</span>Activity sessions</a><a class="" href="monetization-mockup.html"><span class="nav-icon">◇</span>Monetization</a><a class="" href="settings-mockup.html"><span class="nav-icon">⚙</span>Project settings</a></nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">Retention</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><div class="page-heading"><div><h1>Retention</h1><div class="subtitle">Exact-day return rates from stored player sessions; incomplete cohorts display N/A.</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><div class="grid metrics"><article class="card card-pad metric-card"><div class="metric-label">Day 1 return rate</div><div class="metric-value" data-metric="retention" data-format="percent">N/A</div><div class="metric-foot">Weighted across mature cohorts</div></article><article class="card card-pad metric-card"><div class="metric-label">Day 7 return rate</div><div class="metric-value" data-metric="day7" data-format="percent">N/A</div><div class="metric-foot">Exact day 7, with a complete observation day</div></article><article class="card card-pad metric-card"><div class="metric-label">New players</div><div class="metric-value" data-metric="newPlayers" data-format="number">N/A</div><div class="metric-foot">First seen in selected period</div></article><article class="card card-pad metric-card"><div class="metric-label">Returning players</div><div class="metric-value" data-metric="returningPlayers" data-format="number">N/A</div><div class="metric-foot">First seen before selected period</div></article></div><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><article class="card"><div class="card-head"><div><div class="card-title">Return rate trend</div><div class="card-note">N/A observations are gaps in the chart</div></div></div><div class="chart-wrap" data-chart="trend"><div class="empty">N/A</div></div></article></div><div class="section-gap"><article class="card"><div class="card-head"><div><div class="card-title">First-week retention</div><div class="card-note">Day 1–7 exact-day returns · same mature cohorts at every point</div></div></div><div class="chart-wrap" data-chart="retention-week"><div class="empty">N/A</div></div></article></div></section></main></div><nav class="mobile-nav"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="active" href="sessions-mockup.html"><span class="nav-icon">◷</span>Analytics</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="settings-mockup.html"><span class="nav-icon">···</span>More</a></nav><script src="mockup.js"></script></body></html>
|
||||
@@ -5,6 +5,10 @@ import math
|
||||
import statistics
|
||||
from database import connect
|
||||
from metrics_api import engagement, monetization, gamepasses
|
||||
from player_records import record_page, player_inventory
|
||||
from prompt_metrics import prompt_analytics
|
||||
from health_reports import reports_for_game
|
||||
from data_quality import quality_log
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
@@ -79,6 +83,16 @@ def segment_players(players, segment):
|
||||
def player_list(conn,gid,params):
|
||||
fields,templates=fields_templates(conn,gid)
|
||||
players=all_players(conn,gid)
|
||||
ranking=params.get('ranking',['level'])[0]
|
||||
if ranking not in {f['key'] for f in fields}: raise ApiError(400,'Unknown ranking.')
|
||||
boards=[]
|
||||
for key in dict.fromkeys(['robux','playtime','cash',ranking]):
|
||||
field=next((f for f in fields if f['key']==key),None)
|
||||
if not field: continue
|
||||
eligible=[p for p in players if type(p.get(key)) in (int,float)]
|
||||
leaders=sorted(eligible,key=lambda p:(-p[key],p['playerId']))[:5]
|
||||
boards.append({'key':key,'label':field['label'],'format':field['format'],
|
||||
'players':[{'playerId':p['playerId'],'name':p['name'],'robloxUserId':p['robloxUserId'],'value':p[key]} for p in leaders]})
|
||||
known_levels=[p["level"] for p in players if p.get("level") is not None]
|
||||
known_times=[p["playtime"] for p in players if p["playtime"] is not None]
|
||||
metrics={"playerCount":len(players),"activePlayers":sum(p["lastSeen"] is not None and p["lastSeen"]<=7*1440 for p in players),
|
||||
@@ -92,9 +106,9 @@ def player_list(conn,gid,params):
|
||||
if direction not in ("asc","desc"): raise ApiError(400,"Unknown direction.")
|
||||
known=sorted([p for p in players if p.get(key) is not None],key=lambda p:(p[key],p["playerId"]),reverse=direction=="desc")
|
||||
players=known+sorted([p for p in players if p.get(key) is None],key=lambda p:p["playerId"])
|
||||
size=integer(params,"page_size",50,100)
|
||||
size=integer(params,"page_size",20,20)
|
||||
page=min(integer(params,"page",1),max(1,math.ceil(len(players)/size)))
|
||||
return {"metrics":metrics,"fields":fields,"templates":templates,"playerRows":players[(page-1)*size:page*size],
|
||||
return {"metrics":metrics,"fields":fields,"templates":templates,"leaderboards":boards,"playerRows":players[(page-1)*size:page*size],
|
||||
"pagination":{"page":page,"pageSize":size,"total":len(players),"pages":max(1,math.ceil(len(players)/size))},
|
||||
"groupDefinitions":{"whales":"Top 20% of known positive Robux spenders","playtime":"Top 20% of known positive playtime","first":"First seen within 24 hours"}}
|
||||
|
||||
@@ -134,7 +148,11 @@ def retention(conn,gid,days):
|
||||
values=[round(100*sum(start+timedelta(days=d) in dates for dates in members)/len(members),1)
|
||||
if members and start+timedelta(days=d)<today else None for d in range(8)]
|
||||
grid.append({"name":label(start),"players":len(members),"values":values})
|
||||
return {"retention":rate(1),"day7":rate(7),"retentionGrid":{"columns":[f"Day {d}" for d in range(8)],"rows":grid},
|
||||
mature=[(d,m) for d,m in cohorts.items() if starts[0]<=d and d+timedelta(days=7)<today]
|
||||
denominator=sum(len(m) for d,m in mature)
|
||||
week=[round(100*sum(sum(d+timedelta(days=offset) in dates for dates in members) for d,members in mature)/denominator,2) if denominator else None for offset in range(1,8)]
|
||||
return {"retention":rate(1),"day7":rate(7),"weekRetention":{"labels":[f'Day {d}' for d in range(1,8)],
|
||||
"series":[{"name":"Exact-day retention","format":"percent","values":week}],"players":denominator},"retentionGrid":{"columns":[f"Day {d}" for d in range(8)],"rows":grid},
|
||||
"labels":[r["name"] for r in grid],"series":[{"name":"Day 1","values":[r["values"][1] for r in grid]},{"name":"Day 7","values":[r["values"][7] for r in grid]}]}
|
||||
|
||||
|
||||
@@ -189,7 +207,7 @@ def stage_funnel(runs,definition):
|
||||
if definition.get('end_event'): stages.append(('End',definition['end_event']))
|
||||
unknown=sum(r.get('stage_progress') is None for r in runs)
|
||||
survivors=[(r['stage_progress'],0) for r in runs if isinstance(r.get('stage_progress'),list)]
|
||||
count=len(runs); previous=count; result=[]
|
||||
count=len(survivors); previous=count; result=[]
|
||||
for index,(kind,event) in enumerate(stages):
|
||||
reached=[]
|
||||
for progress,cursor in survivors:
|
||||
@@ -197,7 +215,7 @@ def stage_funnel(runs,definition):
|
||||
except ValueError: pass
|
||||
survivors=reached
|
||||
current=count if index==0 else len(reached)
|
||||
known=index==0 or unknown==0
|
||||
known=True
|
||||
result.append({'label':kind,'event':event,'runs':current,
|
||||
'reachPercent':100*current/count if count and known else None,
|
||||
'dropoffPercent':100*(previous-current)/previous if index and previous and known else None,
|
||||
@@ -219,7 +237,7 @@ def activities(conn,gid,params,days):
|
||||
ids=[p["playerId"] for p in segment_players(all_players(conn,gid),segment)]
|
||||
clause="AND player_id=ANY(%s)"
|
||||
args.append(ids)
|
||||
runs=rows(conn,f"SELECT *,extract(epoch FROM ended_at-started_at)::double precision duration FROM activity_runs WHERE game_id=%s AND definition_id=%s AND started_at>=current_date-(%s-1) {clause} ORDER BY started_at",args)
|
||||
runs=rows(conn,f"SELECT *,extract(epoch FROM ended_at-started_at)::double precision duration FROM activity_runs WHERE game_id=%s AND definition_id=%s AND ended_at IS NOT NULL AND outcome IS NOT NULL AND started_at>=current_date-(%s-1) {clause} ORDER BY started_at",args)
|
||||
completed=[r for r in runs if r["duration"] is not None]
|
||||
times=[r["duration"] for r in completed]
|
||||
prompts=[r for r in runs if (r["prompt_count"] or 0)>0]
|
||||
@@ -292,19 +310,16 @@ def player_detail(conn,gid,pid,params):
|
||||
if not record: raise ApiError(404,"Player not found in this game.")
|
||||
fields,_=fields_templates(conn,gid)
|
||||
history=player_history(conn,gid,pid,fields,params)
|
||||
runs=rows(conn,"""SELECT d.name,a.started_at,a.ended_at,a.outcome,a.step_count,a.prompt_count,a.captured_values,
|
||||
extract(epoch FROM a.ended_at-a.started_at)::double precision duration FROM activity_runs a
|
||||
JOIN activity_definitions d ON d.game_id=a.game_id AND d.id=a.definition_id WHERE a.game_id=%s AND a.player_id=%s ORDER BY a.started_at DESC LIMIT 50""",(gid,pid))
|
||||
sales=rows(conn,"""SELECT s.receipt_id,s.player_id,p.roblox_user_id,c.name,c.roblox_product_id,s.unit_price_robux,s.quantity,
|
||||
(s.unit_price_robux*s.quantity)::double precision robux,s.purchased_at FROM product_sales s
|
||||
JOIN products c ON c.game_id=s.game_id AND c.id=s.product_id JOIN players p ON p.game_id=s.game_id AND p.id=s.player_id
|
||||
WHERE s.game_id=%s AND s.player_id=%s ORDER BY s.purchased_at DESC LIMIT 50""",(gid,pid))
|
||||
history.pop('snapshots',None)
|
||||
pages={kind:record_page(conn,gid,pid,kind,{}) for kind in ('snapshots','sessions','activities','events','purchases')}
|
||||
peers=all_players(conn,gid)
|
||||
groups=[title for key,title in [('whales','Whale'),('playtime','Top playtime'),('first','First-time')]
|
||||
if any(p['playerId']==pid for p in segment_players(peers,key))]
|
||||
return {"player":player_values(record),"fields":fields,"sessions":sessions(conn,gid,pid),"events":events(conn,gid,pid),"activities":runs,
|
||||
"sales":sales,"groups":groups,
|
||||
**history}
|
||||
return {"player":player_values(record),"fields":fields,"groups":groups,"recordPages":pages,
|
||||
"snapshots":pages['snapshots']['records'],"sessions":pages['sessions']['records'],"events":pages['events']['records'],
|
||||
"activities":pages['activities']['records'],"sales":pages['purchases']['records'],
|
||||
**player_inventory(conn,gid,pid),**history}
|
||||
|
||||
|
||||
|
||||
def dispatch(path,params):
|
||||
@@ -316,16 +331,20 @@ def dispatch(path,params):
|
||||
gid=int(parts[2]); root=game(conn,gid)
|
||||
base={"gameId":gid,"game":root}
|
||||
if len(parts)==3:
|
||||
return base|{"datasets":{k:f"/api/games/{gid}/{k}" for k in ('players','sessions','events','metrics','activities','overview','retention','engagement','monetization','gamepasses')}}
|
||||
return base|{"datasets":{k:f"/api/games/{gid}/{k}" for k in ('players','sessions','events','metrics','activities','overview','retention','engagement','monetization','gamepasses','prompts','reports','data-quality')}}
|
||||
days=integer(params,'days',14,90); route=parts[3]
|
||||
if route=='players':
|
||||
if len(parts)==6 and parts[4].isdigit(): return base|record_page(conn,gid,int(parts[4]),parts[5],params)
|
||||
if len(parts)==5 and parts[4].isdigit(): return base|player_detail(conn,gid,int(parts[4]),params)
|
||||
if len(parts)!=4: raise ApiError(404,'Unknown player route.')
|
||||
return base|player_list(conn,gid,params)
|
||||
if len(parts)!=4: raise ApiError(404,'Unknown dataset route.')
|
||||
if route=='activities': return base|activities(conn,gid,params,days)
|
||||
if route=='reports': return base|reports_for_game(conn,gid)
|
||||
if route=='data-quality': return base|quality_log(conn,gid,params)
|
||||
if route=='metrics': return base|root_metrics(conn,gid,days)
|
||||
if route=='engagement': return base|engagement(conn,gid,days)
|
||||
if route=='prompts': return base|prompt_analytics(conn,gid,days,params.get('source',['all'])[0])
|
||||
if route=='monetization': return base|monetization(conn,gid,days)
|
||||
if route=='gamepasses': return base|gamepasses(conn,gid)
|
||||
if route=='events': return base|event_analytics(conn,gid,days)
|
||||
@@ -338,7 +357,9 @@ def dispatch(path,params):
|
||||
data['engagement']=engagement(conn,gid,days,selected_ids)
|
||||
data['metrics'].update(data['engagement']['metrics'])
|
||||
data['segment']=params.get('segment',['all'])[0]
|
||||
if route=='overview': data['rootMetrics']=root_metrics(conn,gid,days)
|
||||
if route=='overview':
|
||||
data['rootMetrics']=root_metrics(conn,gid,days)
|
||||
data['healthReports']=reports_for_game(conn,gid)['reports']
|
||||
else:
|
||||
ret=retention(conn,gid,days); data['metrics']['day7']=ret['day7']
|
||||
data.update({k:v for k,v in ret.items() if k not in ('retention','day7')})
|
||||
|
||||
@@ -10,6 +10,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
||||
import psycopg
|
||||
from database import connect
|
||||
from sample_api import ApiError, dispatch
|
||||
from health_reports import start_daily_reports
|
||||
|
||||
STATIC_ROOT=Path(__file__).resolve().parent
|
||||
|
||||
@@ -57,6 +58,7 @@ def main():
|
||||
with connect() as conn: conn.execute('SELECT 1 FROM games LIMIT 1')
|
||||
port=int(os.environ.get('MOCKUP_PORT','8011'))
|
||||
server=ThreadingHTTPServer(('127.0.0.1',port),Handler)
|
||||
start_daily_reports()
|
||||
print(f'PostgreSQL mockups: http://127.0.0.1:{port}/mockups/overview-mockup.html',flush=True)
|
||||
try: server.serve_forever()
|
||||
except KeyboardInterrupt: server.server_close()
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Project settings — Statboard</title><link rel="stylesheet" href="mockup.css"></head>
|
||||
<body data-page="settings"><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"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Sessions</a><a class="" href="retention-mockup.html"><span class="nav-icon">↗</span>Retention</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="activities-mockup.html"><span class="nav-icon">⟿</span>Activity sessions</a><a class="" href="monetization-mockup.html"><span class="nav-icon">◇</span>Monetization</a><a class="active" href="settings-mockup.html"><span class="nav-icon">⚙</span>Project settings</a></nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">Project settings</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><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><div class="page-heading"><div><h1>Project settings</h1><div class="subtitle">The internal game ID is the root of every dataset.</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><article class="card card-pad"><div class="card-title">Game details</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></section></main></div><nav class="mobile-nav"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Analytics</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="active" href="settings-mockup.html"><span class="nav-icon">···</span>More</a></nav><script src="mockup.js"></script></body></html>
|
||||
<body data-page="settings"><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"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Sessions</a><a class="" href="retention-mockup.html"><span class="nav-icon">↗</span>Retention</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="" href="activities-mockup.html"><span class="nav-icon">⟿</span>Activity sessions</a><a class="" href="monetization-mockup.html"><span class="nav-icon">◇</span>Monetization</a><a class="active" href="settings-mockup.html"><span class="nav-icon">⚙</span>Project settings</a></nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">Project settings</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><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><div class="page-heading"><div><h1>Project settings</h1><div class="subtitle">The internal game ID is the root of every dataset.</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><article class="card card-pad"><div class="card-title">Game details</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><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><article class="card"><div class="card-head"><div><div class="card-title">Unresolved data</div><div class="card-note"></div></div></div><div class="table-wrap"><table><thead><tr><th>Issue</th><th>Record type</th><th>Record ID</th><th>Details</th><th>Reported (UTC)</th></tr></thead><tbody data-quality-rows><tr><td colspan="5" class="empty">N/A</td></tr></tbody></table></div></article><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></section></main></div><nav class="mobile-nav"><a class="" href="overview-mockup.html"><span class="nav-icon">◫</span>Overview</a><a class="" href="sessions-mockup.html"><span class="nav-icon">◷</span>Analytics</a><a class="" href="players-mockup.html"><span class="nav-icon">♙</span>Players</a><a class="" href="events-mockup.html"><span class="nav-icon">⌘</span>Events</a><a class="active" href="settings-mockup.html"><span class="nav-icon">···</span>More</a></nav><script src="mockup.js"></script></body></html>
|
||||
1
mockups/summaries-mockup.html
Normal file
1
mockups/summaries-mockup.html
Normal file
@@ -0,0 +1 @@
|
||||
<!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>
|
||||
110
mockups/summary_reports.py
Normal file
110
mockups/summary_reports.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Mockup editorial summaries using the saved report's game and UTC window."""
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
import statistics
|
||||
|
||||
|
||||
def extend_report(conn,report,start,end):
|
||||
from health_reports import number,change
|
||||
from sample_api import stage_funnel
|
||||
gid=report['gameId'];m=report['metrics'];prev=report['previousMetrics']
|
||||
scope={'gid':gid,'start':start,'end':end}
|
||||
pct=lambda v: 'N/A' if v is None else number(v)+'%'
|
||||
minutes=lambda v: 'N/A' if v is None else number(v/60)+' min'
|
||||
hours=lambda v: 'N/A' if v is None else number(v/3600)+' h'
|
||||
money=lambda v,decimals=0: 'N/A' if v is None else 'R$ '+number(v,decimals)
|
||||
timing=lambda v: 'N/A' if v is None else number(v)+' sec'
|
||||
extra=conn.execute('''SELECT
|
||||
(SELECT count(DISTINCT player_id) FROM player_active_days WHERE game_id=%(gid)s
|
||||
AND active_day>=%(end)s::date-30 AND active_day<%(end)s::date)::int mau,
|
||||
avg(extract(epoch FROM s.ended_at-s.started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date)::double precision new_session,
|
||||
avg(extract(epoch FROM s.ended_at-s.started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date)::double precision returning_session
|
||||
FROM sessions s JOIN players p ON p.game_id=s.game_id AND p.id=s.player_id
|
||||
WHERE s.game_id=%(gid)s AND s.started_at>=%(start)s::date AND s.started_at<%(end)s::date''',scope).fetchone()
|
||||
retained={}
|
||||
for offset in (7,30):
|
||||
r=conn.execute('''SELECT count(*)::int cohort,count(*) FILTER(WHERE EXISTS(SELECT 1 FROM sessions s
|
||||
WHERE s.game_id=p.game_id AND s.player_id=p.id AND s.started_at>=(p.first_seen AT TIME ZONE 'UTC')::date+%(offset)s
|
||||
AND s.started_at<(p.first_seen AT TIME ZONE 'UTC')::date+%(offset)s+1))::int returned
|
||||
FROM players p WHERE p.game_id=%(gid)s AND p.first_seen>=%(start)s::date-%(offset)s
|
||||
AND p.first_seen<%(end)s::date-%(offset)s''',scope|{'offset':offset}).fetchone()
|
||||
retained[offset]=100*r['returned']/r['cohort'] if r['cohort'] else None
|
||||
products=conn.execute('''SELECT p.id,p.name,sum(s.quantity)::int units,sum(s.unit_price_robux*s.quantity)::double precision robux
|
||||
FROM product_sales s JOIN products p ON p.game_id=s.game_id AND p.id=s.product_id
|
||||
WHERE s.game_id=%(gid)s AND s.purchased_at>=%(start)s::date AND s.purchased_at<%(end)s::date
|
||||
GROUP BY p.id ORDER BY robux DESC,p.id''',scope).fetchall()
|
||||
payers=conn.execute('''SELECT player_id,sum(unit_price_robux*quantity)::double precision robux FROM product_sales
|
||||
WHERE game_id=%(gid)s AND purchased_at>=%(start)s::date AND purchased_at<%(end)s::date AND unit_price_robux>0
|
||||
AND player_id IS NOT NULL GROUP BY player_id ORDER BY robux DESC,player_id''',scope).fetchall()
|
||||
largest=100*payers[0]['robux']/m['robux'] if payers and m['robux'] and not m['unattributed'] else None
|
||||
arppu=m['robux']/m['payers'] if m['payers'] and not m['unattributed'] else None
|
||||
user_days=m['average_dau']*report['periodDays']
|
||||
arpdau=m['robux']/user_days if user_days else None
|
||||
prompts=conn.execute('''SELECT trigger_source source,count(*)::int prompts,count(*) FILTER(WHERE outcome='bought')::int bought,
|
||||
count(*) FILTER(WHERE outcome='closed')::int closed,count(*) FILTER(WHERE outcome IS NULL)::int unresolved,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='bought')::double precision buy_time,
|
||||
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE outcome='closed')::double precision close_time
|
||||
FROM interaction_events WHERE game_id=%(gid)s AND kind='product_prompt'
|
||||
AND outcome IN ('bought','closed') AND ended_at IS NOT NULL AND trigger_source IN ('game','player')
|
||||
AND started_at>=%(start)s::date AND started_at<%(end)s::date GROUP BY trigger_source''',scope).fetchall()
|
||||
prompt_by_source={r['source']:dict(r,conversion=100*r['bought']/r['prompts'] if r['prompts'] else None) for r in prompts}
|
||||
game_prompt=prompt_by_source.get('game',{});player_prompt=prompt_by_source.get('player',{})
|
||||
definitions=conn.execute('SELECT * FROM activity_definitions WHERE game_id=%s ORDER BY id',(gid,)).fetchall()
|
||||
runs=conn.execute('''SELECT definition_id,stage_progress,extract(epoch FROM ended_at-started_at)::double precision duration
|
||||
FROM activity_runs WHERE game_id=%(gid)s AND ended_at IS NOT NULL AND outcome IS NOT NULL AND started_at>=%(start)s::date AND started_at<%(end)s::date''',scope).fetchall()
|
||||
grouped=defaultdict(list)
|
||||
for row in runs: grouped[row['definition_id']].append(row)
|
||||
activity_lines=[];drops=[]
|
||||
for definition in definitions:
|
||||
members=grouped[definition['id']]
|
||||
if not members: continue
|
||||
times=[r['duration'] for r in members if r['duration'] is not None]
|
||||
activity_lines.append(f"{definition['name']}: {number(len(members),0)} runs, {minutes(statistics.mean(times) if times else None)} average known duration.")
|
||||
funnel=stage_funnel(members,definition)
|
||||
for i,stage in enumerate(funnel[1:],1):
|
||||
if stage['dropoffPercent'] is not None and funnel[i-1]['runs']:
|
||||
drops.append({'activity':definition['name'],'from':funnel[i-1]['event'],'to':stage['event'],
|
||||
'dropoff':stage['dropoffPercent'],'previousRuns':funnel[i-1]['runs'],'reach':stage['reachPercent']})
|
||||
biggest=max(drops,key=lambda r:r['dropoff']) if drops else None
|
||||
largest_drop=(f"The largest observed stage loss was in {biggest['activity']}: {pct(biggest['dropoff'])} from {biggest['from']} to {biggest['to']}, among {number(biggest['previousRuns'],0)} runs at the preceding stage. {pct(biggest['reach'])} of all starts reached that stage." if biggest else 'Stage drop-off is N/A because no complete ordered funnel observations are available.')
|
||||
game=conn.execute('SELECT sample_key FROM games WHERE id=%s',(gid,)).fetchone()
|
||||
angle={
|
||||
'willow-v1':'For this social exploration game, inspect whether collection and exploration encourage another visit after the first session.',
|
||||
'orbit-v1':'For this racing game, inspect how quickly a new player reaches a first race and whether the post-race flow encourages another round.',
|
||||
'dungeon-v1':'For this progression game, inspect whether early crafting and quest rewards give new players a clear reason to return.'
|
||||
}.get(game['sample_key'],'Inspect the onboarding and progression steps behind these observed changes.')
|
||||
audience_grew=m['active_players']>prev['active_players']
|
||||
revenue_grew=m['robux']>prev['robux']
|
||||
assessment=('Audience and spending both grew.' if audience_grew and revenue_grew else
|
||||
'Spending grew while the active audience contracted or stayed flat.' if revenue_grew else
|
||||
'The audience grew while spending declined or stayed flat.' if audience_grew else
|
||||
'Active reach and spending were flat or lower than the preceding period.')
|
||||
top_product=(f"{products[0]['name']} led recorded product spending with {money(products[0]['robux'])} from {number(products[0]['units'],0)} units." if products else 'No product sales were reported in this window.')
|
||||
prompt_lines=[]
|
||||
for key,title in [('game','Game-induced'),('player','Player-induced')]:
|
||||
p=prompt_by_source.get(key)
|
||||
if not p: prompt_lines.append(f'{title}: no reported prompts.');continue
|
||||
prompt_lines.append(f"{title}: {number(p['prompts'],0)} prompts, {pct(p['conversion'])} bought; average time to buy {timing(p['buy_time'])}, to close {timing(p['close_time'])}.")
|
||||
engagement_text=f"{report['gameName']} recorded {number(m['sessions'],0)} session starts and {hours(m['total_playtime'])} of known playtime. Average completed sessions lasted {minutes(m['average_session'])} ({change(m['average_session'],prev['average_session'])} versus the prior period). New-user sessions averaged {minutes(extra['new_session'])}; returning-user sessions averaged {minutes(extra['returning_session'])}."
|
||||
retention_text=f"Five-minute first-session retention was {pct(m['first_five_rate'])}. Mature exact-day returns were D1 {pct(m['retention'])}, D7 {pct(retained[7])}, and D30 {pct(retained[30])}; these use cohorts whose observation day fell inside this report window."
|
||||
money_text=f"Reported spending reached {money(m['robux'])} ({change(m['robux'],prev['robux'])}), with {number(m['payers'],0) if not m['unattributed'] else 'N/A'} paying players and {pct(m['payer_conversion'])} period payer conversion. ARPPU was {money(arppu,2)}; Robux per active user-day was {money(arpdau,2)}."
|
||||
concentration=f"The largest recorded payer contributed {pct(largest)} of period spending. Check whether revenue changes persist without that player before treating them as a broad spending trend." if largest is not None else 'Payer concentration is N/A when no attributed positive-price sales are available.'
|
||||
investigations=[
|
||||
{'title':'First-session experience','text':f"Five-minute first-session retention: {pct(m['first_five_rate'])}. {angle}"},
|
||||
{'title':'Activity transition','text':largest_drop+' Review the preceding step before changing the entire activity.' if biggest else largest_drop},
|
||||
{'title':'Offer placement','text':f"Player-induced prompts converted at {pct(player_prompt.get('conversion'))}; game-induced prompts at {pct(game_prompt.get('conversion'))}. Compare the same products and similar player groups before testing offer timing or frequency."}
|
||||
]
|
||||
sections=[
|
||||
{'key':'health','title':'Game health','target':'overview','paragraphs':report['paragraphs']},
|
||||
{'key':'engagement','title':'Engagement & retention','target':'sessions','paragraphs':[{'title':'Session quality','text':engagement_text},{'title':'Returning players','text':retention_text},{'title':'What to inspect','text':angle}]},
|
||||
{'key':'monetization','title':'Monetization','target':'monetization','paragraphs':[{'title':'Spending & conversion','text':money_text},{'title':'Products','text':top_product},{'title':'Payer concentration','text':concentration}]},
|
||||
{'key':'prompts','title':'Purchase prompts','target':'prompts','paragraphs':[{'title':'Source breakdown','text':' '.join(prompt_lines)},{'title':'Interpretation','text':'Source comparisons describe different reported audiences, not a proven effect of prompting. Use matching products and player segments to decide which placement to test.'}]},
|
||||
{'key':'activities','title':'Activities & funnels','target':'activities','paragraphs':[{'title':'Activity use','text':' '.join(activity_lines) or 'No activity runs were reported in this window.'},{'title':'Stage drop-off','text':largest_drop}]},
|
||||
{'key':'investigations','title':'Suggested investigations','target':None,'paragraphs':investigations}
|
||||
]
|
||||
evidence={'mau':extra['mau'],'d7':retained[7],'d30':retained[30],'arppu':arppu,'arpdau':arpdau,
|
||||
'newSession':extra['new_session'],'returningSession':extra['returning_session'],
|
||||
'largestPayerShare':largest,'topProducts':products,'promptSources':prompt_by_source,'largestDrop':biggest}
|
||||
from overview_analysis import deep_analysis
|
||||
executive=deep_analysis(conn,report,start,end,evidence)
|
||||
return {'summaryVersion':4,'executive':executive,'summaries':sections,'summaryEvidence':evidence}
|
||||
@@ -11,6 +11,10 @@ from database import connect
|
||||
from metrics_api import engagement, monetization, gamepasses
|
||||
from sample_api import ApiError, dispatch, player_values, stage_funnel, player_detail, sessions
|
||||
from psycopg.types.json import Jsonb
|
||||
from player_records import record_page,player_inventory
|
||||
from prompt_metrics import prompt_analytics
|
||||
from health_reports import window_metrics,reports_for_game,change
|
||||
from data_quality import quality_log
|
||||
|
||||
|
||||
class MetricDefinitions(unittest.TestCase):
|
||||
@@ -115,6 +119,99 @@ class MetricDefinitions(unittest.TestCase):
|
||||
self.assertEqual(sum(s['joinAvailable'] for s in records),1)
|
||||
self.assertTrue(all(not s['joinAvailable'] for s in records if s['ended_at']))
|
||||
|
||||
def test_record_pages_newest_first_ties_filters_and_game_scope(self):
|
||||
pid=self.players[0]
|
||||
for i in range(25):
|
||||
self.conn.execute("INSERT INTO events(game_id,player_id,name,occurred_at) VALUES(%s,%s,%s,%s)",(self.game,pid,'clicked' if i%2 else 'opened',self.yesterday))
|
||||
self.conn.execute('INSERT INTO player_snapshots(game_id,player_id,captured_at,data) VALUES(%s,%s,%s,%s)',(self.game,pid,self.yesterday+timedelta(seconds=i),Jsonb({'cash':i})))
|
||||
for kind in ['events','snapshots']:
|
||||
first=record_page(self.conn,self.game,pid,kind,{})
|
||||
second=record_page(self.conn,self.game,pid,kind,{'page':['2']})
|
||||
self.assertEqual(first['pagination']['total'],25)
|
||||
self.assertEqual(len(first['records']),20)
|
||||
self.assertEqual(len(second['records']),5)
|
||||
self.assertFalse({r['id'] for r in first['records']} & {r['id'] for r in second['records']})
|
||||
self.assertGreater(first['records'][0]['id'],second['records'][0]['id'])
|
||||
filtered=record_page(self.conn,self.game,pid,'events',{'event':['clicked']})
|
||||
self.assertEqual(filtered['pagination']['total'],12)
|
||||
self.assertTrue(all(r['name']=='clicked' for r in filtered['records']))
|
||||
sales=record_page(self.conn,self.game,pid,'purchases',{'product':[str(self.products[1])]})
|
||||
self.assertEqual(sales['pagination']['total'],0)
|
||||
with self.assertRaises(ApiError): record_page(self.conn,self.game+999,pid,'events',{})
|
||||
|
||||
def test_prompt_timings_conversion_and_unresolved(self):
|
||||
pid=self.players[0]
|
||||
for i,(outcome,seconds) in enumerate([('bought',10),('bought',30),('closed',5),(None,None)]):
|
||||
self.conn.execute('''INSERT INTO interaction_events(game_id,kind,report_key,player_id,product_id,started_at,ended_at,outcome,trigger_source)
|
||||
VALUES(%s,'product_prompt',%s,%s,%s,%s,%s,%s,%s)''',(self.game,str(i),pid,self.products[0],self.yesterday,
|
||||
self.yesterday+timedelta(seconds=seconds) if seconds else None,outcome,['player','game','game','unknown'][i]))
|
||||
data=prompt_analytics(self.conn,self.game,2)
|
||||
row=next(r for r in data['products'] if r['id']==self.products[0])
|
||||
self.assertAlmostEqual(row['conversion'],200/3)
|
||||
self.assertEqual(row['buy_time'],20)
|
||||
self.assertEqual(row['close_time'],5)
|
||||
self.assertEqual(row['unresolved'],0)
|
||||
self.assertEqual(data['metrics']['prompts'],3)
|
||||
sources={r['source']:r for r in data['sourceComparison']}
|
||||
self.assertEqual(sources['player']['conversion'],100)
|
||||
self.assertEqual(sources['game']['conversion'],50)
|
||||
self.assertNotIn('unknown',sources)
|
||||
filtered=prompt_analytics(self.conn,self.game,2,'game')
|
||||
self.assertEqual(filtered['metrics']['prompts'],2)
|
||||
self.assertEqual(filtered['metrics']['buy_time'],30)
|
||||
self.assertEqual(sum(r['prompts'] for r in filtered['products']),2)
|
||||
self.assertEqual(sum(r['prompts'] for r in filtered['sourceComparison']),3)
|
||||
log=quality_log(self.conn,self.game,{})
|
||||
self.assertEqual(log['pagination']['total'],2)
|
||||
self.assertEqual({r['issue'] for r in log['records']},{'prompt_source','prompt_outcome'})
|
||||
self.assertEqual(quality_log(self.conn,self.game,{'issue':['prompt_outcome']})['pagination']['total'],1)
|
||||
self.assertEqual(self.conn.execute('SELECT count(*) n FROM interaction_events WHERE game_id=%s',(self.game,)).fetchone()['n'],4)
|
||||
with self.assertRaises(ApiError): prompt_analytics(self.conn,self.game,2,'unknown')
|
||||
with self.assertRaises(ApiError): prompt_analytics(self.conn,self.game,2,'invalid')
|
||||
with self.assertRaises(psycopg.errors.CheckViolation):
|
||||
with self.conn.transaction():
|
||||
self.conn.execute("UPDATE interaction_events SET trigger_source='invalid' WHERE game_id=%s",(self.game,))
|
||||
|
||||
def test_inventory_orders_purchase_dates_and_aggregates_quantities(self):
|
||||
pid=self.players[0]
|
||||
for n,at in [(1,self.today),(2,self.yesterday),(3,None)]:
|
||||
gp=self.conn.execute('INSERT INTO gamepasses(game_id,roblox_gamepass_id,name) VALUES(%s,%s,%s) RETURNING id',(self.game,n,str(n))).fetchone()['id']
|
||||
self.conn.execute('INSERT INTO gamepass_ownership(game_id,gamepass_id,player_id,is_owned,checked_at,purchased_at) VALUES(%s,%s,%s,true,now(),%s)',(self.game,gp,pid,at))
|
||||
inv=player_inventory(self.conn,self.game,pid)
|
||||
self.assertEqual([p['name'] for p in inv['ownedGamepasses']],['2','1','3'])
|
||||
self.assertEqual(inv['purchasedProducts'][0]['quantity'],2)
|
||||
self.assertEqual(inv['purchasedProducts'][0]['robux'],200)
|
||||
|
||||
def test_daily_health_report_complete_windows_and_cache(self):
|
||||
today=self.today.date()
|
||||
for day in [self.yesterday,self.yesterday-timedelta(days=1)]:
|
||||
for hour,value in enumerate([0,4,None]):
|
||||
self.conn.execute("INSERT INTO game_metrics(game_id,category,name,recorded_at,value) VALUES(%s,'ccu','concurrent_players',%s,%s)",(self.game,day+timedelta(hours=hour),value))
|
||||
metrics=window_metrics(self.conn,self.game,today-timedelta(days=1),today)
|
||||
self.assertEqual(metrics['sessions'],2)
|
||||
self.assertEqual(metrics['average_session'],210)
|
||||
self.assertEqual(metrics['total_playtime'],420)
|
||||
self.assertEqual(metrics['active_players'],2)
|
||||
self.assertEqual(metrics['robux'],250)
|
||||
self.assertEqual(metrics['average_ccu'],2)
|
||||
self.assertEqual(metrics['peak_ccu'],4)
|
||||
self.assertEqual(metrics['known_ccu'],2)
|
||||
first=reports_for_game(self.conn,self.game,today)['reports']
|
||||
again=reports_for_game(self.conn,self.game,today)['reports']
|
||||
self.assertEqual([r['periodDays'] for r in first],[1,7])
|
||||
self.assertEqual(first[0]['generatedAt'],again[0]['generatedAt'])
|
||||
self.assertEqual(first[0]['endDate'],self.yesterday.date().isoformat())
|
||||
self.assertEqual(first[0]['metrics']['robux'],250)
|
||||
self.assertEqual(len(first[0]['summaries']),6)
|
||||
self.assertEqual(first[0]['summaryEvidence']['arppu'],125)
|
||||
self.assertEqual(first[0]['summaryEvidence']['mau'],2)
|
||||
self.assertIsNone(first[0]['summaryEvidence']['d7'])
|
||||
self.assertEqual(first[0]['summaryEvidence']['promptSources'],{})
|
||||
self.assertIn('Metric fixture',first[0]['executive']['paragraphs'][0]['text'])
|
||||
self.assertTrue(first[0]['notes'])
|
||||
self.assertEqual(change(1,0),'up from zero')
|
||||
self.assertEqual(change(None,2),'comparison N/A')
|
||||
|
||||
def test_sale_price_is_independent_of_catalog_price(self):
|
||||
self.conn.execute('UPDATE products SET current_price_robux=9999 WHERE game_id=%s',(self.game,))
|
||||
self.assertEqual(monetization(self.conn,self.game,2)['metrics']['revenue'],250)
|
||||
@@ -149,6 +246,34 @@ class SeededApi(unittest.TestCase):
|
||||
cls.games=dispatch('/api/games',{})['games']
|
||||
cls.game=cls.games[0]['id']
|
||||
|
||||
def test_hall_of_fame_and_seven_day_retention(self):
|
||||
data=dispatch(f'/api/games/{self.game}/players',{})
|
||||
self.assertEqual(data['pagination']['pageSize'],20)
|
||||
for board in data['leaderboards']:
|
||||
values=[p['value'] for p in board['players']]
|
||||
self.assertEqual(len(values),5)
|
||||
self.assertEqual(values,sorted(values,reverse=True))
|
||||
week=dispatch(f'/api/games/{self.game}/retention',{'days':['30']})['weekRetention']
|
||||
self.assertEqual(len(week['labels']),7)
|
||||
self.assertGreater(week['players'],0)
|
||||
self.assertTrue(all(v is None or 0<=v<=100 for v in week['series'][0]['values']))
|
||||
|
||||
def test_summaries_are_complete_per_game_and_period(self):
|
||||
texts=[]
|
||||
for game in self.games:
|
||||
data=dispatch(f"/api/games/{game['id']}/reports",{})
|
||||
for report in data['reports']:
|
||||
self.assertEqual(report['gameId'],game['id'])
|
||||
self.assertEqual(report['summaryVersion'],4)
|
||||
self.assertEqual(len(report['summaries']),6)
|
||||
self.assertEqual({r['key'] for r in report['summaries']},{'health','engagement','monetization','prompts','activities','investigations'})
|
||||
self.assertGreater(len(report['executive']['paragraphs']),2)
|
||||
self.assertEqual(len(report['executive']['paragraphs']),6)
|
||||
self.assertIn('economyEvidence',report['executive'])
|
||||
self.assertNotIn('Average CCU was',report['executive']['paragraphs'][0]['text'])
|
||||
if report['periodDays']==7: texts.append(report['executive']['paragraphs'][0]['text'])
|
||||
self.assertEqual(len(texts),len(set(texts)))
|
||||
|
||||
def test_daily_event_lines_match_summary(self):
|
||||
data=dispatch(f'/api/games/{self.game}/events',{'days':['7']})
|
||||
self.assertNotIn('events',data)
|
||||
@@ -207,7 +332,8 @@ class ActivityStages(unittest.TestCase):
|
||||
self.assertEqual(stages[1]['dropoffPercent'],25)
|
||||
self.assertAlmostEqual(stages[2]['dropoffPercent'],200/3)
|
||||
runs.append({'stage_progress':None})
|
||||
self.assertIsNone(stage_funnel(runs,definition)[1]['dropoffPercent'])
|
||||
self.assertEqual(stage_funnel(runs,definition)[1]['dropoffPercent'],25)
|
||||
self.assertEqual(stage_funnel(runs,definition)[0]['runs'],4)
|
||||
self.assertIsNone(stage_funnel([],definition)[0]['reachPercent'])
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
|
||||
65
mockups/tests/test_launcher.py
Normal file
65
mockups/tests/test_launcher.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Exercise real Windows process cleanup without touching PostgreSQL."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt', 'Windows launcher')
|
||||
class LauncherLifecycle(unittest.TestCase):
|
||||
def test_forced_owner_exit_stops_descendants(self):
|
||||
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
kernel.WaitForSingleObject.restype = wintypes.DWORD
|
||||
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
code = '''from start_dashboard import TerminalJob
|
||||
import subprocess, sys, time
|
||||
job = TerminalJob()
|
||||
child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print(child.pid, flush=True)
|
||||
time.sleep(60)
|
||||
'''
|
||||
owner = subprocess.Popen([sys.executable, '-u', '-c', code], cwd=ROOT,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
child_handle = None
|
||||
try:
|
||||
line = owner.stdout.readline().strip()
|
||||
if not line:
|
||||
self.fail(owner.communicate(timeout=5)[1])
|
||||
child_handle = kernel.OpenProcess(0x100000, False, int(line)) # SYNCHRONIZE
|
||||
self.assertTrue(child_handle)
|
||||
owner.kill()
|
||||
owner.wait(timeout=5)
|
||||
self.assertEqual(kernel.WaitForSingleObject(child_handle, 5000), 0,
|
||||
'A child survived the owner terminal process exiting.')
|
||||
finally:
|
||||
if owner.poll() is None:
|
||||
owner.kill()
|
||||
owner.communicate(timeout=5)
|
||||
if child_handle:
|
||||
kernel.CloseHandle(child_handle)
|
||||
|
||||
def test_busy_port_leaves_existing_service_alone(self):
|
||||
with socket.socket() as listener:
|
||||
listener.bind(('127.0.0.1', 0))
|
||||
listener.listen()
|
||||
env = dict(os.environ, MOCKUP_PORT=str(listener.getsockname()[1]))
|
||||
result = subprocess.run([sys.executable, 'start_dashboard.py', '--no-browser'],
|
||||
cwd=ROOT, env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertTrue('already in use' in result.stderr or 'already running' in result.stderr,result.stderr)
|
||||
with socket.create_connection(listener.getsockname(), timeout=1):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user