Expand dashboard analytics and document production implementation

This commit is contained in:
maxwes08
2026-11-03 14:49:01 +01:00
parent 70b94f8672
commit aa0028e7e0
36 changed files with 2212 additions and 76 deletions

View File

@@ -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')})