Add PostgreSQL analytics dashboard prototype and realistic sample data
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
.local/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
189
PROJECT.md
Normal file
189
PROJECT.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Roblox Game Analytics Dashboard
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
Build a custom analytics data logger and web dashboard for Roblox games. Roblox experiences send event data to the service over HTTP requests; game owners use the web frontend to inspect activity, retention, playtime, and other metrics. The system must also support game-defined custom datasets so developers can record and analyze data specific to their experience.
|
||||||
|
|
||||||
|
The service is intended for the owner's team and shared games, rather than public self-service signups. The first implementation will use Lua with Lapis and run on a self-hosted VPS. The web frontend will use React and TypeScript, with Apache ECharts for graphs rendered directly in the dashboard.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Provide a reliable, documented API for Roblox experiences to submit analytics events.
|
||||||
|
- Track player sessions, retention, playtime, and other common game metrics.
|
||||||
|
- Let each game define and send custom datasets/events without requiring a backend code change for every new metric.
|
||||||
|
- Provide a web frontend to explore metrics over time and inspect relevant underlying records.
|
||||||
|
- Store analytics data in PostgreSQL and support multiple games/projects with clear data isolation.
|
||||||
|
- Track engagement and monetization with documented calculations and chart-based views, including individual reported product sales.
|
||||||
|
|
||||||
|
## Core concepts and data
|
||||||
|
|
||||||
|
- **Project/game:** A registered game rooted at its internal database `games.id`. Every dataset references this internal `game_id`; the Roblox experience ID is an optional child property of the game record and is never used as the dataset root.
|
||||||
|
- **Player:** A Roblox user identified by their Roblox user ID within a project. Avoid collecting personal information that is not needed for analytics.
|
||||||
|
- **Player snapshot:** A timestamped copy of the player stats reported by the game, such as cash, level, cumulative playtime, session count, and game-reported Robux spending. Keep the latest values available for sorting and retain snapshot history for trends and progress analysis.
|
||||||
|
- **Session:** A play session with a start time, end time or heartbeat/last-seen time, duration, and optional context such as place or server.
|
||||||
|
- **Activity run:** A user-defined interval inside a game session, such as a shop visit or minigame run, paired from configured start and end events. It can include ordered steps, prompts, outcomes, and captured custom values. Store each run with its player ID for lookup in that player's profile, while the activity analytics page reports aggregates such as average/median duration, completion, prompt, and purchase rates across runs.
|
||||||
|
- **Event:** A timestamped record used to calculate standard metrics or represent a custom event/dataset. Events may include validated properties supplied by the game.
|
||||||
|
- **Metrics:** Aggregations such as active players, session counts, average playtime, retention cohorts, and custom event counts or values.
|
||||||
|
- **Game-level data:** Categorized events and metric time samples that have no player binding, such as visits, CCU, server events, and performance reports. These belong directly to the internal game root.
|
||||||
|
- **Product sale:** A unique receipt under a game, with product ID, actual unit price at sale time, quantity, timestamp, and player/session references when available. Store catalog metadata separately so price changes do not rewrite past sales.
|
||||||
|
|
||||||
|
Keep the event model modular so a variety of event types and game-defined properties can be stored without a schema change for every new event. Build common, game-independent metrics such as retention and playtime on top of this event data, with room to add further broadly useful metrics.
|
||||||
|
|
||||||
|
Use PostgreSQL as the source of truth. Keep project ownership and credentials relationally scoped; design event storage and indexes for time-based, per-project queries. Custom event properties should be flexible enough to evolve while retaining searchable fields and sensible validation/size limits. Define retention and aggregation strategies as data volume grows.
|
||||||
|
|
||||||
|
Use composite foreign keys to keep player, session, event, activity, and product references inside the same game. Keep unavailable source values null or absent; display `N/A` for missing values, retain known zeros, and leave gaps in charts instead of inventing observations.
|
||||||
|
|
||||||
|
## API and Roblox ingestion
|
||||||
|
|
||||||
|
- The Roblox integration is a ModuleScript created and maintained by the project owner. The web service provides the HTTP API and its contract; the ModuleScript is a client of that API, not a deliverable of the web backend.
|
||||||
|
- The ModuleScript accepts a project API key and Lua tables, JSON-encodes the tables, and sends them to the HTTPS API using Roblox HTTP services (for example, `HttpService:RequestAsync`). The API should accept JSON request bodies and return documented JSON responses.
|
||||||
|
- Provide endpoints for session start/update/end and for submitting custom events or dataset records. Prefer a small, versioned JSON API with documented request and response formats so the ModuleScript can send both standard analytics and arbitrary game-defined data.
|
||||||
|
- Authenticate game ingestion with per-project API keys. Store keys securely, support rotation/revocation, and never expose them to dashboard clients. The key must only be used by server-side Roblox scripts; it must not be shipped to clients where players can extract it.
|
||||||
|
- Validate project credentials, timestamps, identifiers, event names, payload size, and allowed property shapes. Apply rate limits and return clear status codes so Roblox scripts can handle failures.
|
||||||
|
- Make ingestion safe to retry: accept an optional event/request identifier and enforce idempotency where appropriate. Do not assume requests arrive exactly once or in order.
|
||||||
|
- Roblox requests may be delayed, duplicated, or unavailable temporarily. Document response codes and retry behavior so the ModuleScript can retry transient failures without creating duplicate sessions/events.
|
||||||
|
- The ModuleScript will send session-start data, session-end data with session statistics, and other events when they occur. Heartbeat-based session tracking is not required by the current plan.
|
||||||
|
- Support player-stat snapshots submitted by the game, associated with the Roblox user ID and capture time. Store a copy of each accepted snapshot so the dashboard can show current values and how those values change over time. The game controls which custom fields it reports; validate field names, types, and payload sizes.
|
||||||
|
- Support configurable activity definitions that pair start/end events and optionally track intermediate events, outcomes, timeouts, and selected property values. Persist individual activity runs against the associated player for profile history; aggregate activity run statistics across players for analysis. The development prototype now persists seeded definitions and runs in PostgreSQL; definition editing and live event ingestion remain future work.
|
||||||
|
- Report each trusted product sale with receipt ID, product ID, price, quantity, timestamp, and player. Deduplicate receipts within each game and derive monetization metrics from stored sale reports.
|
||||||
|
- Whether each HTTP request contains one event or a batch of events is undecided; choose this when defining the API contract, taking Roblox request limits and retry behavior into account.
|
||||||
|
|
||||||
|
## Web frontend
|
||||||
|
|
||||||
|
- Provide a private team dashboard for shared games, with login and cookie-based sessions. Public account creation and user-managed signup are out of scope for the initial plan.
|
||||||
|
- Save dashboard preferences such as selected project or display settings where useful. Project/game access should remain private to the team.
|
||||||
|
- Support project selection, date/time ranges, and useful filters such as place, event name, or custom properties when available.
|
||||||
|
- Show overview metrics, session/playtime views, retention cohorts, and custom dataset/event exploration.
|
||||||
|
- Provide a Players area with a searchable, sortable table of each player's latest reported stats. Initial sortable values: cash, level, game-reported Robux spent, cumulative playtime, session count, and last seen. A player detail view can show snapshot history and session/event activity.
|
||||||
|
- Render charts in the application with Apache ECharts so they share the dashboard's navigation, filters, and visual design.
|
||||||
|
- Have the API return aggregated, time-bucketed results for charts instead of sending large volumes of raw events to the browser for aggregation.
|
||||||
|
- Make metric definitions and timezone/date-range behavior clear. Avoid presenting incomplete or delayed ingestion as exact real-time data.
|
||||||
|
- Keep all project data isolated in both API authorization and database queries.
|
||||||
|
|
||||||
|
## Website pages and navigation
|
||||||
|
|
||||||
|
Use the supplied analytics dashboard screenshots as visual inspiration: a compact dark interface, persistent navigation, clear filters, and charts that emphasize trends. Build the product's own layout and visual identity rather than copying another dashboard.
|
||||||
|
|
||||||
|
### App shell
|
||||||
|
|
||||||
|
- **Desktop:** A fixed, collapsible left sidebar with the product mark, project selector, and primary navigation. The main area has a slim top bar with breadcrumbs/page title, date range and timezone controls where relevant, refresh status, and account/session menu. Keep filters close to the charts they affect.
|
||||||
|
- **Phone:** Use a compact top bar for the current project and page title, a bottom navigation bar for the four main destinations (Overview, Analytics, Events, More), and a drawer or sheet for secondary pages and filters. Stack metric cards and charts vertically; allow wide charts and data tables to scroll horizontally or switch to a compact list. Keep date selection and key filters usable without desktop hover behavior.
|
||||||
|
- **Visual style:** Dark mode by default, with layered charcoal surfaces, restrained borders, readable muted labels, and one bright accent color for primary actions and selected data series. Use accessible text contrast, visible focus states, and distinguish chart series with more than color alone. Consider a light theme later, but do not block the initial dark theme on it.
|
||||||
|
- **Shared controls:** Project selector, date range presets and custom range, timezone indicator, loading/empty/error states, refresh action, and clear active-filter chips. Preserve useful filters when moving between analytics views.
|
||||||
|
|
||||||
|
### Pages and tabs
|
||||||
|
|
||||||
|
1. **Login**
|
||||||
|
- Private team sign-in; no public registration.
|
||||||
|
- Clear invalid-login and expired-session states; provide logout from the account menu.
|
||||||
|
|
||||||
|
2. **Overview** (main navigation)
|
||||||
|
- At-a-glance cards for active players, sessions, average playtime, and returning players/retention for the selected range.
|
||||||
|
- Trend charts with comparison to the previous equivalent period when available.
|
||||||
|
- Recent activity or notable event counts, plus ingestion freshness/status.
|
||||||
|
- Links into Sessions, Retention, and Events with the current project and date range preserved.
|
||||||
|
|
||||||
|
3. **Analytics** (main navigation with tabs)
|
||||||
|
- **Sessions tab:** Session counts, duration distribution, average/median playtime, sessions over time, and a paginated session table with player, start/end, duration, and available context.
|
||||||
|
- **Retention tab:** Cohort retention table/heatmap and retention trend, with clearly labeled cohort dates and return windows (for example day 1 and day 7).
|
||||||
|
- **Players tab:** Aggregate player activity and returning/new player trends. Avoid exposing more player-level detail than needed.
|
||||||
|
- All tabs use shared date, place, and other applicable filters.
|
||||||
|
|
||||||
|
4. **Events** (main navigation)
|
||||||
|
- **Explore tab:** Select an event type/name, time range, and supported property filters; view event volume over time and aggregate numeric properties.
|
||||||
|
- **Records tab:** Paginated raw event records for debugging and inspection, with timestamp, event name, player/session identifiers when provided, and expandable JSON properties.
|
||||||
|
- **Event catalog tab:** Discover event names and observed property keys/types for the selected project; show first/last seen and volume.
|
||||||
|
- Clearly distinguish standard events used by built-in metrics from custom game-defined events.
|
||||||
|
|
||||||
|
5. **Players** (main navigation)
|
||||||
|
- Provide column templates that choose which of the many available player fields appear in the table. Start with Overview, Economy, Progress, Engagement, and All values; the All values table can scroll horizontally.
|
||||||
|
- Populate a sort-by dropdown from all available standard, game-reported, and derived player value types; sort the current player list by the selected value.
|
||||||
|
- Search players and show their latest saved snapshot values, including cash, level, game-reported Robux spent, cumulative playtime, session count, and last seen.
|
||||||
|
- Show snapshot freshness and allow filtering by ranges or activity where supported.
|
||||||
|
- Save the selected column template and sort preference for the dashboard user. A player detail view can show snapshot history, progress over time, sessions, and related events.
|
||||||
|
- Provide a player info view with all available player data, value history over a selected time window, average and total session measures, and that player's individual activity runs and events.
|
||||||
|
- Offer player groups such as whales (by trusted spend values), top playtime, and first-time players. Make group definitions and time windows clear.
|
||||||
|
|
||||||
|
6. **Activity Sessions** (main navigation)
|
||||||
|
- Let the team define custom activity journeys such as shop visits, minigame runs, and quest attempts, including start/end events, timeout, intermediate steps, prompts, outcomes, and captured values.
|
||||||
|
- Show activity-level aggregates across runs, including average/median run time, outcome rates, prompt-to-purchase rate, and captured-value summaries.
|
||||||
|
- Keep individual run details associated with a player and available in that player's info view; do not use the aggregate activity list as a player-by-player session list.
|
||||||
|
- The sample backend reads seeded definitions and stored runs from PostgreSQL. Definition management and live event pairing remain future work.
|
||||||
|
|
||||||
|
7. **Monetization** (main navigation)
|
||||||
|
- Charts for hourly/daily Robux spending, daily paying users, daily payer conversion, daily ARPDAU, and Robux totals by product.
|
||||||
|
- Period cards for total reported Robux, paying users, payer conversion, average Robux per payer, products per payer, and ARPDAU (also called average Robux per daily active user).
|
||||||
|
- List every product with its product ID, current price, reported sale revenue, paid quantity, distinct buyers, and decimal average units bought per paying user. The per-product denominator is all period paying users.
|
||||||
|
- Inspect individual sale reports with the price captured on that receipt; do not estimate historical revenue from current product prices.
|
||||||
|
|
||||||
|
8. **Projects** (More on phone; sidebar on desktop)
|
||||||
|
- List and select shared games/projects; create, rename, or archive a project.
|
||||||
|
- Project overview with its Roblox experience identifier and data/ingestion status.
|
||||||
|
|
||||||
|
9. **Project Settings** (within a selected project)
|
||||||
|
- **General tab:** Project name, Roblox experience/place identifiers, timezone/display preferences.
|
||||||
|
- **API Keys tab:** Create, label, revoke, and rotate ingestion keys; show a new key only at creation and explain that it belongs in server-side Roblox scripts.
|
||||||
|
- **Data tab:** Event and property validation limits, data retention policy/status, and project-specific ingestion settings as implemented.
|
||||||
|
|
||||||
|
10. **Account / Session** (account menu; More on phone)
|
||||||
|
- Current login/session information and logout. Team account administration is not part of the initial scope.
|
||||||
|
|
||||||
|
### Initial navigation priority
|
||||||
|
|
||||||
|
Keep the first release focused on Login, Overview, Analytics (Sessions and Retention tabs), Players (sortable snapshots and player info), Events (Explore and Records), Activity Sessions (aggregate activity analytics), Monetization, and Project Settings (General and API Keys). Add the event catalog and further project/data controls as the underlying metrics and schema support them.
|
||||||
|
|
||||||
|
## Required engagement measurements
|
||||||
|
|
||||||
|
Record or calculate CCU, DAU, new and returning users, session count, total playtime, average playtime per active player, average session duration, new-user and returning-user session duration, and the share of known first sessions reaching five minutes. Calculate rolling 30-day MAU independently for every day and graph these measures wherever useful. Daily new/returning classification uses the player's first-seen UTC date.
|
||||||
|
|
||||||
|
Monetization includes hourly/daily Robux spent, sale-price totals per product, paying users, payer conversion, average Robux per paying user, average paid units per paying user (including a decimal count for each product), and ARPDAU. ARPDAU and average Robux spent per daily active user are the same measurement and should use a consistent definition.
|
||||||
|
|
||||||
|
Precise formulas, time windows, and missing-data behavior are documented in [mockups/METRICS.md](mockups/METRICS.md).
|
||||||
|
|
||||||
|
## Current development data layer
|
||||||
|
|
||||||
|
The HTML prototype reads a local PostgreSQL database through a temporary read-only Python API. Migrations, a repeatable seed generator, and game-scoped endpoints are in `mockups/`. The sample contains three independent games and 90 days of players, sessions, snapshots, categorized events, game metrics, activity runs, and product sales. Setup and run instructions are in [mockups/README.md](mockups/README.md). The planned production stack remains Lua/Lapis and React/TypeScript with ECharts.
|
||||||
|
|
||||||
|
## Initial scope
|
||||||
|
|
||||||
|
1. Private team login, project management, and API credential management.
|
||||||
|
2. Secure ingestion for session lifecycle and custom events.
|
||||||
|
3. PostgreSQL schema and queries for session, playtime, and event data.
|
||||||
|
4. Modular event storage and a configurable analytics overview with date filtering.
|
||||||
|
5. Player snapshots and a searchable/sortable player list, plus game-independent retention and playtime reporting and flexible custom event exploration.
|
||||||
|
6. API documentation, including the JSON table shape, authentication header, response codes, limits, and retry/idempotency behavior needed by the owner-created ModuleScript.
|
||||||
|
|
||||||
|
## Design considerations
|
||||||
|
|
||||||
|
- Keep the ingestion path efficient; move expensive aggregations off the request path as needed.
|
||||||
|
- Use UTC for stored timestamps and make dashboard display timezone explicit.
|
||||||
|
- Plan for pagination, query limits, indexes, and eventual partitioning/rollups as event volume increases.
|
||||||
|
- Treat analytics payloads as untrusted input and enforce per-project access at every read and write boundary.
|
||||||
|
- Backend: Lua with Lapis. Frontend: React with TypeScript. Charts: Apache ECharts. Deployment target: a self-hosted VPS with PostgreSQL. Choose compatible PostgreSQL access and migration tooling as part of implementation.
|
||||||
|
- Dashboard login should use secure cookie-based sessions. Keep team access private; settle the initial login mechanism and secret/session configuration during implementation.
|
||||||
|
- Player snapshots are game-reported analytics data. Label Robux-spent values as game-reported; only record purchases from trusted, server-validated Roblox purchase/receipt flows, never values asserted solely by a client. Whether to retain every snapshot indefinitely or apply a configurable history window remains an implementation decision.
|
||||||
|
|
||||||
|
## Player metrics to consider
|
||||||
|
|
||||||
|
In addition to raw snapshot fields, useful sortable or trendable measures include:
|
||||||
|
|
||||||
|
- **Progress:** Levels gained, experience gained, quests completed, achievements unlocked, and time to reach a level or milestone.
|
||||||
|
- **Engagement:** Days active in a selected period, days since last seen, sessions per active day, average/median session duration, and return frequency.
|
||||||
|
- **Economy:** Current cash/balance, currency earned and spent over a period, net balance change, purchases made, and game-reported Robux spending. Keep premium purchase reporting separate from in-game currency unless the game explicitly maps them.
|
||||||
|
- **Progress efficiency:** Levels gained per hour, currency earned per hour, or time-to-next milestone, when the game reports enough consistent data to calculate these fairly.
|
||||||
|
- **Social and gameplay:** Friends/party participation, rounds played/won, win rate, matchmaking or mode preference, and other game-defined counters.
|
||||||
|
|
||||||
|
Prefer clear time windows and definitions for derived measures; for example, show whether playtime is lifetime or selected-period, and calculate rates only when the denominator is nonzero and the underlying snapshot history is available.
|
||||||
|
## Analytics UI refinements
|
||||||
|
|
||||||
|
- Player value history compares up to two numeric values with independent left/right scales and hover inspection. Include custom numeric snapshot fields, cash, level, recorded Robux spending, purchases, completed playtime, session count, average/median session duration, active days, and Robux per playtime hour. Derived history is calculated as of each snapshot, never from future records.
|
||||||
|
- Player session tables replace Place with a Join session placeholder. Only unended sessions started within six hours have an enabled button; ended or stale sessions are disabled. Real Roblox joining remains unconnected.
|
||||||
|
- The Events per day chart has searchable event checkboxes and All/None controls. Remember selection per game; preserve event colors when filtering. The summary remains game-wide.
|
||||||
|
|
||||||
|
The sample database uses benchmark-informed synthetic histories documented in `mockups/SAMPLE_DATA.md`: one-session churn, declining exact-day retention, skewed session durations, rare heavy spenders, and coherent sales, snapshots, activity stages, visits, and CCU. Public reference figures guide plausible ranges; no real players' private data is imported. The bundled samples can be replaced transactionally with `manage_database.py reset-sample`, preserving internal game IDs and unrelated projects.
|
||||||
|
|
||||||
|
- Monetization has Revenue and Gamepasses tabs. Each gamepass shows ownership as a percentage of all tracked players and of lifetime paying players, plus reporting coverage. Ownership is stored explicitly per game/player/pass; missing checks remain unknown and affected percentages show N/A.
|
||||||
|
- Sessions aggregates and graphs can be filtered by player category: all players, whales, top playtime, or first-time players.
|
||||||
|
- Events shows daily counts with one graph line per event name and a summary row for each event.
|
||||||
|
- Activity funnels show the percentage reaching each ordered stage, conversion from the previous stage, and drop-off from that stage. Missing stage observations show N/A.
|
||||||
|
- Remove global recent-session, recent-event, and recent-sale panels, the unattributed-sales card, and game-root metric category tables from the UI. Keep the underlying records, game-level storage, and individual player history tabs.
|
||||||
58
mockups/METRICS.md
Normal file
58
mockups/METRICS.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Metric definitions
|
||||||
|
|
||||||
|
The sample calculates metrics under an internal `game_id`, using UTC calendar days. The selected 14-day range, for example, includes today and the previous 13 days. Today's data is partial. All records are synthetic. Recents tables show the latest stored records; their captions state their limits.
|
||||||
|
|
||||||
|
Unknown source values stay `NULL` in the API and display as `N/A`. An empty known count is zero. Ratios with zero denominators are `N/A`. Missing durations are excluded from duration averages, rather than treated as zero. Snapshot values are game-reported; session and sales aggregates are derived from their own stored records.
|
||||||
|
|
||||||
|
## Activity and sessions
|
||||||
|
|
||||||
|
| Measurement | Definition |
|
||||||
|
| --- | --- |
|
||||||
|
| CCU | Latest game-level concurrent-player report. A missing latest report remains N/A; the chart averages available reported samples per day. |
|
||||||
|
| Visits | Sum of game-level daily visit reports in the selected range. The sample generates these from session starts, counting repeat visits. |
|
||||||
|
| DAU | Distinct players with a session overlapping the UTC day, a player event on that day, or a sale attributed to them on that day. An unended session only proves activity on its start day. |
|
||||||
|
| Calculated MAU per day | Distinct active players across the 30 calendar days ending on each graph day, inclusive. It is recalculated for every day, not a monthly total copied across days. |
|
||||||
|
| New users | Daily active players whose first-seen UTC date is that day. |
|
||||||
|
| Returning users | Daily active players first seen before that day. New + returning = DAU. |
|
||||||
|
| Session count | Number of sessions starting in the selected period, including unended sessions. |
|
||||||
|
| Total playtime | Known completed-session time overlapping the selected days. Sessions crossing midnight are split between days. Unknown end times contribute no invented duration. |
|
||||||
|
| Average playtime | Known completed playtime divided by unique active players in the period. The daily graph divides each day's playtime by that day's DAU. |
|
||||||
|
| Average session time | Average duration of completed sessions starting in the period. Median is also shown. |
|
||||||
|
| New-user session time | Average completed-session duration for sessions starting on the player's first-seen UTC day. |
|
||||||
|
| Returning-user session time | Average completed-session duration for sessions starting on a later UTC day. |
|
||||||
|
| First-session five-minute retention | Players whose first completed-known session lasted at least 300 seconds / players with a known end for their first session in the selected cohort window. Missing first-session end times are excluded from the denominator, not counted as failures. |
|
||||||
|
| Day 1 / Day 7 retention | Share of players returning on the exact UTC day 1 or day 7 after their first session. Only cohorts in the selected period with a full observation day are eligible. Cohort percentages are weighted by player count. |
|
||||||
|
| Activity run time | Configured activity start to configured activity end. Activity pages aggregate runs; individual runs are available in player info. Incomplete runs show N/A time/outcome. |
|
||||||
|
|
||||||
|
## Monetization
|
||||||
|
|
||||||
|
Each sale row records its product, price **at sale time**, quantity, receipt identifier, time, and player/session when known. `(game_id, receipt_id)` is unique. The real Roblox ModuleScript integration should report trusted server-side receipt data; sample seeding only demonstrates this storage contract. No live Roblox product catalog fetch or real sale ingestion is implemented yet.
|
||||||
|
|
||||||
|
| Measurement | Definition |
|
||||||
|
| --- | --- |
|
||||||
|
| Hourly / daily Robux spent | Sum of reported unit price × quantity per UTC hour/day. These are gross reported Robux, not a conversion to cash or net proceeds. |
|
||||||
|
| Robux per product | Sum of actual reported sale prices × quantities for that product in the selected period. Current catalog price is displayed separately. |
|
||||||
|
| Paying users | Distinct players with at least one positive-price sale in the selected period. Free claims do not create payers. |
|
||||||
|
| Payer conversion | Period paying users / unique period active users × 100. The daily graph uses daily payers / DAU. |
|
||||||
|
| Average Robux per paying user (ARPPU) | Period Robux spent / distinct period paying users. |
|
||||||
|
| Average products bought per paying user | Total positive-price product units / all distinct period paying users. Decimal values are intentional. |
|
||||||
|
| Per-product units per paying user | That product's paid units / **all** period paying users, including payers who did not buy that particular product. Every catalog product is listed. |
|
||||||
|
| ARPDAU / average Robux per daily active user | These are the same measurement: daily Robux / DAU. The period card uses total period Robux / sum of daily active user counts (user-days), which is the weighted daily average. It does not divide by unique period players. |
|
||||||
|
| Sales / transactions | Count of unique reported receipts, including zero-price records. |
|
||||||
|
| Unattributed sales | Positive-price reports with no player ID. Total Robux remains known, but payer ratios become N/A when attribution is incomplete. |
|
||||||
|
|
||||||
|
## Gamepass ownership and activity stages
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Player group defaults
|
||||||
|
|
||||||
|
Player history accepts `value` and optional `value2` and plots them at stored snapshot timestamps with independent left/right scales. Numeric custom snapshot fields are discoverable. Derived session and receipt totals use only records at or before that timestamp; incomplete durations remain N/A. Join session is a UI placeholder enabled only for unended sessions started within the past six hours. This age rule is a mockup heuristic, not confirmation that a Roblox server is still joinable.
|
||||||
|
|
||||||
|
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.
|
||||||
93
mockups/README.md
Normal file
93
mockups/README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# PostgreSQL-backed development mockups
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File mockups/setup-database.ps1
|
||||||
|
powershell -ExecutionPolicy Bypass -File mockups/start-mockups.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Setup creates `.venv`, installs `requirements.txt`, and provisions PostgreSQL 18.6 from the [official EDB binary distribution](https://www.enterprisedb.com/download-postgresql-binaries). The local database listens only on `127.0.0.1:55432`. Its generated password, connection settings, binaries, and data directory stay in ignored `.local/` files. Setup applies migrations and seeds the sample once; running it again preserves the existing data.
|
||||||
|
|
||||||
|
An existing database can be used by setting `DATABASE_URL` before running setup. It must be a development database where the configured role can create the `analytics` schema. No local PostgreSQL download is performed when this environment variable is set.
|
||||||
|
|
||||||
|
Open <http://127.0.0.1:8011/mockups/overview-mockup.html>. The game selector switches between three independent game datasets. For manually running Python commands, use `.venv/Scripts/python.exe` so the PostgreSQL driver is available.
|
||||||
|
|
||||||
|
## Sample data
|
||||||
|
|
||||||
|
The benchmark-informed generator creates 90 days of synthetic records relative to setup time, with 18,000 players across three games. Most players have one session; return rates decline sharply over time. Session lengths, progression, purchases, activity funnels, and gamepass ownership vary by player behavior. A small heavy-spending group accounts for a large share of revenue. Visits and CCU come from the same session histories.
|
||||||
|
|
||||||
|
See [SAMPLE_DATA.md](SAMPLE_DATA.md) for public Roblox references and model assumptions. These are synthetic histories, not imported real player data. Run `manage_database.py status` for current record counts.
|
||||||
|
|
||||||
|
To replace only the three bundled sample datasets while keeping game IDs and unrelated projects:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv/Scripts/python.exe mockups/manage_database.py reset-sample
|
||||||
|
.venv/Scripts/python.exe mockups/sample_report.py
|
||||||
|
```
|
||||||
|
|
||||||
|
There are deliberately missing names, snapshot fields, metric reports, session end times, and one missing Roblox experience ID. PostgreSQL keeps unknowns as `NULL` or absent JSON properties; the UI displays `N/A`. Zero remains zero, incomplete sessions do not get an invented duration, and missing chart observations appear as gaps.
|
||||||
|
|
||||||
|
## Game roots and data organization
|
||||||
|
|
||||||
|
`games.id` is the internal database identity and the root of every dataset. `games.roblox_experience_id` is an optional property of that root. The IDs are not interchangeable.
|
||||||
|
|
||||||
|
- `players` and `sessions` are scoped to `game_id`. Sessions reference an internal player ID.
|
||||||
|
- `player_snapshots` keeps timestamped copies of custom player data in JSONB.
|
||||||
|
- `events` is categorized and always belongs to a game. Its player and session references are optional, so announcements, server events, and other unbound data stay directly under the game.
|
||||||
|
- `game_metrics` holds named, categorized time samples such as visits, CCU, and server performance, without a player reference.
|
||||||
|
- `activity_definitions` and `activity_runs` support aggregate journey analytics and individual player history.
|
||||||
|
- `products` stores the Roblox product ID and catalog metadata. `product_sales` stores each unique receipt with time, actual unit price, quantity, product, and player/session when available. Updating catalog prices does not rewrite historical sales.
|
||||||
|
|
||||||
|
Composite foreign keys enforce game boundaries for player, session, activity, and product relationships. Source migrations are in `db/`; `manage_database.py init` applies unapplied migrations and seeds an empty sample set without deleting other records.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Start with `GET /api/games`, then use the returned **internal** ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/games/{game_id}
|
||||||
|
/api/games/{game_id}/overview
|
||||||
|
/api/games/{game_id}/sessions
|
||||||
|
/api/games/{game_id}/engagement
|
||||||
|
/api/games/{game_id}/retention
|
||||||
|
/api/games/{game_id}/events
|
||||||
|
/api/games/{game_id}/metrics
|
||||||
|
/api/games/{game_id}/players
|
||||||
|
/api/games/{game_id}/players/{internal_player_id}
|
||||||
|
/api/games/{game_id}/activities
|
||||||
|
/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.
|
||||||
|
|
||||||
|
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, 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.
|
||||||
|
|
||||||
|
Monetization includes a Gamepasses tab (`gamepasses-mockup.html`) with ownership percentages and reporting coverage. Events shows daily lines per event and aggregate summaries. Sessions supports player-group filtering; activity journeys display stage reach and drop-off. Individual session, event, activity, and purchase records remain accessible in player info.
|
||||||
|
|
||||||
|
Page layouts are generated by `build_pages.py`. After editing the generator, run `.venv/Scripts/python.exe mockups/build_pages.py` from the repository root to refresh the HTML pages.
|
||||||
|
|
||||||
|
## Checks and database control
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv/Scripts/python.exe mockups/manage_database.py status
|
||||||
|
.venv/Scripts/python.exe -m unittest discover -s mockups/tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The integration checks insert fixture records in transactions and roll them back. They verify known metric arithmetic, missing-data behavior, historical sale prices, receipt deduplication, game isolation, player lookup, and missing-last sorting.
|
||||||
|
|
||||||
|
To stop only the local sample database:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.local/pgsql/bin/pg_ctl.exe -D .local/pgdata -m fast stop
|
||||||
|
```
|
||||||
35
mockups/SAMPLE_DATA.md
Normal file
35
mockups/SAMPLE_DATA.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Benchmark-informed sample histories
|
||||||
|
|
||||||
|
All users, names, receipts, and histories are synthetic. No private Roblox analytics or real player records are imported. The game IDs and Roblox experience IDs are demo identifiers, not claims about those games' performance.
|
||||||
|
|
||||||
|
## Public references checked 3 November 2026
|
||||||
|
|
||||||
|
- [GameAnalytics 2026 Roblox Benchmark Report](https://www.gameanalytics.com/reports/2026-roblox-report): its sample covers 500+ large titles with at least 1 million monthly active users. Median exact-day retention is D1 10.3%, D7 1.6%, D30 0.5%. Average game session length is 9.8 minutes at the median, 7.3 at p25, and 14.7 at p90. These are benchmarks for established games, not a representative census of new releases. The report also describes strongly concentrated payer spending; its USD figures are not converted into Robux in this prototype.
|
||||||
|
- [Roblox retention documentation](https://create.roblox.com/docs/production/analytics/retention): D1, D7, and D30 refer to returning on the exact day, not cumulative return. New cohorts need complete observation windows.
|
||||||
|
- [A creator's report of high session length and low retention](https://devforum.roblox.com/t/high-session-length-but-low-retention-rate/1752184): approximately 20-minute sessions with 12% D1 retention. This is self-reported, not audited or a platform benchmark. It illustrates that long sessions and strong retention are separate properties.
|
||||||
|
- [A creator's report of weak onboarding](https://devforum.roblox.com/t/how-do-i-improve-average-playtime-and-day-1-retention/4565940): approximately 5.4-minute sessions with 0.58% D1 retention over a week. Another self-reported example; it establishes possible variation rather than a calibration target for all games.
|
||||||
|
|
||||||
|
## Model assumptions
|
||||||
|
|
||||||
|
`realistic_seed.py` uses a fixed random seed and the current UTC time. Each game gets 6,000 players acquired over 90 days, with gradual growth, weekend lifts, an acquisition campaign, and an update bump. Orbit has shorter sessions and lower returns; Dungeon has longer sessions and stronger returns; Willow sits between them. These genre differences are illustrative assumptions, not measured claims about named games.
|
||||||
|
|
||||||
|
The latent population includes 55% one-visit players, 32% casual players, 12.7% regular players, and 0.3% high-spending candidates. Casual return probability decays quickly; regular players have a longer tail; heavy spenders have a persistent but diminishing return probability. Some players skip several days and return. Observed exact-day retention can fluctuate between adjacent days or small cohorts; it is not forced into an artificially monotonic chart.
|
||||||
|
|
||||||
|
Session durations are skewed rather than uniform: many short first visits, longer repeat visits, and occasional long sessions. Mobile/desktop/tablet proportions, payer propensity, spending intensity, and catalog prices are simulation assumptions. Most players never buy. Purchases occur within actual sessions; heavy spenders buy more frequently and favor larger bundles. A promotion changes historical prices without rewriting catalog prices. Gamepass observations are correlated with actual spending; some passes were previously owned or gifted, so ownership does not imply a receipt within this window.
|
||||||
|
|
||||||
|
Snapshots track progression and cumulative recorded purchases. Sessions, activity stages, events, product receipts, daily visits, and hourly CCU share the same histories. CCU counts intervals at each hourly sample; unended sessions use a short observation window for the synthetic CCU report while stored session duration remains unknown. Rare missing fields, missing pass checks, and missing end reports exercise N/A handling. The current UTC day is partial; its totals should be lower than full days.
|
||||||
|
|
||||||
|
## Rebuild only the bundled sample games
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv/Scripts/python.exe mockups/manage_database.py reset-sample
|
||||||
|
```
|
||||||
|
|
||||||
|
This deletes and replaces child datasets only for `willow-v1`, `orbit-v1`, and `dungeon-v1`, in one transaction. Internal game IDs are preserved, unrelated games are untouched, and ordinary `init`/`seed` calls keep existing data. Player and record IDs change, so old player-detail links may no longer resolve.
|
||||||
|
|
||||||
|
Run the validation report and tests:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv/Scripts/python.exe mockups/sample_report.py
|
||||||
|
.venv/Scripts/python.exe -m unittest discover -s mockups/tests -v
|
||||||
|
```
|
||||||
3
mockups/activities-mockup.html
Normal file
3
mockups/activities-mockup.html
Normal file
File diff suppressed because one or more lines are too long
83
mockups/build_pages.py
Normal file
83
mockups/build_pages.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
ROOT=Path(__file__).resolve().parent.parent/'mockups'
|
||||||
|
|
||||||
|
def metric(label,key,fmt='number',note=''):
|
||||||
|
return f'<article class="card card-pad metric-card"><div class="metric-label">{label}</div><div class="metric-value" data-metric="{key}" data-format="{fmt}">N/A</div><div class="metric-foot">{note}</div></article>'
|
||||||
|
|
||||||
|
def metrics(items): return '<div class="grid metrics">'+''.join(metric(*item) for item in items)+'</div>'
|
||||||
|
def chart(title,note='',key='trend'):
|
||||||
|
return f'<article class="card"><div class="card-head"><div><div class="card-title">{title}</div><div class="card-note">{note}</div></div></div><div class="chart-wrap" data-chart="{key}"><div class="empty">N/A</div></div></article>'
|
||||||
|
def table(title,heads,target,note=''):
|
||||||
|
return f'<article class="card"><div class="card-head"><div><div class="card-title">{title}</div><div class="card-note">{note}</div></div></div><div class="table-wrap"><table><thead><tr>'+''.join('<th>'+h+'</th>' for h in heads)+f'</tr></thead><tbody {target}><tr><td colspan="{len(heads)}" class="empty">N/A</td></tr></tbody></table></div></article>'
|
||||||
|
def heading(title,note): return f'<div class="page-heading"><div><h1>{title}</h1><div class="subtitle">{note}</div></div><div class="page-controls"><select class="select" data-days aria-label="Date range"><option value="7">Last 7 days</option><option value="14" selected>Last 14 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></div></div>'
|
||||||
|
def segments():
|
||||||
|
return '<div class="player-segments" role="group" aria-label="Player groups">'+''.join(f'<button class="segment-button {"active" if k=="all" else ""}" data-player-segment="{k}">{v}</button>' for k,v in [('all','All players'),('whales','Whales · top spend'),('playtime','Top playtime'),('first','First-time')])+'</div>'
|
||||||
|
SESSION_HEAD=['Player','Started (UTC)','Duration','Join session','Status']
|
||||||
|
EVENT_HEAD=['Event','Category','Player ID','Scope','Time (UTC)','Properties']
|
||||||
|
METRIC_HEAD=['Category','Metric','Latest value','Reported (UTC)']
|
||||||
|
|
||||||
|
pages={}
|
||||||
|
pages['overview']=heading('Overview','Game-wide analytics from PostgreSQL. Missing or unreported values display N/A.')+metrics([
|
||||||
|
('Unique players','players','number','Players with a session in the selected period'),('Sessions','sessions','number','Session starts in the selected period'),
|
||||||
|
('Average session','avgSession','duration','Completed sessions only'),('Day 1 retention','retention','percent','Exact next-day return; mature cohorts'),
|
||||||
|
('Visits','visits','number','Game-level daily visit samples in the period'),('Latest CCU','ccu','number','Latest game-level report; unknown reports stay N/A'),
|
||||||
|
('Events','events','number','Player and game-level events'),('Latest event','latestEvent','date','Most recent event in selected period')])+ '<div class="grid two-col">'+chart('Players and sessions','Grouped by UTC date')+chart('Top events','Recorded event counts','events')+'</div><div class="section-gap">'+table('Recent sessions',SESSION_HEAD,'data-session-rows','Latest 8 sessions across this game')+'</div><div class="grid two-col section-gap">'+chart('Concurrent players','Daily average of reported CCU samples','ccu')+table('Game root metrics',METRIC_HEAD,'data-root-metric-rows','Game-level categories with no player binding')+'</div>'
|
||||||
|
pages['sessions']=heading('Sessions','Stored sessions linked to players within the selected game.')+metrics([
|
||||||
|
('Sessions','sessions','number','Starts in selected period'),('Average duration','avgSession','duration','Completed sessions only'),('Median duration','medianSession','duration','Completed sessions only'),('Open sessions (recent)','active','number','No end report and started within six hours')])+chart('Sessions and players','UTC daily totals')+'<div class="section-gap">'+table('Recent sessions',SESSION_HEAD,'data-session-rows','Latest 50 game sessions; an unreported end time gives N/A duration')+'</div>'
|
||||||
|
pages['retention']=heading('Retention','Exact-day return rates from stored player sessions; incomplete cohorts display N/A.')+metrics([
|
||||||
|
('Day 1 return rate','retention','percent','Weighted across mature cohorts'),('Day 7 return rate','day7','percent','Exact day 7, with a complete observation day'),('New players','newPlayers','number','First seen in selected period'),('Returning players','returningPlayers','number','First seen before selected period')])+'<div class="grid two-col"><article class="card"><div class="card-head"><div><div class="card-title">Retention by cohort</div><div class="card-note">First-session date and percentage returning on the exact day · UTC</div></div></div><div class="table-wrap"><div class="retention-grid" data-retention></div></div></article>'+chart('Return rate trend','N/A observations are gaps in the chart')+'</div>'
|
||||||
|
pages['events']=heading('Events','Events belong to the game root and can optionally reference a player and session.')+'<nav class="tabs"><a class="active" href="events-mockup.html">Explore & records</a><a href="activities-mockup.html">Activity sessions</a></nav>'+metrics([
|
||||||
|
('Events','events','number','Selected period'),('Event types','eventTypes','number','Distinct event names'),('Players','players','number','Players with sessions in period'),('Latest event','latestEvent','date','UTC')])+chart('Event volume','Player-bound and game-level events')+'<div class="section-gap">'+table('Recent event records',EVENT_HEAD,'data-event-rows','Latest 50 records; player ID is N/A for game-level events')+'</div>'
|
||||||
|
pages['players']=heading('Players','Inspect latest snapshots and session aggregates. Open a player for all stored records.')+metrics([
|
||||||
|
('Tracked players','playerCount','number','All players under the selected game'),('Active in last 7 days','activePlayers','number','Based on last seen'),('Average known level','averageLevel','decimal','Unavailable levels excluded'),('Completed playtime','totalPlaytime','duration','Sum of known completed-session durations')])+ '<article class="card"><div class="card-head"><div><div class="card-title">Player stats</div><div class="card-note">The selected sort value is the first stat column. Missing values always sort last.</div></div><input class="filter-input" data-player-search placeholder="Search name or Roblox user ID"></div>'+segments()+'<div class="filters player-controls"><label class="control-label">Columns<select class="select" data-player-template></select></label><label class="control-label">Sort by<select class="select" data-player-sort></select></label><label class="control-label">Direction<select class="select" data-player-direction><option value="desc">Highest first</option><option value="asc">Lowest first</option></select></label></div><div class="table-wrap player-table-wrap"><table><thead><tr data-player-head><th>Player</th></tr></thead><tbody data-player-rows><tr><td>N/A</td></tr></tbody></table></div><div class="table-footer"><span data-page-summary>N/A</span><div class="inline"><button class="button" data-prev>Previous</button><span data-page-number>N/A</span><button class="button" data-next>Next</button></div></div></article><div class="card card-pad section-gap"><div class="setting-title">Player groups</div><div class="setting-help" data-group-definitions>N/A</div></div>'
|
||||||
|
|
||||||
|
profile_tabs='<nav class="tabs">'+''.join(f'<a href="#{k}" class="{"active" if k=="overview" else ""}" data-profile-tab="{k}">{v}</a>' for k,v in [('overview','Overview'),('values','Values'),('sessions','Sessions'),('activities','Activity runs'),('events','Events')])+'</nav>'
|
||||||
|
pages['player-info']='<div class="page-heading"><div><a class="back-link" href="players-mockup.html">← All players</a><h1 data-profile-name>N/A</h1><div class="subtitle" data-profile-id>N/A</div><div class="player-tags" data-profile-tags></div></div><div class="page-controls"><span class="pill">Stored player records</span></div></div>'+profile_tabs+'<section class="player-info-panel active" data-profile-panel="overview">'+metrics([
|
||||||
|
('Lifetime completed playtime','playtime','duration','Unreported end times are excluded'),('Average session','avgSession','duration','Completed sessions'),('Sessions','sessions','number','Lifetime session count'),('Median session','medianSession','duration','Completed sessions')])+'<div class="grid two-col">'+table('Latest saved values',['Field','Value'],'data-latest-value-rows','Fields absent from this player snapshot display N/A')+'<article class="card"><div class="card-head"><div><div class="card-title">Value history</div><div class="card-note">Stored snapshots, oldest to newest ? compare up to two values with separate scales</div></div><div class="profile-history-controls"><label class="control-label">Left axis<select class="select" data-profile-value aria-label="First history value"></select></label><label class="control-label">Right axis<select class="select" data-profile-value-second aria-label="Second history value"></select></label></div></div><div class="chart-wrap" data-chart="profile"></div></article></div></section><section class="player-info-panel" data-profile-panel="values">'+table('Snapshot history',['Captured (UTC)','Stored values'],'data-snapshot-rows','Full copies of the submitted player data')+'</section><section class="player-info-panel" data-profile-panel="sessions">'+table('Player sessions',SESSION_HEAD,'data-session-rows','Latest 50 sessions for this exact internal player ID')+'</section><section class="player-info-panel" data-profile-panel="activities">'+table('Individual activity runs',['Activity','Started (UTC)','Duration','Steps','Prompts','Outcome','Captured values'],'data-activity-rows','Underlying records are attached to the player; the activity page shows group averages')+'</section><section class="player-info-panel" data-profile-panel="events">'+table('Player event records',EVENT_HEAD,'data-event-rows','Latest 50 player-bound event records')+'</section>'
|
||||||
|
|
||||||
|
activity_tabs='<nav class="tabs">'+''.join(f'<a class="{"active" if k=="insights" else ""}" href="#{k}" data-activity-tab="{k}">{v}</a>' for k,v in [('insights','Insights'),('records','Run analysis'),('configure','Configure')])+'</nav>'
|
||||||
|
pages['activities']=heading('Activity sessions','Aggregate activity duration and outcomes; individual runs are stored in player info.')+'<div class="activity-picker-row"><label class="control-label">Activity<select class="select" data-activity-select></select></label><label class="control-label">Player group<select class="select" data-activity-group><option value="all">All players</option><option value="whales">Whales</option><option value="playtime">Top playtime</option><option value="first">First-time</option></select></label><button class="button" data-new-activity>+ New draft</button></div>'+activity_tabs+'<section class="activity-panel active" data-activity-panel="insights">'+metrics([
|
||||||
|
('Activity runs','runs','number','Selected activity, group and period'),('Average time','average','duration','Runs with a reported end'),('Median time','median','duration','Runs with a reported end'),('Completed','completion','percent','Runs with end report'),('Prompt rate','promptRate','percent','Runs with a recorded prompt'),('Purchase rate','purchaseRate','percent','Runs with purchase outcome'),('Prompt to purchase','promptPurchaseRate','percent','Purchased among prompted runs')])+'<article class="card"><div class="card-head"><div><div class="card-title">Configured journey</div><div class="card-note">Counts are derived from stored run counters and outcomes</div></div></div><div class="activity-flow" data-activity-journey></div></article><div class="grid two-col section-gap">'+chart('Average activity duration','Seconds by UTC date')+table('Captured values',['Field','Summary','Value'],'data-captured-rows','Only reported values are summarized')+'</div></section><section class="activity-panel" data-activity-panel="records">'+table('Activity outcomes',['Outcome','Runs','Share','Average time','Median time'],'data-outcome-rows','Aggregate groups, not a list of players. Unknown outcomes and durations display N/A.')+'</section><section class="activity-panel" data-activity-panel="configure"><article class="card card-pad"><div class="card-title">Activity definition</div><div class="card-note">Loaded from PostgreSQL. Editing remains a UI draft; changes are not saved.</div><div class="config-grid"><label>Activity name<input class="filter-input" data-activity-name></label><label>Start event<input class="filter-input" data-start-event></label><label>End event<input class="filter-input" data-end-event></label><label>Timeout<select class="select"><option>N/A</option><option>30 minutes</option><option>1 hour</option></select></label></div><div class="setting-row"><div class="setting-title">Journey events</div><button class="button" data-add-step>+ Add event</button></div><div class="config-events" data-config-events></div><div class="setting-row"><div class="setting-title">Captured properties</div><button class="button" data-add-field>+ Add value</button></div><div class="capture-fields" data-capture-fields></div><button class="button primary" data-save-activity>Preview draft</button></article></section><div class="toast" data-toast role="status"></div>'
|
||||||
|
pages['settings']=heading('Project settings','The internal game ID is the root of every dataset.')+'<article class="card card-pad"><div class="card-title">Game root</div><div class="config-grid"><label>Internal database game ID<input class="filter-input" data-game-field="id" readonly></label><label>Name<input class="filter-input" data-game-field="name" readonly></label><label>Roblox experience ID<input class="filter-input" data-game-field="robloxExperienceId" readonly></label><label>Display timezone<input class="filter-input" data-game-field="timezone" readonly></label></div><div class="config-note">Game metadata is read from PostgreSQL. API key management is not connected; ingestion credentials: N/A.</div></article><div class="section-gap">'+table('Game-level metric categories',METRIC_HEAD,'data-root-metric-rows','These values belong to the internal game root and have no player binding')+'</div>'
|
||||||
|
|
||||||
|
pages['overview']+='<div class="section-gap">'+metrics([('DAU today','dau','number','Distinct active players today · UTC'),('Rolling MAU today','mau','number','Distinct active players in last 30 calendar days'),('New users today','newUsersToday','number','First seen today'),('Returning users today','returningUsersToday','number','Active today, first seen before today')])+'</div><div class="grid two-col">'+chart('Daily and monthly active users','MAU is calculated separately for every day','active-users')+chart('New and returning daily users','UTC daily counts','new-returning')+'</div>'
|
||||||
|
pages['sessions']+='<div class="section-gap">'+metrics([('DAU today','dau','number','Active today'),('Rolling MAU','mau','number','30 calendar days ending today'),('Total playtime','totalPlaytime','duration','Completed-session time inside selected days'),('Playtime per active player','averagePlaytime','duration','Known playtime divided by unique period actives'),('New-player session time','newSessionTime','duration','Average completed session on first-seen day'),('Returning-player session time','returningSessionTime','duration','Average completed session on a later day'),('First-session 5m retention','firstSession5min','percent','Completed first sessions lasting at least 300 seconds')])+'</div><div class="grid two-col">'+chart('New vs returning session duration','Average seconds for each daily user group','session-groups')+chart('First-session five-minute retention','Percentage of known first sessions reaching 300 seconds','first-five')+chart('Total daily playtime','Known completed-session seconds, split at UTC midnight','playtime-total')+chart('Average daily playtime','Seconds per daily active user','playtime-average')+chart('DAU and rolling MAU','Distinct players · calculated for every day','active-users')+chart('New and returning users','Daily active user groups','new-returning')+'</div>'
|
||||||
|
pages['monetization']=heading('Monetization','Reported product sales, actual sale prices, payer behavior, and revenue trends.')+metrics([
|
||||||
|
('Robux spent','revenue','robux','Sum of recorded unit price × quantity'),('Paying users','payingUsers','number','Distinct positive-price buyers in period'),('Payer conversion','payerConversion','percent','Paying users / unique active period users'),('Robux per paying user','arppu','robux','Period Robux / period paying users'),('Robux per daily user · ARPDAU','arpdau','decimal','Period Robux / total daily active user-days'),('Products per paying user','productsPerPayer','decimal','Paid units / all period payers'),('Reported sales','transactions','number','Unique receipts, including free sales'),('Unattributed sales','unattributedSales','number','Positive-price sales without a player binding')])+'<div class="grid two-col">'+chart('Daily Robux spent','UTC daily totals','revenue-daily')+chart('Hourly Robux spent','UTC hourly totals','revenue-hourly')+chart('Paying users per day','Distinct daily positive-price buyers','payers')+chart('Daily payer conversion','Daily payers / DAU','payer-conversion')+chart('Daily ARPDAU','Same measure as average Robux per daily active user','arpdau')+chart('Robux by product','Recorded sales at their actual reported prices','products')+'</div><div class="section-gap">'+table('Products',['Product','Roblox product ID','Current price','Robux spent','Paid units','Buyers','Units per paying user'],'data-product-rows','Each product average uses ALL period paying users; decimal counts are intentional')+'</div><div class="section-gap">'+table('Recent sale reports',['Receipt','Product','Roblox product ID','Player ID','Unit price','Quantity','Total','Purchased (UTC)'],'data-sale-rows','Latest 50 reports; sale price is stored on each report independently of the catalog price')+'</div>'
|
||||||
|
pages['player-info']=pages['player-info'].replace('>Events</a></nav>','>Events</a><a href="#sales" data-profile-tab="sales">Purchases</a></nav>')+'<section class="player-info-panel" data-profile-panel="sales">'+table('Player purchase history',['Receipt','Product','Roblox product ID','Player ID','Unit price','Quantity','Total','Purchased (UTC)'],'data-sale-rows','Recorded sales associated with this internal player ID')+'</section>'
|
||||||
|
pages['settings']='<nav class="mobile-more-links" aria-label="More analytics"><a class="button" href="retention-mockup.html">Retention</a><a class="button" href="activities-mockup.html">Activity sessions</a><a class="button" href="monetization-mockup.html">Monetization</a></nav>'+pages['settings']
|
||||||
|
pages['player-info'] += '<div class="toast" data-toast role="status"></div>'
|
||||||
|
|
||||||
|
import re
|
||||||
|
removed_titles = ['Recent sessions', 'Game root metrics', 'Game-level metric categories', 'Recent sale reports', 'Unattributed sales']
|
||||||
|
for key in ('overview', 'sessions', 'settings', 'monetization'):
|
||||||
|
pages[key] = re.sub(r'<article\b[^>]*>.*?</article>', lambda m: '' if any(f'>{title}</div>' in m.group(0) for title in removed_titles) else m.group(0), pages[key], flags=re.S)
|
||||||
|
pages[key] = pages[key].replace('<div class="section-gap"></div>', '')
|
||||||
|
group_picker = '<div class="filters section-gap"><label class="control-label">Player group<select class="select" data-session-group><option value="all">All players</option><option value="whales">Whales · top spend</option><option value="playtime">Top playtime</option><option value="first">First-time</option></select></label><span class="card-note">Whales and top playtime use the top 20% of eligible players; first-time means first seen within 24 hours.</span></div>'
|
||||||
|
pages['sessions'] = pages['sessions'].replace('<div class="grid metrics">', group_picker + '<div class="grid metrics">', 1)
|
||||||
|
pages['events'] = heading('Events', 'Daily event totals across the selected game. Individual records remain available in player info.') + metrics([
|
||||||
|
('Events', 'events', 'number', 'Selected period'), ('Event types', 'eventTypes', 'number', 'Recorded event names'),
|
||||||
|
('Players', 'players', 'number', 'Distinct players with events'), ('Latest event', 'latestEvent', 'date', 'UTC')
|
||||||
|
]) + chart('Events per day', 'One line per event · UTC daily counts') + '<div class="section-gap">' + table('Event summary', ['Event', 'Categories', 'Total', 'Daily average', 'Unique players', 'Last seen (UTC)'], 'data-event-summary', 'One row per event name in the selected period') + '</div>'
|
||||||
|
def monetization_tabs(active):
|
||||||
|
return '<nav class="tabs">' + ''.join(f'<a class="{"active" if key == active else ""}" href="{key}-mockup.html">{label}</a>' for key, label in [('monetization', 'Revenue'), ('gamepasses', 'Gamepasses')]) + '</nav>'
|
||||||
|
event_picker = '<div class="event-chart-controls"><details class="event-picker"><summary>Choose events <span class="card-note" data-event-selection-count></span></summary><div class="event-picker-panel"><div class="event-picker-toolbar"><input class="filter-input" data-event-search type="search" placeholder="Find an event" aria-label="Find events to graph"><button class="button" data-events-all type="button">All</button><button class="button" data-events-none type="button">None</button></div><div class="event-options" data-event-options role="group" aria-label="Events to graph"></div></div></details></div>'
|
||||||
|
pages['events'] = pages['events'].replace('<div class="chart-wrap" data-chart="trend">', event_picker + '<div class="chart-wrap" data-chart="trend">', 1)
|
||||||
|
pages['monetization'] = pages['monetization'].replace('<div class="grid metrics">', monetization_tabs('monetization') + '<div class="grid metrics">', 1)
|
||||||
|
pages['gamepasses'] = heading('Gamepasses', 'Ownership among all tracked players and among lifetime paying players.') + monetization_tabs('gamepasses') + metrics([
|
||||||
|
('Gamepasses', 'gamepasses', 'number', 'Passes in the game catalog'), ('Tracked players', 'trackedPlayers', 'number', 'All tracked players in this game'), ('Paying players', 'lifetimePayers', 'number', 'Players with a recorded positive-price purchase')
|
||||||
|
]) + chart('Gamepass ownership', 'Share of all players and share of paying players · incomplete ownership reports display N/A', 'ownership') + '<div class="section-gap">' + table('Ownership by gamepass', ['Gamepass', 'Roblox pass ID', 'Owners', '% of all players', 'Paying owners', '% of paying players', 'Reporting coverage', 'Last checked (UTC)'], 'data-gamepass-rows', 'Ownership is reported explicitly. Missing checks are unknown; purchases alone do not establish ownership.') + '</div>'
|
||||||
|
pages['activities'] = pages['activities'].replace('Counts are derived from stored run counters and outcomes', 'Ordered stages · reach is measured from all starts; drop-off is measured from the previous stage')
|
||||||
|
pages['activities'] = pages['activities'].replace('<div class="grid two-col section-gap">', '<div class="section-gap">' + table('Stage conversion', ['Stage', 'Runs', 'Reached from start', 'From previous stage', 'Drop-off', 'Dropped runs', 'Unknown runs'], 'data-stage-rows', 'Only sequentially reached stages count. Missing stage reports display N/A.') + '</div><div class="grid two-col section-gap">', 1)
|
||||||
|
pages['settings'] = pages['settings'].replace('>Game root</div>', '>Game details</div>')
|
||||||
|
nav=[('overview','◫','Overview'),('sessions','◷','Sessions'),('retention','↗','Retention'),('players','♙','Players'),('events','⌘','Events'),('activities','⟿','Activity sessions'),('monetization','◇','Monetization'),('settings','⚙','Project settings')]
|
||||||
|
for page,content in pages.items():
|
||||||
|
current='players' if page=='player-info' else 'monetization' if page=='gamepasses' else page
|
||||||
|
links=''.join(f'<a class="{"active" if k==current else ""}" href="{k}-mockup.html"><span class="nav-icon">{icon}</span>{name}</a>' for k,icon,name in nav)
|
||||||
|
mobile=''.join(f'<a class="{"active" if current==k or (current in ("sessions","retention") and k=="sessions") or (current=="activities" and k=="events") else ""}" href="{k}-mockup.html"><span class="nav-icon">{icon}</span>{name}</a>' for k,icon,name in [('overview','◫','Overview'),('sessions','◷','Analytics'),('players','♙','Players'),('events','⌘','Events'),('settings','···','More')])
|
||||||
|
title=dict((k,n) for k,_,n in nav).get(page,'Gamepasses' if page=='gamepasses' else 'Player info')
|
||||||
|
html=f'''<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{title} — Statboard</title><link rel="stylesheet" href="mockup.css"></head>
|
||||||
|
<body data-page="{page}"><div class="app"><aside class="sidebar"><a class="brand" href="overview-mockup.html"><span class="brand-mark">S</span>Statboard</a><div class="project-select"><div class="project-label">Current game</div><select class="select game-select" data-game-select aria-label="Game"><option>N/A</option></select></div><div class="nav-label">Workspace</div><nav class="nav">{links}</nav><div class="sidebar-bottom"><span class="pill">PostgreSQL sample</span></div></aside><main class="main"><header class="topbar"><div class="breadcrumbs">{title}</div><select class="select mobile-game-select" data-game-select aria-label="Game"><option>N/A</option></select><div class="top-actions"><span class="status">PostgreSQL · UTC</span></div></header><section class="page"><div class="error-banner" data-error hidden></div>{content}</section></main></div><nav class="mobile-nav">{mobile}</nav><script src="mockup.js"></script></body></html>'''
|
||||||
|
(ROOT/f'{page}-mockup.html').write_text(html,encoding='utf-8')
|
||||||
|
print(f'Updated {len(pages)} data-driven mockup pages.')
|
||||||
507
mockups/data-ui.js
Normal file
507
mockups/data-ui.js
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const state = { game: null, days: Number(params.get('days')) || 14, page: 1, sort: 'level', direction: 'desc', segment: 'all', q: '', template: 'overview' };
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||||
|
const missing = value => value === null || value === undefined || value === '';
|
||||||
|
const escape = value => String(value).replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
||||||
|
|
||||||
|
function format(value, kind = 'number') {
|
||||||
|
if (missing(value)) return 'N/A';
|
||||||
|
if (kind === 'text') return String(value);
|
||||||
|
if (kind === 'json') return JSON.stringify(value, (key,item)=>item === null ? 'N/A' : item);
|
||||||
|
if (kind === 'date') {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? 'N/A' : new Intl.DateTimeFormat('en-GB', {timeZone:'UTC',dateStyle:'medium',timeStyle:'short'}).format(date);
|
||||||
|
}
|
||||||
|
if (kind === 'duration') {
|
||||||
|
if (!Number.isFinite(Number(value))) return 'N/A';
|
||||||
|
const seconds = Math.max(0,Math.round(Number(value)));
|
||||||
|
return seconds >= 3600 ? `${Math.floor(seconds/3600).toLocaleString()}h ${String(Math.floor(seconds%3600/60)).padStart(2,'0')}m` : `${Math.floor(seconds/60)}m ${String(seconds%60).padStart(2,'0')}s`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
if (typeof value !== 'number') return String(value);
|
||||||
|
if (!Number.isFinite(value)) return 'N/A';
|
||||||
|
const number = new Intl.NumberFormat('en-US',{maximumFractionDigits:kind === 'decimal' ? 2 : 1}).format(value);
|
||||||
|
return kind === 'percent' ? `${number}%` : kind === 'robux' ? `R$ ${number}` : kind === 'currency' ? `$${number}` : number;
|
||||||
|
}
|
||||||
|
const cell = (value, kind) => escape(format(value, kind));
|
||||||
|
const chartStyle=getComputedStyle(document.documentElement);
|
||||||
|
const seriesColors=Array.from({length:12},(_,i)=>chartStyle.getPropertyValue(`--chart-${i+1}`).trim());
|
||||||
|
const seriesColor=index=>seriesColors[index] || `hsl(${index*137.508%360} 70% 70%)`;
|
||||||
|
function reveal(node) {
|
||||||
|
if (!node || reducedMotion.matches || !node.animate) return;
|
||||||
|
node.getAnimations().forEach(animation => animation.cancel());
|
||||||
|
node.animate([{opacity:.65},{opacity:1}],{duration:180,easing:'ease-out'});
|
||||||
|
}
|
||||||
|
function error(message) {
|
||||||
|
fillMetrics({});
|
||||||
|
$$('[data-chart]').forEach(node=>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;
|
||||||
|
}
|
||||||
|
function clearError() { $('[data-error]').hidden=true; }
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
const response=await fetch(url);
|
||||||
|
const data=await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function api(dataset='', query={}) {
|
||||||
|
const search=new URLSearchParams({days:state.days,...query});
|
||||||
|
return fetchJSON(`/api/games/${state.game.id}${dataset ? '/'+dataset : ''}?${search}`);
|
||||||
|
}
|
||||||
|
function fillMetrics(metrics={}) {
|
||||||
|
$$('[data-metric]').forEach(node => node.textContent=format(metrics[node.dataset.metric],node.dataset.format));
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
function playerLink(playerId, name, robloxId) {
|
||||||
|
if (missing(playerId)) return 'N/A';
|
||||||
|
const query=new URLSearchParams({game_id:state.game.id,player_id:playerId});
|
||||||
|
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>`]));
|
||||||
|
}
|
||||||
|
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>`]));
|
||||||
|
}
|
||||||
|
function renderRoot(records=[]) {
|
||||||
|
table('[data-root-metric-rows]',records.map(r=>[cell(r.category),cell(r.name),cell(r.value),cell(r.recorded_at,'date')]));
|
||||||
|
}
|
||||||
|
function renderSales(records=[]) {
|
||||||
|
table('[data-sale-rows]',records.map(r=>[cell(r.receipt_id),cell(r.name),cell(r.roblox_product_id),cell(r.roblox_user_id),cell(r.unit_price_robux,'robux'),cell(r.quantity),cell(r.robux,'robux'),cell(r.purchased_at,'date')]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chartTooltip;
|
||||||
|
function inspectChart(node, {width, height, left, right, top=0, bottom=0, count, pointX, inspect}) {
|
||||||
|
const svg=node.querySelector('svg');
|
||||||
|
if (!svg || !count) return;
|
||||||
|
svg.classList.add('interactive-chart');
|
||||||
|
svg.setAttribute('tabindex','0');
|
||||||
|
svg.setAttribute('aria-label',`${svg.getAttribute('aria-label')}. Move the pointer or use left and right arrow keys to inspect values.`);
|
||||||
|
svg.insertAdjacentHTML('beforeend','<g class="chart-inspection" aria-hidden="true" hidden></g>');
|
||||||
|
const overlay=svg.querySelector('.chart-inspection');
|
||||||
|
let selected=0;
|
||||||
|
const hide=()=>{
|
||||||
|
overlay.setAttribute('hidden','');
|
||||||
|
if (chartTooltip?.owner===svg) chartTooltip.hidden=true;
|
||||||
|
};
|
||||||
|
const show=(index, clientX, clientY)=>{
|
||||||
|
selected=Math.max(0,Math.min(count-1,index));
|
||||||
|
const detail=inspect(selected);
|
||||||
|
overlay.innerHTML=detail.overlay || '';
|
||||||
|
overlay.removeAttribute('hidden');
|
||||||
|
if (!chartTooltip) {
|
||||||
|
chartTooltip=document.createElement('div');
|
||||||
|
chartTooltip.className='chart-tooltip';
|
||||||
|
chartTooltip.setAttribute('role','tooltip');
|
||||||
|
document.body.append(chartTooltip);
|
||||||
|
const dismiss=()=>{
|
||||||
|
chartTooltip.hidden=true;
|
||||||
|
$$('.chart-inspection').forEach(item=>item.setAttribute('hidden',''));
|
||||||
|
};
|
||||||
|
document.addEventListener('scroll',dismiss,true);
|
||||||
|
window.addEventListener('resize',dismiss);
|
||||||
|
}
|
||||||
|
chartTooltip.owner=svg;
|
||||||
|
chartTooltip.innerHTML=`<div class="chart-tooltip-title">${cell(detail.label,'text')}</div>${detail.rows.map(row=>`<div class="chart-tooltip-row"><span><i class="dot" style="background:${row.color}"></i>${cell(row.name,'text')}</span><strong>${cell(row.value,row.format || 'decimal')}</strong></div>`).join('')}`;
|
||||||
|
chartTooltip.hidden=false;
|
||||||
|
const bounds=chartTooltip.getBoundingClientRect();
|
||||||
|
const x=clientX+16+bounds.width>innerWidth-8 ? clientX-bounds.width-16 : clientX+16;
|
||||||
|
chartTooltip.style.left=`${Math.max(8,Math.min(x,innerWidth-bounds.width-8))}px`;
|
||||||
|
chartTooltip.style.top=`${Math.max(8,Math.min(clientY+16,innerHeight-bounds.height-8))}px`;
|
||||||
|
const description=`${detail.label}: ${detail.rows.map(row=>`${row.name}: ${format(row.value,row.format || 'decimal')}`).join('; ')}`;
|
||||||
|
svg.setAttribute('aria-label',description);
|
||||||
|
};
|
||||||
|
svg.addEventListener('pointermove',event=>{
|
||||||
|
const rect=svg.getBoundingClientRect();
|
||||||
|
const x=(event.clientX-rect.left)*width/rect.width;
|
||||||
|
const y=(event.clientY-rect.top)*height/rect.height;
|
||||||
|
if (x<left || x>width-right || y<top || y>height-bottom) return hide();
|
||||||
|
let nearest=0;
|
||||||
|
for (let i=1;i<count;i++) if (Math.abs(pointX(i)-x)<Math.abs(pointX(nearest)-x)) nearest=i;
|
||||||
|
show(nearest,event.clientX,event.clientY);
|
||||||
|
});
|
||||||
|
const showSelected=()=>{
|
||||||
|
const rect=svg.getBoundingClientRect();
|
||||||
|
show(selected,rect.left+pointX(selected)/width*rect.width,rect.top+rect.height*.25);
|
||||||
|
};
|
||||||
|
svg.addEventListener('focus',showSelected);
|
||||||
|
svg.addEventListener('keydown',event=>{
|
||||||
|
if (event.key==='Escape') return hide();
|
||||||
|
if (!['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
selected=event.key==='Home' ? 0 : event.key==='End' ? count-1 : selected+(event.key==='ArrowRight' ? 1 : -1);
|
||||||
|
selected=Math.max(0,Math.min(count-1,selected));
|
||||||
|
showSelected();
|
||||||
|
});
|
||||||
|
['pointerleave','pointercancel','blur'].forEach(name=>svg.addEventListener(name,hide));
|
||||||
|
// Replacing a chart or changing the page must not leave an old tooltip visible.
|
||||||
|
if (chartTooltip) chartTooltip.hidden=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineChart(selector, series=[], labels=[], {separateAxes=false}={}) {
|
||||||
|
if (chartTooltip) chartTooltip.hidden=true;
|
||||||
|
const node=$(selector); if (!node) return;
|
||||||
|
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; }
|
||||||
|
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 low=Math.min(0,...values),high=Math.max(...values)*1.1 || 1;
|
||||||
|
const scales=series.map(s=>{
|
||||||
|
const valid=s.values.filter(Number.isFinite);
|
||||||
|
return {low:Math.min(0,...valid),high:Math.max(0,...valid)*1.1 || 1};
|
||||||
|
});
|
||||||
|
const x=i=>left+i*(width-left-right)/Math.max(1,labels.length-1);
|
||||||
|
const y=(value,index=0)=>{
|
||||||
|
const range=dual ? scales[index] : {low,high};
|
||||||
|
return top+(range.high-value)*(height-top-bottom)/(range.high-range.low);
|
||||||
|
};
|
||||||
|
const grid=Array.from({length:4},(_,i)=>{
|
||||||
|
const yy=top+i*(height-top-bottom)/3;
|
||||||
|
const range=dual ? scales[0] : {low,high};
|
||||||
|
const tick=value=>new Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(value);
|
||||||
|
const rightTick=dual ? `<text style="fill:${seriesColor(1)}" x="${width-right+6}" y="${yy+3}">${escape(tick(scales[1].high-i*(scales[1].high-scales[1].low)/3))}</text>` : '';
|
||||||
|
return `<line class="gridline" x1="${left}" y1="${yy}" x2="${width-right}" y2="${yy}"/><text ${dual ? `style="fill:${seriesColor(0)}"` : ''} x="${left-6}" y="${yy+3}" text-anchor="end">${escape(tick(range.high-i*(range.high-range.low)/3))}</text>${rightTick}`;
|
||||||
|
}).join('');
|
||||||
|
const paths=series.map((s,index)=>{
|
||||||
|
const color=colorFor(index);
|
||||||
|
let connected=false;
|
||||||
|
const path=s.values.map((value,i)=>{
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) { connected=false; return ''; }
|
||||||
|
const point=`${connected ? 'L' : 'M'}${x(i)},${y(value,index)}`; connected=true; return point;
|
||||||
|
}).join(' ');
|
||||||
|
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>`;
|
||||||
|
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('')
|
||||||
|
})});
|
||||||
|
}
|
||||||
|
function barChart(selector, values=[], labels=[]) {
|
||||||
|
if (chartTooltip) chartTooltip.hidden=true;
|
||||||
|
const node=$(selector); if (!node) return;
|
||||||
|
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 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)=>{
|
||||||
|
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>`;
|
||||||
|
}).join('')}</svg>`;
|
||||||
|
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}"/>`
|
||||||
|
})});
|
||||||
|
}
|
||||||
|
function seriesFromDaily(selector, daily, mapping, dateKey='day') {
|
||||||
|
const labels=daily.map(r=>String(r[dateKey]).slice(5,16).replace('T',' '));
|
||||||
|
lineChart(`[data-chart="${selector}"]`,mapping.map(([key,name])=>({name,format:name.includes('(%)') ? 'percent' : name.includes('(s)') ? 'duration' : 'decimal',values:daily.map(r=>r[key])})),labels);
|
||||||
|
}
|
||||||
|
function renderEngagement(data) {
|
||||||
|
if (!data) return;
|
||||||
|
const daily=data.daily;
|
||||||
|
seriesFromDaily('active-users',daily,[['dau','DAU'],['mau','Rolling MAU']]);
|
||||||
|
seriesFromDaily('new-returning',daily,[['new_users','New users'],['returning_users','Returning users']]);
|
||||||
|
seriesFromDaily('session-groups',daily,[['new_session_time','New-user average session (s)'],['returning_session_time','Returning-user average session (s)']]);
|
||||||
|
seriesFromDaily('first-five',daily,[['first_session_5min','First-session 5m retention (%)']]);
|
||||||
|
seriesFromDaily('playtime-total',daily,[['total_playtime','Total completed playtime (s)']]);
|
||||||
|
seriesFromDaily('playtime-average',daily,[['average_playtime','Playtime per DAU (s)']]);
|
||||||
|
}
|
||||||
|
function renderRetention(data) {
|
||||||
|
const node=$('[data-retention]'); if (!node) return;
|
||||||
|
const grid=data.retentionGrid;
|
||||||
|
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('')}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
if (ticket!==playerRequest) return;
|
||||||
|
playerData=data; fillMetrics(data.metrics);
|
||||||
|
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)));
|
||||||
|
data.fields.forEach(field=>sort.add(new Option(field.label,field.key)));
|
||||||
|
template.value=state.template; sort.value=state.sort;
|
||||||
|
}
|
||||||
|
const selected=data.templates[state.template] || data.templates.all;
|
||||||
|
const keys=[state.sort,...selected.columns.filter(key=>key!==state.sort)];
|
||||||
|
const fields=keys.map(key=>data.fields.find(field=>field.key===key)).filter(Boolean);
|
||||||
|
$('[data-player-head]').innerHTML='<th>Player</th>'+fields.map(f=>`<th>${escape(f.label)}</th>`).join('');
|
||||||
|
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}`;
|
||||||
|
$('[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-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();
|
||||||
|
});
|
||||||
|
$('[data-player-sort]').addEventListener('change',e=>{state.sort=e.target.value;refresh();});
|
||||||
|
$('[data-player-direction]').addEventListener('change',e=>{state.direction=e.target.value;refresh();});
|
||||||
|
$$('[data-player-segment]').forEach(button=>button.addEventListener('click',()=>{
|
||||||
|
state.segment=button.dataset.playerSegment;
|
||||||
|
$$('[data-player-segment]').forEach(other=>other.classList.toggle('active',other===button)); refresh();
|
||||||
|
}));
|
||||||
|
let searchTimer;
|
||||||
|
$('[data-player-search]').addEventListener('input',event=>{clearTimeout(searchTimer);state.q=event.target.value;searchTimer=setTimeout(refresh,220);});
|
||||||
|
$('[data-prev]').addEventListener('click',()=>{state.page--;loadPlayers().catch(e=>error(e.message));});
|
||||||
|
$('[data-next]').addEventListener('click',()=>{state.page++;loadPlayers().catch(e=>error(e.message));});
|
||||||
|
}
|
||||||
|
function bindTabs(tabAttribute,panelAttribute) {
|
||||||
|
$$(`[${tabAttribute}]`).forEach(tab=>tab.addEventListener('click',event=>{
|
||||||
|
event.preventDefault();
|
||||||
|
$$(`[${tabAttribute}]`).forEach(other=>other.classList.toggle('active',other===tab));
|
||||||
|
$$(`[${panelAttribute}]`).forEach(panel=>panel.classList.toggle('active',panel.getAttribute(panelAttribute)===tab.getAttribute(tabAttribute)));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let profileId=params.get('player_id'),profileRequest=0;
|
||||||
|
async function loadProfile() {
|
||||||
|
const ticket=++profileRequest;
|
||||||
|
if (!profileId) {
|
||||||
|
const list=await api('players',{page_size:1}); profileId=list.playerRows[0]?.playerId;
|
||||||
|
if (!profileId) throw new Error('N/A — this game has no players.');
|
||||||
|
}
|
||||||
|
const selected=$('[data-profile-value]').value || 'cash';
|
||||||
|
const second=$('[data-profile-value-second]');
|
||||||
|
const secondary=second.options.length ? second.value : 'robux';
|
||||||
|
const data=await api(`players/${profileId}`,{value:selected,value2:secondary});
|
||||||
|
if (ticket!==profileRequest) return;
|
||||||
|
const p=data.player;
|
||||||
|
$('[data-profile-name]').textContent=format(p.name);
|
||||||
|
$('[data-profile-id]').textContent=`Internal player ${p.playerId} · Roblox user ${p.robloxUserId} · First seen ${format(p.firstSeen,'date')} · Snapshot ${format(p.snapshotAt,'date')}`;
|
||||||
|
$('[data-profile-tags]').innerHTML=(data.groups || []).map(v=>`<span class="pill">${escape(v)}</span>`).join('');
|
||||||
|
fillMetrics(p);
|
||||||
|
const chooser=$('[data-profile-value]');
|
||||||
|
if (!chooser.options.length) data.historyFields.forEach(f=>chooser.add(new Option(f.label,f.key)));
|
||||||
|
if (!second.options.length) {
|
||||||
|
second.add(new Option('None',''));
|
||||||
|
data.historyFields.forEach(f=>second.add(new Option(f.label,f.key)));
|
||||||
|
}
|
||||||
|
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>`]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let activityData,activityId,toastTimer;
|
||||||
|
function toast(message) {
|
||||||
|
const node=$('[data-toast]'); node.textContent=message;node.classList.add('visible');
|
||||||
|
clearTimeout(toastTimer);toastTimer=setTimeout(()=>node.classList.remove('visible'),2600);
|
||||||
|
}
|
||||||
|
function eventEditor(name='') {
|
||||||
|
return `<div class="config-event"><select class="select"><option>Step</option><option>Prompt</option><option>Outcome</option></select><input class="filter-input" value="${escape(name)}" placeholder="event_name"><button class="icon-button" data-remove-row aria-label="Remove event">×</button></div>`;
|
||||||
|
}
|
||||||
|
function fieldEditor(name='') {
|
||||||
|
return `<div class="capture-field"><input class="filter-input" value="${escape(name)}" placeholder="property_name"><select class="select"><option>Latest value</option><option>First value</option><option>Change over run</option></select><button class="icon-button" data-remove-row aria-label="Remove value">×</button></div>`;
|
||||||
|
}
|
||||||
|
async function loadActivities() {
|
||||||
|
const query={segment:$('[data-activity-group]').value}; if (activityId) query.activity_id=activityId;
|
||||||
|
const data=await api('activities',query); activityData=data; fillMetrics(data.metrics);
|
||||||
|
const chooser=$('[data-activity-select]');
|
||||||
|
if (!chooser.options.length) data.definitions.forEach(d=>chooser.add(new Option(d.name,d.id)));
|
||||||
|
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)]));
|
||||||
|
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')]));
|
||||||
|
$('[data-activity-name]').value=data.definition.name;
|
||||||
|
$('[data-start-event]').value=data.definition.start_event;
|
||||||
|
$('[data-end-event]').value=data.definition.end_event || 'N/A';
|
||||||
|
$('[data-config-events]').innerHTML=(data.definition.configuration.steps || []).map(eventEditor).join('');
|
||||||
|
$('[data-capture-fields]').innerHTML=(data.definition.configuration.fields || []).map(fieldEditor).join('');
|
||||||
|
}
|
||||||
|
function bindActivities() {
|
||||||
|
bindTabs('data-activity-tab','data-activity-panel');
|
||||||
|
$('[data-activity-select]').addEventListener('change',event=>{activityId=event.target.value;loadActivities().catch(e=>error(e.message));});
|
||||||
|
$('[data-activity-group]').addEventListener('change',()=>loadActivities().catch(e=>error(e.message)));
|
||||||
|
$('[data-add-step]').addEventListener('click',()=>$('[data-config-events]').insertAdjacentHTML('beforeend',eventEditor()));
|
||||||
|
$('[data-add-field]').addEventListener('click',()=>$('[data-capture-fields]').insertAdjacentHTML('beforeend',fieldEditor()));
|
||||||
|
document.addEventListener('click',event=>{if(event.target.closest('[data-remove-row]')) event.target.closest('.config-event,.capture-field').remove();});
|
||||||
|
$('[data-save-activity]').addEventListener('click',()=>toast('UI draft only — database definition is unchanged.'));
|
||||||
|
$('[data-new-activity]').addEventListener('click',()=>{
|
||||||
|
$('[data-activity-tab="configure"]').click();
|
||||||
|
$('[data-activity-name]').value=''; $('[data-start-event]').value=''; $('[data-end-event]').value='';
|
||||||
|
$('[data-config-events]').innerHTML='';$('[data-capture-fields]').innerHTML='';
|
||||||
|
$('[data-activity-name]').focus();toast('New UI draft. Ingestion and saving are not connected.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderMonetization(data) {
|
||||||
|
fillMetrics(data.metrics);
|
||||||
|
seriesFromDaily('revenue-daily',data.daily,[['robux','Daily Robux spent']]);
|
||||||
|
seriesFromDaily('revenue-hourly',data.hourly,[['robux','Hourly Robux spent']],'hour');
|
||||||
|
seriesFromDaily('payers',data.daily,[['payers','Daily paying users']]);
|
||||||
|
seriesFromDaily('payer-conversion',data.daily,[['conversion','Payer conversion (%)']]);
|
||||||
|
seriesFromDaily('arpdau',data.daily,[['arpdau','Robux per DAU']]);
|
||||||
|
barChart('[data-chart="products"]',data.products.map(p=>p.robux),data.products.map(p=>format(p.name)));
|
||||||
|
table('[data-product-rows]',data.products.map(p=>[cell(p.name),cell(p.roblox_product_id),cell(p.current_price_robux,'robux'),cell(p.robux,'robux'),cell(p.quantity),cell(p.buyers),cell(p.perPayer,'decimal')]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGamepasses(data) {
|
||||||
|
fillMetrics(data.metrics);
|
||||||
|
const passes=data.gamepasses, colors=[seriesColor(0),seriesColor(1)];
|
||||||
|
const node=$('[data-chart="ownership"]');
|
||||||
|
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 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 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('');
|
||||||
|
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>`;
|
||||||
|
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')]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventChartData,eventSelection=new Set();
|
||||||
|
function drawEventChart() {
|
||||||
|
if (!eventChartData) return;
|
||||||
|
const series=eventChartData.series.map((item,index)=>({...item,color:seriesColor(index)})).filter(item=>eventSelection.has(item.name));
|
||||||
|
$('[data-event-selection-count]').textContent=`${series.length} of ${eventChartData.series.length}`;
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
try { localStorage.setItem(`eventChartSelection:${state.game.id}`,JSON.stringify([...eventSelection])); } catch {}
|
||||||
|
}
|
||||||
|
function renderEventChart(data) {
|
||||||
|
eventChartData=data;
|
||||||
|
let saved;
|
||||||
|
try { saved=JSON.parse(localStorage.getItem(`eventChartSelection:${state.game.id}`)); } catch {}
|
||||||
|
eventSelection=new Set(data.series.map(s=>s.name).filter(name=>!Array.isArray(saved) || saved.includes(name)));
|
||||||
|
$('[data-event-options]').innerHTML=data.series.map((s,i)=>`<label class="event-option" data-event-option><input type="checkbox" value="${cell(s.name,'text')}" ${eventSelection.has(s.name) ? 'checked' : ''}><i class="dot" style="background:${seriesColor(i)}"></i><span>${cell(s.name,'text')}</span></label>`).join('');
|
||||||
|
$('[data-event-search]').value='';
|
||||||
|
drawEventChart();
|
||||||
|
}
|
||||||
|
function bindEventChart() {
|
||||||
|
$('[data-event-options]').addEventListener('change',event=>{
|
||||||
|
if (!event.target.matches('input[type="checkbox"]')) return;
|
||||||
|
if (event.target.checked) eventSelection.add(event.target.value);
|
||||||
|
else eventSelection.delete(event.target.value);
|
||||||
|
drawEventChart();
|
||||||
|
});
|
||||||
|
for (const [selector,checked] of [['[data-events-all]',true],['[data-events-none]',false]]) {
|
||||||
|
$(selector).addEventListener('click',()=>{
|
||||||
|
if (!eventChartData) return;
|
||||||
|
eventSelection=new Set(checked ? eventChartData.series.map(s=>s.name) : []);
|
||||||
|
$$('[data-event-options] input').forEach(input=>input.checked=checked);
|
||||||
|
drawEventChart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('[data-event-search]').addEventListener('input',event=>{
|
||||||
|
const query=event.target.value.trim().toLowerCase();
|
||||||
|
$$('[data-event-option]').forEach(label=>label.hidden=!label.textContent.toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentPage() {
|
||||||
|
if (chartTooltip) chartTooltip.hidden=true;
|
||||||
|
clearError();
|
||||||
|
const page=document.body.dataset.page;
|
||||||
|
if (page==='players') return loadPlayers();
|
||||||
|
if (page==='player-info') return loadProfile();
|
||||||
|
if (page==='activities') return loadActivities();
|
||||||
|
if (page==='monetization') return renderMonetization(await api('monetization'));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const data=await api(page,page==='sessions' ? {segment:$('[data-session-group]').value} : {});
|
||||||
|
fillMetrics(data.metrics);
|
||||||
|
if (page==='events') renderEventChart(data);
|
||||||
|
else lineChart('[data-chart="trend"]',data.series,data.labels);
|
||||||
|
barChart('[data-chart="events"]',data.eventValues,data.eventLabels);
|
||||||
|
if (data.sessions) renderSessions(data.sessions);
|
||||||
|
if (data.events) renderEvents(data.events);
|
||||||
|
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.rootMetrics) {renderRoot(data.rootMetrics.rows);lineChart('[data-chart="ccu"]',data.rootMetrics.series,data.rootMetrics.labels);}
|
||||||
|
}
|
||||||
|
async function boot() {
|
||||||
|
const result=await fetchJSON('/api/games');
|
||||||
|
const requested=params.get('game_id') || localStorage.getItem('sampleGameId');
|
||||||
|
const chosen=result.games.find(g=>String(g.id)===requested);
|
||||||
|
if (params.has('game_id') && !chosen) throw new Error('Game not found. Open the dashboard without game_id to choose a game.');
|
||||||
|
state.game=chosen || result.games[0];
|
||||||
|
if (!state.game) throw new Error('No games available. Run the database seed.');
|
||||||
|
localStorage.setItem('sampleGameId',state.game.id);
|
||||||
|
$$('[data-game-select]').forEach(select=>{
|
||||||
|
select.replaceChildren(...result.games.map(game=>new Option(`${game.name} · #${game.id}`,game.id)));
|
||||||
|
select.value=state.game.id;
|
||||||
|
select.addEventListener('change',()=>{
|
||||||
|
localStorage.setItem('sampleGameId',select.value);
|
||||||
|
const url=new URL(location.href);url.searchParams.set('game_id',select.value);url.searchParams.delete('player_id');
|
||||||
|
location.href=url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('.breadcrumbs').textContent=`${state.game.name} / ${document.title.split(' — ')[0]}`;
|
||||||
|
$$('a[href*="-mockup.html"]').forEach(link=>{
|
||||||
|
const url=new URL(link.getAttribute('href'),location.href);url.searchParams.set('game_id',state.game.id);url.searchParams.set('days',state.days);link.href=url;
|
||||||
|
});
|
||||||
|
const date=$('[data-days]');
|
||||||
|
if (date) {
|
||||||
|
date.value=state.days;
|
||||||
|
if (['players','settings','gamepasses'].includes(document.body.dataset.page)) date.hidden=true;
|
||||||
|
date.addEventListener('change',()=>{
|
||||||
|
state.days=Number(date.value);
|
||||||
|
const url=new URL(location.href);url.searchParams.set('days',state.days);url.searchParams.set('game_id',state.game.id);history.replaceState(null,'',url);
|
||||||
|
$$('a[href*="-mockup.html"]').forEach(link=>{const target=new URL(link.href);target.searchParams.set('days',state.days);link.href=target;});
|
||||||
|
loadCurrentPage().catch(e=>error(e.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.body.dataset.page==='players') bindPlayers();
|
||||||
|
if (document.body.dataset.page==='events') bindEventChart();
|
||||||
|
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)));
|
||||||
|
$('[data-profile-value-second]').addEventListener('change',()=>loadProfile().catch(e=>error(e.message)));
|
||||||
|
$('[data-session-rows]').addEventListener('click',event=>{
|
||||||
|
const button=event.target.closest('[data-join-session]');
|
||||||
|
if (button && !button.disabled) toast('Join session is a placeholder — Roblox joining is not connected yet.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.body.dataset.page==='activities') bindActivities();
|
||||||
|
if (document.body.dataset.page==='sessions') $('[data-session-group]').addEventListener('change',()=>loadCurrentPage().catch(e=>error(e.message)));
|
||||||
|
document.addEventListener('change',event=>{
|
||||||
|
if (event.target.matches('select.select') && !reducedMotion.matches && event.target.animate) event.target.animate([{boxShadow:'0 0 0 3px rgb(124 140 255 / 16%)'},{boxShadow:'0 0 0 3px transparent'}],{duration:280,easing:'ease-out'});
|
||||||
|
});
|
||||||
|
await loadCurrentPage();
|
||||||
|
}
|
||||||
|
boot().catch(e=>error(e.message));
|
||||||
24
mockups/database.py
Normal file
24
mockups/database.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""PostgreSQL connection settings for the development sample service."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def database_url():
|
||||||
|
configured = os.environ.get("DATABASE_URL")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
local = ROOT / ".local" / "database.json"
|
||||||
|
if local.exists():
|
||||||
|
return json.loads(local.read_text(encoding="utf-8-sig"))["url"]
|
||||||
|
raise RuntimeError("Database is not configured. Run mockups/setup-database.ps1 or set DATABASE_URL.")
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return psycopg.connect(database_url(), row_factory=dict_row, connect_timeout=5,
|
||||||
|
options="-c search_path=analytics,public -c timezone=UTC -c statement_timeout=15000")
|
||||||
130
mockups/db/001_initial.sql
Normal file
130
mockups/db/001_initial.sql
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
CREATE SCHEMA IF NOT EXISTS analytics;
|
||||||
|
SET search_path TO analytics, public;
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());
|
||||||
|
CREATE TABLE games (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
roblox_experience_id bigint,
|
||||||
|
timezone text NOT NULL DEFAULT 'UTC',
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}',
|
||||||
|
sample_key text UNIQUE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE players (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
roblox_user_id bigint NOT NULL,
|
||||||
|
display_name text,
|
||||||
|
first_seen timestamptz NOT NULL,
|
||||||
|
last_seen timestamptz,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE (game_id, id), UNIQUE (game_id, roblox_user_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
player_id bigint NOT NULL,
|
||||||
|
started_at timestamptz NOT NULL,
|
||||||
|
ended_at timestamptz,
|
||||||
|
place_name text,
|
||||||
|
stats jsonb NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE (game_id, id), UNIQUE (game_id, player_id, id),
|
||||||
|
FOREIGN KEY (game_id, player_id) REFERENCES players(game_id, id),
|
||||||
|
CHECK (ended_at IS NULL OR ended_at >= started_at)
|
||||||
|
);
|
||||||
|
CREATE TABLE player_snapshots (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
player_id bigint NOT NULL,
|
||||||
|
captured_at timestamptz NOT NULL,
|
||||||
|
data jsonb NOT NULL,
|
||||||
|
FOREIGN KEY (game_id, player_id) REFERENCES players(game_id, id),
|
||||||
|
CHECK (jsonb_typeof(data) = 'object')
|
||||||
|
);
|
||||||
|
CREATE TABLE activity_definitions (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
key text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
start_event text NOT NULL,
|
||||||
|
end_event text,
|
||||||
|
configuration jsonb NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE (game_id, id), UNIQUE (game_id, key)
|
||||||
|
);
|
||||||
|
CREATE TABLE activity_runs (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
definition_id bigint NOT NULL,
|
||||||
|
player_id bigint NOT NULL,
|
||||||
|
session_id bigint,
|
||||||
|
started_at timestamptz NOT NULL,
|
||||||
|
ended_at timestamptz,
|
||||||
|
outcome text,
|
||||||
|
step_count integer,
|
||||||
|
prompt_count integer,
|
||||||
|
captured_values jsonb NOT NULL DEFAULT '{}',
|
||||||
|
FOREIGN KEY (game_id, definition_id) REFERENCES activity_definitions(game_id, id),
|
||||||
|
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),
|
||||||
|
CHECK (ended_at IS NULL OR ended_at >= started_at)
|
||||||
|
);
|
||||||
|
CREATE TABLE events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
category text NOT NULL DEFAULT 'events',
|
||||||
|
name text NOT NULL,
|
||||||
|
player_id bigint,
|
||||||
|
session_id bigint,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}',
|
||||||
|
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),
|
||||||
|
CHECK (session_id IS NULL OR player_id IS NOT NULL)
|
||||||
|
);
|
||||||
|
CREATE TABLE game_metrics (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
category text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
recorded_at timestamptz NOT NULL,
|
||||||
|
value double precision,
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE (game_id, category, name, recorded_at)
|
||||||
|
);
|
||||||
|
CREATE TABLE player_fields (
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
key text NOT NULL,
|
||||||
|
label text NOT NULL,
|
||||||
|
format text NOT NULL DEFAULT 'number',
|
||||||
|
source text NOT NULL DEFAULT 'snapshot',
|
||||||
|
PRIMARY KEY (game_id, key)
|
||||||
|
);
|
||||||
|
CREATE TABLE column_templates (
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
key text NOT NULL,
|
||||||
|
label text NOT NULL,
|
||||||
|
columns jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (game_id, key)
|
||||||
|
);
|
||||||
|
CREATE INDEX sessions_game_time ON sessions(game_id, started_at DESC);
|
||||||
|
CREATE INDEX sessions_player_time ON sessions(game_id, player_id, started_at DESC);
|
||||||
|
CREATE INDEX snapshots_player_latest ON player_snapshots(game_id, player_id, captured_at DESC, id DESC);
|
||||||
|
CREATE INDEX events_game_time ON events(game_id, occurred_at DESC);
|
||||||
|
CREATE INDEX events_player_time ON events(game_id, player_id, occurred_at DESC);
|
||||||
|
CREATE INDEX metrics_game_category_time ON game_metrics(game_id, category, name, recorded_at DESC);
|
||||||
|
CREATE INDEX runs_game_definition ON activity_runs(game_id, definition_id, started_at DESC);
|
||||||
|
CREATE INDEX runs_player ON activity_runs(game_id, player_id, started_at DESC);
|
||||||
|
CREATE VIEW player_statistics AS
|
||||||
|
WITH session_totals AS (
|
||||||
|
SELECT game_id, player_id, count(*)::int AS session_count,
|
||||||
|
sum(extract(epoch FROM ended_at-started_at))::double precision AS playtime,
|
||||||
|
avg(extract(epoch FROM ended_at-started_at))::double precision AS average_session,
|
||||||
|
percentile_cont(.5) WITHIN GROUP (ORDER BY extract(epoch FROM ended_at-started_at))::double precision AS median_session,
|
||||||
|
count(DISTINCT (started_at AT TIME ZONE 'UTC')::date) FILTER (WHERE started_at >= now()-interval '30 days')::int AS active_days
|
||||||
|
FROM sessions GROUP BY game_id, player_id
|
||||||
|
)
|
||||||
|
SELECT p.*, latest.data AS latest_data, latest.captured_at AS snapshot_at,
|
||||||
|
coalesce(s.session_count,0) AS session_count, s.playtime, s.average_session, s.median_session, coalesce(s.active_days,0) AS active_days
|
||||||
|
FROM players p LEFT JOIN session_totals s ON s.game_id=p.game_id AND s.player_id=p.id
|
||||||
|
LEFT JOIN LATERAL (SELECT data,captured_at FROM player_snapshots ps WHERE ps.game_id=p.game_id AND ps.player_id=p.id ORDER BY captured_at DESC,id DESC LIMIT 1) latest ON true;
|
||||||
|
INSERT INTO schema_migrations(version) VALUES ('001_initial');
|
||||||
57
mockups/db/002_monetization.sql
Normal file
57
mockups/db/002_monetization.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
SET search_path TO analytics, public;
|
||||||
|
CREATE TABLE products (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
roblox_product_id bigint NOT NULL,
|
||||||
|
name text,
|
||||||
|
current_price_robux bigint CHECK(current_price_robux>=0),
|
||||||
|
UNIQUE(game_id,id), UNIQUE(game_id,roblox_product_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE product_sales (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
product_id bigint NOT NULL,
|
||||||
|
player_id bigint,
|
||||||
|
session_id bigint,
|
||||||
|
receipt_id text NOT NULL,
|
||||||
|
purchased_at timestamptz NOT NULL,
|
||||||
|
unit_price_robux bigint NOT NULL CHECK(unit_price_robux>=0),
|
||||||
|
quantity integer NOT NULL DEFAULT 1 CHECK(quantity>0),
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE(game_id,receipt_id),
|
||||||
|
FOREIGN KEY(game_id,product_id) REFERENCES products(game_id,id),
|
||||||
|
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),
|
||||||
|
CHECK(session_id IS NULL OR player_id IS NOT NULL)
|
||||||
|
);
|
||||||
|
CREATE INDEX sales_game_time ON product_sales(game_id,purchased_at DESC);
|
||||||
|
CREATE INDEX sales_player_time ON product_sales(game_id,player_id,purchased_at DESC);
|
||||||
|
CREATE INDEX sales_product_time ON product_sales(game_id,product_id,purchased_at DESC);
|
||||||
|
CREATE OR REPLACE VIEW player_statistics AS
|
||||||
|
WITH session_totals AS (
|
||||||
|
SELECT game_id,player_id,count(*)::int session_count,
|
||||||
|
sum(extract(epoch FROM ended_at-started_at))::double precision playtime,
|
||||||
|
avg(extract(epoch FROM ended_at-started_at))::double precision average_session,
|
||||||
|
percentile_cont(.5) WITHIN GROUP(ORDER BY extract(epoch FROM ended_at-started_at))::double precision median_session,
|
||||||
|
count(DISTINCT (started_at AT TIME ZONE 'UTC')::date) FILTER(WHERE started_at>=now()-interval '30 days')::int active_days
|
||||||
|
FROM sessions GROUP BY game_id,player_id
|
||||||
|
), spend AS (
|
||||||
|
SELECT game_id,player_id,sum(unit_price_robux*quantity)::double precision robux,sum(quantity)::int products_bought
|
||||||
|
FROM product_sales WHERE unit_price_robux>0 AND player_id IS NOT NULL GROUP BY game_id,player_id
|
||||||
|
)
|
||||||
|
SELECT p.*,latest.data latest_data,latest.captured_at snapshot_at,
|
||||||
|
coalesce(s.session_count,0) session_count,s.playtime,s.average_session,s.median_session,coalesce(s.active_days,0) active_days,
|
||||||
|
coalesce(spend.robux,0) robux_recorded,coalesce(spend.products_bought,0) products_bought
|
||||||
|
FROM players p LEFT JOIN session_totals s ON s.game_id=p.game_id AND s.player_id=p.id
|
||||||
|
LEFT JOIN spend ON spend.game_id=p.game_id AND spend.player_id=p.id
|
||||||
|
LEFT JOIN LATERAL(SELECT data,captured_at FROM player_snapshots ps WHERE ps.game_id=p.game_id AND ps.player_id=p.id ORDER BY captured_at DESC,id DESC LIMIT 1) latest ON true;
|
||||||
|
|
||||||
|
CREATE VIEW session_day_playtime AS
|
||||||
|
SELECT s.game_id,s.player_id,s.id session_id,d.day::date active_day,
|
||||||
|
CASE WHEN s.ended_at IS NOT NULL THEN greatest(0,extract(epoch FROM least(s.ended_at,d.day+interval '1 day')-greatest(s.started_at,d.day)))::double precision END playtime
|
||||||
|
FROM sessions s CROSS JOIN LATERAL generate_series(date_trunc('day',s.started_at),date_trunc('day',coalesce(s.ended_at,s.started_at)),interval '1 day') d(day);
|
||||||
|
CREATE VIEW player_active_days AS
|
||||||
|
SELECT game_id,player_id,active_day FROM session_day_playtime
|
||||||
|
UNION SELECT game_id,player_id,(occurred_at AT TIME ZONE 'UTC')::date FROM events WHERE player_id IS NOT NULL
|
||||||
|
UNION SELECT game_id,player_id,(purchased_at AT TIME ZONE 'UTC')::date FROM product_sales WHERE player_id IS NOT NULL;
|
||||||
|
INSERT INTO schema_migrations(version) VALUES('002_monetization');
|
||||||
22
mockups/db/003_gamepasses_and_stages.sql
Normal file
22
mockups/db/003_gamepasses_and_stages.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
SET search_path TO analytics, public;
|
||||||
|
CREATE TABLE gamepasses (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
roblox_gamepass_id bigint NOT NULL,
|
||||||
|
name text,
|
||||||
|
UNIQUE(game_id,id), UNIQUE(game_id,roblox_gamepass_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE gamepass_ownership (
|
||||||
|
game_id bigint NOT NULL REFERENCES games(id),
|
||||||
|
gamepass_id bigint NOT NULL,
|
||||||
|
player_id bigint NOT NULL,
|
||||||
|
is_owned boolean NOT NULL,
|
||||||
|
checked_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY(game_id,gamepass_id,player_id),
|
||||||
|
FOREIGN KEY(game_id,gamepass_id) REFERENCES gamepasses(game_id,id),
|
||||||
|
FOREIGN KEY(game_id,player_id) REFERENCES players(game_id,id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ownership_player ON gamepass_ownership(game_id,player_id);
|
||||||
|
ALTER TABLE activity_runs ADD COLUMN stage_progress jsonb;
|
||||||
|
ALTER TABLE activity_runs ADD CONSTRAINT stage_progress_array CHECK(stage_progress IS NULL OR jsonb_typeof(stage_progress)='array');
|
||||||
|
INSERT INTO schema_migrations(version) VALUES('003_gamepasses_and_stages');
|
||||||
43
mockups/db/seed.sql
Normal file
43
mockups/db/seed.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- Sample catalogs and dashboard configuration. Player histories are generated by realistic_seed.py.
|
||||||
|
SET search_path TO analytics, public;
|
||||||
|
INSERT INTO games(name,roblox_experience_id,sample_key,properties) VALUES
|
||||||
|
('Willow''s Park',18472930652,'willow-v1',jsonb_build_object('sampleAsOf',now(),'description','Social exploration and collection')),
|
||||||
|
('Orbit Racers',9482036192,'orbit-v1',jsonb_build_object('sampleAsOf',now(),'description','Competitive racing')),
|
||||||
|
('Dungeon Workshop',NULL,'dungeon-v1',jsonb_build_object('sampleAsOf',now(),'description','Adventure and crafting'))
|
||||||
|
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"]}'),
|
||||||
|
('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');
|
||||||
|
|
||||||
|
INSERT INTO player_fields(game_id,key,label,format,source)
|
||||||
|
SELECT g.id,f.* FROM games g CROSS JOIN (VALUES
|
||||||
|
('level','Level','number','snapshot'),('cash','Cash balance','currency','snapshot'),
|
||||||
|
('cashEarned','Cash earned','currency','snapshot'),('cashSpent','Cash spent','currency','snapshot'),
|
||||||
|
('robux','Robux spent','robux','snapshot'),('purchases','Purchases','number','snapshot'),
|
||||||
|
('playtime','Lifetime playtime','duration','derived'),('sessions','Sessions','number','derived'),
|
||||||
|
('avgSession','Average session','duration','derived'),('activeDays','Active days (30d)','number','derived'),
|
||||||
|
('levelsGained','Levels gained','number','snapshot'),('quests','Quests completed','number','snapshot'),
|
||||||
|
('achievements','Achievements','number','snapshot'),('lastSeen','Minutes since last seen','number','derived'),
|
||||||
|
('robuxPerHour','Robux per playtime hour','decimal','derived')
|
||||||
|
) f WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1');
|
||||||
|
INSERT INTO player_fields SELECT id,'roundWins','Rounds won','number','snapshot' FROM games WHERE sample_key='orbit-v1';
|
||||||
|
INSERT INTO column_templates(game_id,key,label,columns)
|
||||||
|
SELECT g.id,t.key,t.label,t.columns::jsonb FROM games g CROSS JOIN (VALUES
|
||||||
|
('overview','Overview','["level","cash","playtime","sessions","lastSeen"]'),
|
||||||
|
('economy','Economy','["cash","cashEarned","cashSpent","robux","purchases","robuxPerHour"]'),
|
||||||
|
('progress','Progress','["level","levelsGained","quests","achievements","playtime"]'),
|
||||||
|
('engagement','Engagement','["playtime","sessions","avgSession","activeDays","lastSeen"]')
|
||||||
|
) t(key,label,columns) WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1');
|
||||||
|
|
||||||
|
INSERT INTO products(game_id,roblox_product_id,name,current_price_robux)
|
||||||
|
SELECT g.id,8000000+g.id*100+n,names[n],prices[n]
|
||||||
|
FROM games g CROSS JOIN generate_series(1,4) n
|
||||||
|
CROSS JOIN (SELECT ARRAY['Double XP','Gem bundle','Starter pack','Coin bundle'] names,ARRAY[49,149,399,999] prices) catalog
|
||||||
|
WHERE g.sample_key IN ('willow-v1','orbit-v1','dungeon-v1');
|
||||||
|
|
||||||
|
UPDATE player_fields SET source='derived' WHERE key IN ('robux','purchases') AND game_id IN (SELECT id FROM games WHERE sample_key IN ('willow-v1','orbit-v1','dungeon-v1'));
|
||||||
3
mockups/events-mockup.html
Normal file
3
mockups/events-mockup.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Events — Statboard</title><link rel="stylesheet" href="mockup.css"></head>
|
||||||
|
<body data-page="events"><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="active" 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">Events</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>Events</h1><div class="subtitle">Daily event totals across the selected game. Individual records remain available in player info.</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">Events</div><div class="metric-value" data-metric="events" data-format="number">N/A</div><div class="metric-foot">Selected period</div></article><article class="card card-pad metric-card"><div class="metric-label">Event types</div><div class="metric-value" data-metric="eventTypes" data-format="number">N/A</div><div class="metric-foot">Recorded event names</div></article><article class="card card-pad metric-card"><div class="metric-label">Players</div><div class="metric-value" data-metric="players" data-format="number">N/A</div><div class="metric-foot">Distinct players with events</div></article><article class="card card-pad metric-card"><div class="metric-label">Latest event</div><div class="metric-value" data-metric="latestEvent" data-format="date">N/A</div><div class="metric-foot">UTC</div></article></div><article class="card"><div class="card-head"><div><div class="card-title">Events per day</div><div class="card-note">One line per event · UTC daily counts</div></div></div><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><div class="chart-wrap" data-chart="trend"><div class="empty">N/A</div></div></article><div class="section-gap"><article class="card"><div class="card-head"><div><div class="card-title">Event summary</div><div class="card-note">One row per event name in the selected period</div></div></div><div class="table-wrap"><table><thead><tr><th>Event</th><th>Categories</th><th>Total</th><th>Daily average</th><th>Unique players</th><th>Last seen (UTC)</th></tr></thead><tbody data-event-summary><tr><td colspan="6" 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="active" 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>
|
||||||
3
mockups/gamepasses-mockup.html
Normal file
3
mockups/gamepasses-mockup.html
Normal file
@@ -0,0 +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>
|
||||||
2
mockups/login-mockup.html
Normal file
2
mockups/login-mockup.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in — Statboard</title><link rel="stylesheet" href="mockup.css"></head><body class="login-page"><main class="login-card"><a class="brand login-brand" href="overview-mockup.html"><span class="brand-mark">S</span> Statboard</a><h1>Welcome back</h1><p class="login-subtitle">Sign in to your team's analytics workspace.</p><form><label class="login-label" for="email">Email</label><input class="login-input" id="email" type="email" placeholder="you@studio.com"><label class="login-label" for="password">Password</label><input class="login-input" id="password" type="password" placeholder="Enter your password"><button class="button primary login-submit" formaction="overview-mockup.html">Sign in</button></form><div class="login-foot">Private team workspace · Access by invitation</div></main></body></html>
|
||||||
71
mockups/manage_database.py
Normal file
71
mockups/manage_database.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""Create/migrate/seed the sample database without replacing existing records."""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg import sql
|
||||||
|
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
||||||
|
|
||||||
|
from database import connect, database_url
|
||||||
|
from realistic_seed import populate, reset_sample
|
||||||
|
|
||||||
|
DB_FILES = Path(__file__).parent / "db"
|
||||||
|
|
||||||
|
|
||||||
|
def initialize():
|
||||||
|
settings = conninfo_to_dict(database_url())
|
||||||
|
name = settings.get("dbname", "stat_dashboard_sample")
|
||||||
|
try:
|
||||||
|
with connect():
|
||||||
|
pass
|
||||||
|
except psycopg.OperationalError as error:
|
||||||
|
if error.sqlstate != '3D000' and 'does not exist' not in str(error):
|
||||||
|
raise
|
||||||
|
admin = make_conninfo(database_url(), dbname="postgres")
|
||||||
|
with psycopg.connect(admin, autocommit=True) as conn:
|
||||||
|
conn.execute(sql.SQL("CREATE DATABASE {} ENCODING 'UTF8'").format(sql.Identifier(name)))
|
||||||
|
with connect() as conn:
|
||||||
|
installed = conn.execute("SELECT to_regclass('analytics.schema_migrations') AS installed").fetchone()["installed"]
|
||||||
|
versions = {r["version"] for r in conn.execute("SELECT version FROM analytics.schema_migrations")} if installed else set()
|
||||||
|
for migration in sorted(DB_FILES.glob("[0-9]*.sql")):
|
||||||
|
if migration.stem not in versions:
|
||||||
|
conn.execute(migration.read_text(encoding="utf-8"))
|
||||||
|
print(f"Applied migration {migration.stem}.")
|
||||||
|
seed()
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
with connect() as conn:
|
||||||
|
if conn.execute("SELECT 1 FROM games WHERE sample_key='willow-v1'").fetchone():
|
||||||
|
print("Sample dataset already exists; keeping existing records.")
|
||||||
|
else:
|
||||||
|
conn.execute("SET LOCAL statement_timeout=0")
|
||||||
|
populate(conn)
|
||||||
|
print("Seeded three games with 90 days of sample data.")
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute("ANALYZE")
|
||||||
|
status()
|
||||||
|
|
||||||
|
|
||||||
|
def reseed():
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute('SET LOCAL statement_timeout=0')
|
||||||
|
reset_sample(conn)
|
||||||
|
populate(conn)
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute('ANALYZE')
|
||||||
|
status()
|
||||||
|
|
||||||
|
|
||||||
|
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"):
|
||||||
|
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()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("command", choices=["init", "seed", "status", "reset-sample"], default="init", nargs="?")
|
||||||
|
globals()[{"init": "initialize", "seed": "seed", "status": "status", "reset-sample": "reseed"}[parser.parse_args().command]]()
|
||||||
114
mockups/metrics_api.py
Normal file
114
mockups/metrics_api.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Explicit UTC activity and reported-sales metric definitions."""
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
|
||||||
|
def engagement(conn,game_id,days,player_ids=None):
|
||||||
|
daily=conn.execute("""
|
||||||
|
WITH calendar AS (SELECT generate_series(current_date-(%(days)s-1),current_date,interval '1 day')::date AS day),
|
||||||
|
selected_players AS (SELECT id,first_seen FROM players WHERE game_id=%(gid)s AND (%(player_ids)s::bigint[] IS NULL OR id=ANY(%(player_ids)s))),
|
||||||
|
active AS (SELECT player_id,active_day FROM player_active_days WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) AND active_day>=current_date-(%(days)s+29)),
|
||||||
|
first_sessions AS (SELECT DISTINCT ON(player_id) player_id,started_at,ended_at FROM sessions WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) ORDER BY player_id,started_at,id),
|
||||||
|
visits AS (SELECT a.active_day AS day,count(*) dau,count(*) FILTER(WHERE (p.first_seen AT TIME ZONE 'UTC')::date=a.active_day) new_users
|
||||||
|
FROM active a JOIN selected_players p ON p.id=a.player_id GROUP BY a.active_day),
|
||||||
|
times AS (SELECT active_day AS day,sum(playtime) total,count(*) observed,count(playtime) known FROM session_day_playtime
|
||||||
|
WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) AND active_day>=current_date-(%(days)s-1) GROUP BY active_day),
|
||||||
|
starts AS (SELECT (s.started_at AT TIME ZONE 'UTC')::date AS day,count(*) sessions,
|
||||||
|
avg(extract(epoch FROM ended_at-started_at))::double precision avg_session,
|
||||||
|
sum(extract(epoch FROM ended_at-started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date)::double precision new_time,
|
||||||
|
count(s.ended_at) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date) new_count,
|
||||||
|
sum(extract(epoch FROM ended_at-started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date)::double precision returning_time,
|
||||||
|
count(s.ended_at) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date) returning_count
|
||||||
|
FROM sessions s JOIN selected_players p ON p.id=s.player_id
|
||||||
|
WHERE s.game_id=%(gid)s AND s.started_at>=current_date-(%(days)s-1) GROUP BY day),
|
||||||
|
first5 AS (SELECT (started_at AT TIME ZONE 'UTC')::date AS day,count(ended_at) known,
|
||||||
|
count(*) FILTER(WHERE ended_at-started_at>=interval '5 minutes') stayed5 FROM first_sessions GROUP BY day)
|
||||||
|
SELECT c.day,coalesce(v.dau,0)::int dau,coalesce(v.new_users,0)::int new_users,
|
||||||
|
(coalesce(v.dau,0)-coalesce(v.new_users,0))::int returning_users,
|
||||||
|
(SELECT count(DISTINCT player_id)::int FROM active a WHERE a.active_day BETWEEN c.day-29 AND c.day) mau,
|
||||||
|
coalesce(s.sessions,0)::int sessions,s.avg_session,
|
||||||
|
CASE WHEN t.known>0 THEN t.total WHEN t.observed>0 THEN NULL ELSE 0 END total_playtime,
|
||||||
|
s.new_time,s.new_count,s.returning_time,s.returning_count,f.known first_known,f.stayed5 first_stayed5
|
||||||
|
FROM calendar c LEFT JOIN visits v ON v.day=c.day LEFT JOIN times t ON t.day=c.day
|
||||||
|
LEFT JOIN starts s ON s.day=c.day LEFT JOIN first5 f ON f.day=c.day ORDER BY c.day
|
||||||
|
""",{"gid":game_id,"days":days,"player_ids":player_ids}).fetchall()
|
||||||
|
for r in daily:
|
||||||
|
r['average_playtime']=r['total_playtime']/r['dau'] if r['total_playtime'] is not None and r['dau'] else None
|
||||||
|
r['new_session_time']=r['new_time']/r['new_count'] if r['new_count'] else None
|
||||||
|
r['returning_session_time']=r['returning_time']/r['returning_count'] if r['returning_count'] else None
|
||||||
|
r['first_session_5min']=100*r['first_stayed5']/r['first_known'] if r['first_known'] else None
|
||||||
|
latest=daily[-1]
|
||||||
|
new_count=sum(r['new_count'] or 0 for r in daily)
|
||||||
|
returning_count=sum(r['returning_count'] or 0 for r in daily)
|
||||||
|
first_known=sum(r['first_known'] or 0 for r in daily)
|
||||||
|
known_time=[r['total_playtime'] for r in daily if r['total_playtime'] is not None]
|
||||||
|
active_users=conn.execute("SELECT count(DISTINCT player_id) total FROM player_active_days WHERE game_id=%s AND active_day>=current_date-(%s-1) AND (%s::bigint[] IS NULL OR player_id=ANY(%s))",(game_id,days,player_ids,player_ids)).fetchone()['total']
|
||||||
|
total=sum(known_time) if known_time else None
|
||||||
|
metrics={'dau':latest['dau'],'mau':latest['mau'],'newUsersToday':latest['new_users'],'returningUsersToday':latest['returning_users'],
|
||||||
|
'totalPlaytime':total,'averagePlaytime':total/active_users if total is not None and active_users else None,
|
||||||
|
'newSessionTime':sum(r['new_time'] or 0 for r in daily)/new_count if new_count else None,
|
||||||
|
'returningSessionTime':sum(r['returning_time'] or 0 for r in daily)/returning_count if returning_count else None,
|
||||||
|
'firstSession5min':100*sum(r['first_stayed5'] or 0 for r in daily)/first_known if first_known else None,
|
||||||
|
'activeUsers':active_users}
|
||||||
|
return {'metrics':metrics,'daily':daily}
|
||||||
|
|
||||||
|
|
||||||
|
def monetization(conn,game_id,days):
|
||||||
|
totals=conn.execute("""SELECT coalesce(sum(unit_price_robux*quantity),0)::double precision revenue,
|
||||||
|
count(DISTINCT player_id) FILTER(WHERE unit_price_robux>0)::int paying_users,
|
||||||
|
coalesce(sum(quantity) FILTER(WHERE unit_price_robux>0),0)::int paid_products,
|
||||||
|
count(*) FILTER(WHERE player_id IS NULL AND unit_price_robux>0)::int unattributed_sales,
|
||||||
|
count(*)::int transactions FROM product_sales WHERE game_id=%s AND purchased_at>=current_date-(%s-1)""",(game_id,days)).fetchone()
|
||||||
|
active=conn.execute("""SELECT count(DISTINCT player_id)::int users,count(*)::int user_days FROM player_active_days
|
||||||
|
WHERE game_id=%s AND active_day>=current_date-(%s-1)""",(game_id,days)).fetchone()
|
||||||
|
valid=totals['unattributed_sales']==0
|
||||||
|
metrics={'revenue':totals['revenue'],'payingUsers':totals['paying_users'] if valid else None,
|
||||||
|
'payerConversion':100*totals['paying_users']/active['users'] if active['users'] and valid else None,
|
||||||
|
'arppu':totals['revenue']/totals['paying_users'] if totals['paying_users'] and valid else None,
|
||||||
|
'productsPerPayer':totals['paid_products']/totals['paying_users'] if totals['paying_users'] and valid else None,
|
||||||
|
'arpdau':totals['revenue']/active['user_days'] if active['user_days'] and valid else None,
|
||||||
|
'transactions':totals['transactions'],'unattributedSales':totals['unattributed_sales']}
|
||||||
|
daily=conn.execute("""WITH calendar AS(SELECT generate_series(current_date-(%(days)s-1),current_date,interval '1 day')::date AS day),
|
||||||
|
sales AS(SELECT (purchased_at AT TIME ZONE 'UTC')::date AS day,sum(unit_price_robux*quantity)::double precision robux,
|
||||||
|
count(DISTINCT player_id) FILTER(WHERE unit_price_robux>0)::int payers FROM product_sales
|
||||||
|
WHERE game_id=%(gid)s AND purchased_at>=current_date-(%(days)s-1) GROUP BY day),
|
||||||
|
active AS(SELECT active_day AS day,count(*)::int dau FROM player_active_days WHERE game_id=%(gid)s AND active_day>=current_date-(%(days)s-1) GROUP BY day)
|
||||||
|
SELECT c.day,coalesce(s.robux,0) robux,coalesce(s.payers,0) payers,coalesce(a.dau,0) dau FROM calendar c LEFT JOIN sales s ON s.day=c.day LEFT JOIN active a ON a.day=c.day ORDER BY c.day""",{'gid':game_id,'days':days}).fetchall()
|
||||||
|
for r in daily:
|
||||||
|
r['arpdau']=r['robux']/r['dau'] if r['dau'] and valid else None
|
||||||
|
r['conversion']=100*r['payers']/r['dau'] if r['dau'] and valid else None
|
||||||
|
if not valid:
|
||||||
|
r['payers']=None
|
||||||
|
hourly=conn.execute("""WITH hours AS(SELECT generate_series(date_trunc('day',now())-(%(days)s-1)*interval '1 day',date_trunc('hour',now()),interval '1 hour') AS hour),
|
||||||
|
sales AS(SELECT date_trunc('hour',purchased_at) AS hour,sum(unit_price_robux*quantity)::double precision robux FROM product_sales
|
||||||
|
WHERE game_id=%(gid)s AND purchased_at>=current_date-(%(days)s-1) GROUP BY hour)
|
||||||
|
SELECT h.hour,coalesce(s.robux,0) robux FROM hours h LEFT JOIN sales s ON s.hour=h.hour ORDER BY h.hour""",{'gid':game_id,'days':days}).fetchall()
|
||||||
|
products=conn.execute("""SELECT p.id,p.roblox_product_id,p.name,p.current_price_robux,
|
||||||
|
coalesce(sum(s.unit_price_robux*s.quantity),0)::double precision robux,
|
||||||
|
coalesce(sum(s.quantity) FILTER(WHERE s.unit_price_robux>0),0)::int quantity,
|
||||||
|
count(DISTINCT s.player_id) FILTER(WHERE s.unit_price_robux>0)::int buyers
|
||||||
|
FROM products p LEFT JOIN product_sales s ON s.game_id=p.game_id AND s.product_id=p.id AND s.purchased_at>=current_date-(%s-1)
|
||||||
|
WHERE p.game_id=%s GROUP BY p.id ORDER BY robux DESC,p.id""",(days,game_id)).fetchall()
|
||||||
|
for p in products:
|
||||||
|
p['perPayer']=p['quantity']/totals['paying_users'] if totals['paying_users'] and valid else None
|
||||||
|
return {'metrics':metrics,'daily':daily,'hourly':hourly,'products':products}
|
||||||
|
|
||||||
|
|
||||||
|
def gamepasses(conn,game_id):
|
||||||
|
totals=conn.execute("""SELECT count(*)::int players,count(*) FILTER(WHERE EXISTS(
|
||||||
|
SELECT 1 FROM product_sales s WHERE s.game_id=p.game_id AND s.player_id=p.id AND s.unit_price_robux>0))::int payers
|
||||||
|
FROM players p WHERE p.game_id=%s""",(game_id,)).fetchone()
|
||||||
|
passes=conn.execute("""WITH payers AS(SELECT DISTINCT player_id FROM product_sales WHERE game_id=%s AND unit_price_robux>0 AND player_id IS NOT NULL)
|
||||||
|
SELECT gp.id,gp.name,gp.roblox_gamepass_id,
|
||||||
|
count(o.player_id)::int checked,count(o.player_id) FILTER(WHERE o.is_owned)::int owners,
|
||||||
|
count(o.player_id) FILTER(WHERE p.player_id IS NOT NULL)::int checked_payers,
|
||||||
|
count(o.player_id) FILTER(WHERE o.is_owned AND p.player_id IS NOT NULL)::int paying_owners,
|
||||||
|
min(o.checked_at) oldest_check,max(o.checked_at) newest_check
|
||||||
|
FROM gamepasses gp LEFT JOIN gamepass_ownership o ON o.game_id=gp.game_id AND o.gamepass_id=gp.id
|
||||||
|
LEFT JOIN payers p ON p.player_id=o.player_id WHERE gp.game_id=%s GROUP BY gp.id ORDER BY gp.id""",(game_id,game_id)).fetchall()
|
||||||
|
for gp in passes:
|
||||||
|
gp['playerPercent']=100*gp['owners']/totals['players'] if totals['players'] and gp['checked']==totals['players'] else None
|
||||||
|
gp['payerPercent']=100*gp['paying_owners']/totals['payers'] if totals['payers'] and gp['checked_payers']==totals['payers'] else None
|
||||||
|
gp['coverage']=100*gp['checked']/totals['players'] if totals['players'] else None
|
||||||
|
return {'metrics':{'gamepasses':len(passes),'trackedPlayers':totals['players'],'lifetimePayers':totals['payers']},
|
||||||
|
'gamepasses':passes,'denominators':totals,
|
||||||
|
'definition':'Current reported ownership across all tracked players and lifetime positive-price paying users. Unknown ownership is not treated as not owned.'}
|
||||||
412
mockups/mockup.css
Normal file
412
mockups/mockup.css
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #0b0c10;
|
||||||
|
--sidebar: #0e1015;
|
||||||
|
--surface: #12151c;
|
||||||
|
--surface-raised: #171a23;
|
||||||
|
--surface-hover: #1d212c;
|
||||||
|
--border: #252a36;
|
||||||
|
--border-soft: #1c202a;
|
||||||
|
--text: #f2f4f8;
|
||||||
|
--muted: #9299a8;
|
||||||
|
--dim: #676f7d;
|
||||||
|
--accent: #7c8cff;
|
||||||
|
--accent-strong: #6477ff;
|
||||||
|
--cyan: #49d4dc;
|
||||||
|
--green: #43d49b;
|
||||||
|
--amber: #f2bb61;
|
||||||
|
--red: #f07483;
|
||||||
|
--chart-1: #7c8cff;
|
||||||
|
--chart-2: #49d4dc;
|
||||||
|
--chart-3: #f2bb61;
|
||||||
|
--chart-4: #ed82b8;
|
||||||
|
--chart-5: #72d59d;
|
||||||
|
--chart-6: #bc96fa;
|
||||||
|
--chart-7: #f08d72;
|
||||||
|
--chart-8: #8bc7f4;
|
||||||
|
--chart-9: #d5d378;
|
||||||
|
--chart-10: #a8aaa5;
|
||||||
|
--chart-11: #ef7793;
|
||||||
|
--chart-12: #67c7b5;
|
||||||
|
--radius: 12px;
|
||||||
|
--sidebar-width: 232px;
|
||||||
|
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--motion-fast: 150ms;
|
||||||
|
--motion-normal: 260ms;
|
||||||
|
--motion-reveal: 420ms;
|
||||||
|
--ease-out: cubic-bezier(.22, 1, .36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--page); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.5; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button { color: inherit; cursor: pointer; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
.app { min-height: 100vh; }
|
||||||
|
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 5; display: flex; width: var(--sidebar-width); flex-direction: column; border-right: 1px solid var(--border-soft); background: var(--sidebar); padding: 20px 14px; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 11px; padding: 2px 8px 22px; font-size: 15px; font-weight: 700; letter-spacing: -.02em; }
|
||||||
|
.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 9px; background: linear-gradient(140deg,#798aff,#a58aff); color: white; font-weight: 800; }
|
||||||
|
.project-select { margin: 0 2px 22px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); padding: 10px; }
|
||||||
|
.project-label, .eyebrow { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.project-name { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; font-weight: 600; }
|
||||||
|
.nav-label { margin: 0 9px 8px; color: var(--dim); font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.nav { display: grid; gap: 4px; }
|
||||||
|
.nav a { display: flex; min-height: 39px; align-items: center; gap: 11px; border-radius: 8px; padding: 0 10px; color: var(--muted); font-size: 13px; }
|
||||||
|
.nav a:hover { background: var(--surface-hover); color: var(--text); }
|
||||||
|
.nav a.active { background: #1a1d2a; color: #dce1ff; }
|
||||||
|
.nav-icon { display: inline-grid; width: 18px; place-items: center; color: currentColor; font-size: 15px; }
|
||||||
|
.sidebar-bottom { margin-top: auto; border-top: 1px solid var(--border-soft); padding-top: 13px; }
|
||||||
|
.user-chip { display: flex; align-items: center; gap: 10px; padding: 9px 7px; }
|
||||||
|
.avatar { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 50%; background: #2d3653; color: #dce1ff; font-size: 11px; font-weight: 700; }
|
||||||
|
.user-name { font-size: 12px; font-weight: 600; }
|
||||||
|
.user-role { color: var(--muted); font-size: 11px; }
|
||||||
|
.main { min-height: 100vh; margin-left: var(--sidebar-width); }
|
||||||
|
.topbar { position: sticky; top: 0; z-index: 4; display: flex; min-height: 62px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-soft); background: rgb(11 12 16 / 92%); padding: 0 30px; backdrop-filter: blur(16px); }
|
||||||
|
.breadcrumbs { color: var(--muted); font-size: 12px; }
|
||||||
|
.breadcrumbs strong { color: var(--text); font-weight: 600; }
|
||||||
|
.top-actions, .toolbar, .inline { display: flex; align-items: center; gap: 9px; }
|
||||||
|
.icon-button, .button, .select, .date-button { min-height: 34px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); padding: 0 11px; color: var(--text); font-size: 12px; }
|
||||||
|
.icon-button { width: 34px; padding: 0; color: var(--muted); }
|
||||||
|
.button:hover, .icon-button:hover, .date-button:hover { background: var(--surface-hover); }
|
||||||
|
.button.primary { border-color: var(--accent-strong); background: var(--accent-strong); color: #fff; font-weight: 600; }
|
||||||
|
.button.ghost { background: transparent; }
|
||||||
|
.page { max-width: 1500px; margin: 0 auto; padding: 28px 30px 48px; }
|
||||||
|
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||||
|
h1 { margin: 0; font-size: 23px; font-weight: 650; letter-spacing: -.035em; }
|
||||||
|
.subtitle { margin-top: 5px; color: var(--muted); font-size: 12px; }
|
||||||
|
.page-controls { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.tabs { display: flex; gap: 22px; border-bottom: 1px solid var(--border); margin: 0 0 20px; }
|
||||||
|
.tabs a { position: relative; padding: 0 1px 11px; color: var(--muted); font-size: 12px; }
|
||||||
|
.tabs a.active { color: var(--text); }
|
||||||
|
.tabs a.active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--accent); content: ""; }
|
||||||
|
.grid { display: grid; gap: 14px; }
|
||||||
|
.metrics { grid-template-columns: repeat(4,minmax(0,1fr)); margin-bottom: 14px; }
|
||||||
|
.two-col { grid-template-columns: minmax(0,1.55fr) minmax(290px,1fr); }
|
||||||
|
.three-col { grid-template-columns: repeat(3,minmax(0,1fr)); }
|
||||||
|
.card { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
||||||
|
.card-pad { padding: 17px 18px; }
|
||||||
|
.metric-label { color: var(--muted); font-size: 12px; }
|
||||||
|
.metric-value { margin-top: 9px; font-size: 26px; font-weight: 650; letter-spacing: -.04em; }
|
||||||
|
.metric-foot { display: flex; align-items: center; gap: 6px; margin-top: 5px; color: var(--muted); font-size: 11px; }
|
||||||
|
.positive { color: var(--green); }
|
||||||
|
.negative { color: var(--red); }
|
||||||
|
.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px 0; }
|
||||||
|
.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.short { height: 170px; }
|
||||||
|
.chart.tall { height: 300px; }
|
||||||
|
.chart text { fill: var(--muted); font-family: var(--font); font-size: 10px; }
|
||||||
|
.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; }
|
||||||
|
.chart .line.secondary { stroke: var(--cyan); stroke-width: 2; }
|
||||||
|
.chart .area { fill: url(#areaFill); }
|
||||||
|
.chart .bar { fill: #7889ff; opacity: .8; }
|
||||||
|
.chart .bar:hover { opacity: 1; }
|
||||||
|
.chart-wrap { padding: 0 16px 10px; }
|
||||||
|
.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; }
|
||||||
|
.chart-inspection[hidden], .chart-tooltip[hidden] { display: none; }
|
||||||
|
.chart-crosshair { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 4 4; vector-effect: non-scaling-stroke; }
|
||||||
|
.chart-hover-point { fill: var(--surface); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||||
|
.chart-hover-band { fill: var(--accent); opacity: .09; }
|
||||||
|
.chart-tooltip { position: fixed; z-index: 100; pointer-events: none; max-width: min(360px, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: hidden; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); color: var(--text); box-shadow: 0 8px 28px rgb(0 0 0 / 35%); font-size: 12px; }
|
||||||
|
.chart-tooltip-title { margin-bottom: 8px; font-weight: 600; }
|
||||||
|
.chart-tooltip-row { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; padding: 3px 0; }
|
||||||
|
.chart-tooltip-row > span { display: flex; align-items: center; gap: 7px; min-width: 0; overflow-wrap: anywhere; color: var(--muted); }
|
||||||
|
.chart-tooltip-row strong { flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.legend { display: flex; flex-wrap: wrap; gap: 14px; color: var(--muted); font-size: 10px; }
|
||||||
|
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||||
|
.dot.cyan { background: var(--cyan); }
|
||||||
|
.dot.green { background: var(--green); }
|
||||||
|
.dot.amber { background: var(--amber); }
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
.player-table-wrap { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-color: var(--dim) var(--surface); }
|
||||||
|
.player-table-wrap table { min-width: 1180px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; white-space: nowrap; }
|
||||||
|
th { padding: 10px 15px; color: var(--muted); font-size: 10px; font-weight: 600; text-align: left; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
th[data-key] { cursor: pointer; user-select: none; }
|
||||||
|
th[data-key]:hover { color: var(--text); }
|
||||||
|
td { border-top: 1px solid var(--border-soft); padding: 12px 15px; font-size: 12px; }
|
||||||
|
tbody tr:hover { background: rgb(255 255 255 / 2%); }
|
||||||
|
.pill { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 999px; padding: 3px 8px; color: var(--muted); font-size: 10px; }
|
||||||
|
.pill.good { border-color: #234537; background: #14251e; color: var(--green); }
|
||||||
|
.pill.warn { border-color: #4c3e25; background: #282114; color: var(--amber); }
|
||||||
|
.event-name { color: #dce1ff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
|
||||||
|
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 16px; }
|
||||||
|
.filter-input { min-width: 185px; min-height: 34px; border: 1px solid var(--border); border-radius: 8px; outline: none; background: var(--surface); padding: 0 10px; color: var(--text); font-size: 12px; }
|
||||||
|
.filter-input:focus { border-color: var(--accent); }
|
||||||
|
.filter-input::placeholder { color: var(--dim); }
|
||||||
|
.player-controls { align-items: end; padding: 0 16px 13px; }
|
||||||
|
.control-label { display: grid; gap: 5px; color: var(--muted); font-size: 10px; font-weight: 600; }
|
||||||
|
.control-label .select { min-width: 145px; }
|
||||||
|
.empty { padding: 35px 20px; color: var(--muted); text-align: center; }
|
||||||
|
.retention-grid { display: grid; grid-template-columns: 150px repeat(8,minmax(56px,1fr)); min-width: 700px; gap: 4px; padding: 14px; }
|
||||||
|
.retention-grid > div { display: grid; min-height: 35px; place-items: center; border-radius: 5px; font-size: 10px; }
|
||||||
|
.retention-grid .heat-label { justify-items: start; color: var(--muted); }
|
||||||
|
.settings-layout { display: grid; grid-template-columns: 190px minmax(0,1fr); gap: 18px; }
|
||||||
|
.settings-nav { display: grid; align-content: start; gap: 3px; }
|
||||||
|
.settings-nav a { border-radius: 7px; padding: 9px 10px; color: var(--muted); font-size: 12px; }
|
||||||
|
.settings-nav a.active { background: var(--surface-hover); color: var(--text); }
|
||||||
|
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-top: 1px solid var(--border-soft); padding: 15px 0; }
|
||||||
|
.setting-row:first-child { border-top: 0; }
|
||||||
|
.setting-title { font-size: 12px; font-weight: 600; }
|
||||||
|
.setting-help { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||||
|
.status { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; }
|
||||||
|
.status::before { width: 7px; height: 7px; border-radius: 50%; background: var(--green); content: ""; }
|
||||||
|
.mobile-nav { display: none; }
|
||||||
|
.menu-toggle { display: none; }
|
||||||
|
.loading { color: var(--muted); font-size: 12px; }
|
||||||
|
.login-page { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: radial-gradient(ellipse at 50% 0%, #171b2b 0%, var(--page) 48%); }
|
||||||
|
.login-card { width: min(100%, 400px); border: 1px solid var(--border); border-radius: 16px; background: var(--surface); padding: 30px; box-shadow: 0 24px 80px #0007; }
|
||||||
|
.login-brand { justify-content: center; padding: 0 0 27px; }
|
||||||
|
.login-card h1 { text-align: center; font-size: 22px; }
|
||||||
|
.login-subtitle { margin: 7px 0 23px; color: var(--muted); text-align: center; font-size: 12px; }
|
||||||
|
.login-label { display: block; margin: 14px 0 6px; color: var(--muted); font-size: 11px; font-weight: 600; }
|
||||||
|
.login-input { width: 100%; height: 40px; border: 1px solid var(--border); border-radius: 8px; outline: none; background: #0d0f14; padding: 0 11px; color: var(--text); }
|
||||||
|
.login-input:focus { border-color: var(--accent); }
|
||||||
|
.login-submit { width: 100%; height: 40px; margin-top: 20px; }
|
||||||
|
.login-foot { margin-top: 18px; color: var(--dim); text-align: center; font-size: 10px; }
|
||||||
|
.activity-picker-row { display: flex; align-items: end; gap: 12px; margin: 0 0 19px; }
|
||||||
|
.activity-select-wrap { display: grid; gap: 5px; min-width: 210px; }
|
||||||
|
.activity-updated { margin-left: auto; color: var(--muted); font-size: 11px; }
|
||||||
|
.activity-panel { display: none; }
|
||||||
|
.activity-panel.active { display: block; animation: surface-enter var(--motion-normal) var(--ease-out) both; }
|
||||||
|
.activity-flow-card { margin-bottom: 14px; }
|
||||||
|
.activity-flow { display: flex; align-items: center; overflow-x: auto; padding: 24px 18px 20px; }
|
||||||
|
.flow-node { display: grid; min-width: 146px; min-height: 91px; align-content: center; gap: 5px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-raised); padding: 11px; }
|
||||||
|
.flow-node.start { border-color: #35466a; background: #151d2c; }
|
||||||
|
.flow-node.prompt { border-color: #59462a; background: #211c15; }
|
||||||
|
.flow-node.outcome { border-color: #3b355e; background: #1b1929; }
|
||||||
|
.flow-node.end { border-color: #2b4438; background: #141f19; }
|
||||||
|
.flow-kind { color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .09em; }
|
||||||
|
.flow-node code, .captured-list code { color: #dce1ff; font-size: 11px; }
|
||||||
|
.flow-node small { color: var(--muted); font-size: 10px; }
|
||||||
|
.flow-connector { position: relative; min-width: 28px; height: 1px; background: var(--border); }
|
||||||
|
.flow-connector::after { position: absolute; top: -3px; right: 0; width: 6px; height: 6px; transform: rotate(45deg); border-top: 1px solid var(--muted); border-right: 1px solid var(--muted); content: ""; }
|
||||||
|
.activity-lower { margin-top: 14px; }
|
||||||
|
.captured-list { display: grid; gap: 0; padding: 6px 18px 16px; }
|
||||||
|
.captured-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--border-soft); padding: 11px 0; }
|
||||||
|
.captured-list > div:first-child { border-top: 0; }
|
||||||
|
.captured-list span { display: grid; gap: 3px; }
|
||||||
|
.captured-list small { color: var(--muted); font-size: 10px; }
|
||||||
|
.captured-list strong { font-size: 12px; }
|
||||||
|
.activity-config-layout { display: grid; grid-template-columns: minmax(0,1.6fr) minmax(240px,.75fr); align-items: start; gap: 14px; }
|
||||||
|
.activity-config-layout > .grid { gap: 14px; }
|
||||||
|
.config-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 17px; }
|
||||||
|
.config-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 10px; font-weight: 600; }
|
||||||
|
.config-grid .filter-input, .config-grid .select { width: 100%; }
|
||||||
|
.config-events, .capture-fields { display: grid; gap: 8px; margin: 14px 0 0; }
|
||||||
|
.config-event, .capture-field { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-soft); border-radius: 9px; background: #10131a; padding: 8px; }
|
||||||
|
.config-event .filter-input, .capture-field .filter-input { min-width: 110px; flex: 1; }
|
||||||
|
.config-event .select, .capture-field .select { min-width: 125px; }
|
||||||
|
.event-type { width: 94px; min-width: 94px; color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .06em; }
|
||||||
|
.event-type.prompt-type { color: var(--amber); }
|
||||||
|
.event-type.outcome-type { color: var(--green); }
|
||||||
|
.drag-handle { color: var(--dim); font-size: 15px; }
|
||||||
|
.remove-event { width: 28px; min-height: 28px; flex: 0 0 28px; }
|
||||||
|
.capture-fields { margin-bottom: 12px; }
|
||||||
|
.summary-list { display: grid; gap: 0; margin-top: 13px; }
|
||||||
|
.summary-list > div { display: grid; gap: 4px; border-top: 1px solid var(--border-soft); padding: 11px 0; }
|
||||||
|
.summary-list span { color: var(--muted); font-size: 10px; }
|
||||||
|
.summary-list strong { font-size: 11px; font-weight: 550; }
|
||||||
|
.config-note { display: flex; gap: 8px; margin: 13px 0; border: 1px solid #3b355e; border-radius: 8px; background: #191726; padding: 10px; color: #b9b4d7; font-size: 10px; }
|
||||||
|
.save-activity { width: 100%; }
|
||||||
|
.activity-dialog { width: min(440px, calc(100vw - 28px)); border: 1px solid var(--border); border-radius: 15px; background: var(--surface); color: var(--text); padding: 24px; box-shadow: 0 24px 80px #0009; }
|
||||||
|
.activity-dialog[open] { animation: surface-enter var(--motion-normal) var(--ease-out) both; }
|
||||||
|
.activity-dialog::backdrop { background: rgb(3 4 8 / 72%); backdrop-filter: blur(3px); }
|
||||||
|
.dialog-card { position: relative; }
|
||||||
|
.dialog-card h2 { margin: 16px 0 5px; font-size: 18px; }
|
||||||
|
.dialog-card > .filter-input { width: 100%; }
|
||||||
|
.dialog-close { position: absolute; top: 0; right: 0; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||||
|
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 30; transform: translateY(8px); border: 1px solid var(--border); border-radius: 9px; background: var(--surface-raised); padding: 11px 14px; color: var(--text); font-size: 12px; opacity: 0; pointer-events: none; transition: opacity var(--motion-fast) ease, transform var(--motion-fast) ease; }
|
||||||
|
.toast.visible { transform: translateY(0); opacity: 1; }
|
||||||
|
.player-segments { display: flex; gap: 5px; overflow-x: auto; border-bottom: 1px solid var(--border-soft); padding: 0 15px 12px; }
|
||||||
|
.segment-button { flex: 0 0 auto; border: 1px solid transparent; border-radius: 8px; background: transparent; padding: 7px 10px; color: var(--muted); font-size: 11px; }
|
||||||
|
.segment-button span { margin-left: 3px; color: var(--dim); font-size: 9px; }
|
||||||
|
.segment-button:hover { background: var(--surface-hover); color: var(--text); }
|
||||||
|
.segment-button.active { border-color: #343a50; background: #1a1d2a; color: #dce1ff; }
|
||||||
|
.player-name-link { color: var(--text); font-weight: 600; }
|
||||||
|
.player-name-link:hover { color: #c8ceff; text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
.back-link { display: inline-block; margin-bottom: 8px; color: var(--muted); font-size: 11px; }
|
||||||
|
.back-link:hover { color: var(--text); }
|
||||||
|
.player-profile-title { margin-top: 0; }
|
||||||
|
.player-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
|
.player-info-panel { display: none; }
|
||||||
|
.player-info-panel.active { display: block; animation: surface-enter var(--motion-normal) var(--ease-out) both; }
|
||||||
|
.snapshot-fields { display: grid; gap: 0; margin-top: 12px; }
|
||||||
|
.snapshot-fields > div { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; border-top: 1px solid var(--border-soft); padding: 13px 0; }
|
||||||
|
.snapshot-fields > div:first-child { border-top: 0; }
|
||||||
|
.snapshot-fields span { color: var(--muted); font-size: 11px; }
|
||||||
|
.snapshot-fields strong { font-size: 12px; }
|
||||||
|
.snapshot-fields small { grid-column: 1 / -1; color: var(--muted); font-size: 10px; }
|
||||||
|
.session-summary { display: grid; grid-template-columns: repeat(4,1fr); gap: 16px; }
|
||||||
|
.session-summary > div { display: grid; gap: 5px; }
|
||||||
|
.session-summary span { color: var(--muted); font-size: 11px; }
|
||||||
|
.session-summary strong { font-size: 18px; }
|
||||||
|
.section-gap { margin-top: 16px; }
|
||||||
|
.table-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 13px 16px; color: var(--muted); font-size: 11px; }
|
||||||
|
.json-value { display: block; max-width: 560px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; }
|
||||||
|
.game-select { width: 100%; margin-top: 6px; }
|
||||||
|
.mobile-game-select { display: none; }
|
||||||
|
.mobile-more-links { display: none; }
|
||||||
|
.error-banner { margin-bottom: 16px; border: 1px solid #653942; border-radius: 9px; background: #29181d; padding: 12px 14px; color: #f8bcc5; font-size: 12px; }
|
||||||
|
.button:disabled { opacity: .4; cursor: default; transform: none; }
|
||||||
|
.metric-value { overflow-wrap: anywhere; }
|
||||||
|
.metric-value[data-format="date"] { font-size: 16px; }
|
||||||
|
.chart-wrap { min-height: 120px; }
|
||||||
|
.card-head .filter-input { min-width: 0; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.mobile-game-select { display: block; max-width: 65%; font-size: 11px; }
|
||||||
|
.topbar .breadcrumbs { max-width: 30%; font-size: 10px; }
|
||||||
|
.topbar .top-actions { display: none; }
|
||||||
|
.mobile-more-links { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||||
|
.mobile-more-links .button { display: inline-flex; align-items: center; }
|
||||||
|
.card-head:has([data-player-search]) { flex-direction: column; padding-bottom: 12px; }
|
||||||
|
.card-head [data-player-search] { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared motion: small distances and brief transitions keep data easy to follow. */
|
||||||
|
.button, .icon-button, .date-button, .select, .filter-input, .login-input,
|
||||||
|
.nav a, .tabs a, .settings-nav a, .mobile-nav a, tbody tr {
|
||||||
|
transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease,
|
||||||
|
border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease;
|
||||||
|
}
|
||||||
|
.button:active, .icon-button:active, .date-button:active { transform: translateY(1px); }
|
||||||
|
.select { cursor: pointer; }
|
||||||
|
.select:hover { border-color: var(--dim); background-color: var(--surface-hover); }
|
||||||
|
.select:focus-visible, .button:focus-visible, .icon-button:focus-visible,
|
||||||
|
.date-button:focus-visible, .nav a:focus-visible, .tabs a:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.filter-input:focus, .login-input:focus { box-shadow: 0 0 0 3px rgb(124 140 255 / 10%); }
|
||||||
|
.page-heading, .login-card { animation: surface-enter var(--motion-reveal) var(--ease-out) both; }
|
||||||
|
.page .card { animation: surface-enter var(--motion-reveal) var(--ease-out) both; }
|
||||||
|
.metrics .card:nth-child(2) { animation-delay: 35ms; }
|
||||||
|
.metrics .card:nth-child(3) { animation-delay: 70ms; }
|
||||||
|
.metrics .card:nth-child(4) { animation-delay: 105ms; }
|
||||||
|
.chart { animation: data-fade var(--motion-normal) ease-out both; }
|
||||||
|
.chart .line { stroke-dasharray: 100; animation: line-reveal 700ms var(--ease-out) both; }
|
||||||
|
.chart .line.secondary { animation-delay: 65ms; }
|
||||||
|
.chart .bar { transform-box: fill-box; transform-origin: center bottom; animation: bar-reveal 480ms var(--ease-out) backwards; transition: opacity var(--motion-fast) ease; }
|
||||||
|
.retention-grid { animation: data-fade var(--motion-normal) ease-out both; }
|
||||||
|
|
||||||
|
@keyframes surface-enter { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes data-fade { from { opacity: .55; } to { opacity: 1; } }
|
||||||
|
@keyframes line-reveal { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
|
||||||
|
@keyframes bar-reveal { from { opacity: .3; transform: scaleY(.08); } to { opacity: .8; transform: scaleY(1); } }
|
||||||
|
|
||||||
|
/* Browsers with customizable native selects get an animated, themed popup. */
|
||||||
|
@supports (appearance: base-select) {
|
||||||
|
select.select, select.select::picker(select) { appearance: base-select; }
|
||||||
|
select.select { align-items: center; gap: 12px; }
|
||||||
|
select.select::picker-icon { color: var(--muted); transition: rotate var(--motion-fast) ease; }
|
||||||
|
select.select:open::picker-icon { rotate: 180deg; }
|
||||||
|
select.select::picker(select) {
|
||||||
|
margin-top: 6px; max-height: 320px; overflow-y: auto; padding: 5px;
|
||||||
|
border: 1px solid var(--border); border-radius: 10px; background: var(--surface-raised);
|
||||||
|
color: var(--text); box-shadow: 0 12px 28px rgb(0 0 0 / 30%);
|
||||||
|
opacity: 0; transform: translateY(-4px); transform-origin: top;
|
||||||
|
transition: opacity var(--motion-fast) ease, transform var(--motion-normal) var(--ease-out),
|
||||||
|
display var(--motion-normal) allow-discrete, overlay var(--motion-normal) allow-discrete;
|
||||||
|
}
|
||||||
|
select.select:open::picker(select) { opacity: 1; transform: translateY(0); }
|
||||||
|
@starting-style { select.select:open::picker(select) { opacity: 0; transform: translateY(-4px); } }
|
||||||
|
select.select option { padding: 9px 10px; border-radius: 6px; font-size: 12px; transition: background-color var(--motion-fast) ease; }
|
||||||
|
select.select option:hover, select.select option:focus { background: var(--surface-hover); }
|
||||||
|
select.select option:checked { color: #dce1ff; background: #23283c; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
|
||||||
|
.button:active, .icon-button:active, .date-button:active { transform: none; }
|
||||||
|
.chart .line { stroke-dasharray: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
:root { --sidebar-width: 196px; }
|
||||||
|
.page { padding-right: 22px; padding-left: 22px; }
|
||||||
|
.metrics { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
.two-col { grid-template-columns: minmax(0,1fr); }
|
||||||
|
.activity-config-layout { grid-template-columns: minmax(0,1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
body { padding-bottom: 68px; }
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.main { margin-left: 0; }
|
||||||
|
.topbar { min-height: 54px; padding: 0 15px; }
|
||||||
|
.menu-toggle { display: inline-grid; }
|
||||||
|
.breadcrumbs { display: block; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.top-actions .status { display: none; }
|
||||||
|
.page { padding: 21px 14px 30px; }
|
||||||
|
.page-heading { flex-direction: column; gap: 13px; margin-bottom: 17px; }
|
||||||
|
h1 { font-size: 21px; }
|
||||||
|
.page-controls { width: 100%; }
|
||||||
|
.page-controls > * { flex: 1; }
|
||||||
|
.metrics { gap: 9px; }
|
||||||
|
.metric-card { padding: 13px; }
|
||||||
|
.metric-value { font-size: 22px; }
|
||||||
|
.three-col { grid-template-columns: 1fr; }
|
||||||
|
.tabs { gap: 19px; overflow-x: auto; }
|
||||||
|
.tabs a { flex: 0 0 auto; }
|
||||||
|
.card-head { padding: 14px 14px 0; }
|
||||||
|
.chart-wrap { padding: 0 10px 10px; }
|
||||||
|
.chart { height: 210px; }
|
||||||
|
.chart.tall { height: 255px; }
|
||||||
|
.mobile-nav { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; display: grid; grid-template-columns: repeat(5,1fr); border-top: 1px solid var(--border); background: rgb(14 16 21 / 96%); padding: 7px 8px calc(7px + env(safe-area-inset-bottom)); backdrop-filter: blur(15px); }
|
||||||
|
.mobile-nav a { display: grid; justify-items: center; gap: 2px; color: var(--muted); font-size: 9px; }
|
||||||
|
.mobile-nav a.active { color: #cbd2ff; }
|
||||||
|
.mobile-nav .nav-icon { font-size: 16px; }
|
||||||
|
.toolbar { width: 100%; }
|
||||||
|
.toolbar .date-button { flex: 1; }
|
||||||
|
.player-controls { align-items: stretch; padding: 0 12px 12px; }
|
||||||
|
.control-label { flex: 1 1 44%; }
|
||||||
|
.control-label .select { width: 100%; min-width: 0; }
|
||||||
|
.settings-layout { grid-template-columns: 1fr; }
|
||||||
|
.settings-nav { display: flex; overflow-x: auto; }
|
||||||
|
.settings-nav a { flex: 0 0 auto; }
|
||||||
|
.retention-grid { grid-template-columns: 105px repeat(8,minmax(48px,1fr)); padding: 10px; }
|
||||||
|
.player-table-wrap { margin-right: -1px; }
|
||||||
|
.login-card { padding: 26px 22px; }
|
||||||
|
.activity-picker-row { align-items: stretch; flex-wrap: wrap; }
|
||||||
|
.activity-select-wrap { min-width: 0; flex: 1 1 55%; }
|
||||||
|
.activity-updated { margin-left: 0; }
|
||||||
|
.activity-flow { margin-right: -14px; padding-right: 14px; padding-left: 14px; }
|
||||||
|
.config-grid { grid-template-columns: minmax(0,1fr); }
|
||||||
|
.config-event, .capture-field { flex-wrap: wrap; }
|
||||||
|
.config-event .filter-input, .capture-field .filter-input { flex-basis: calc(100% - 130px); }
|
||||||
|
.event-type { width: auto; min-width: 68px; }
|
||||||
|
.config-event .select, .capture-field .select { flex: 1; min-width: 100px; }
|
||||||
|
.toast { right: 12px; bottom: 80px; left: 12px; text-align: center; }
|
||||||
|
.player-segments { margin-right: -14px; padding-right: 14px; padding-left: 12px; }
|
||||||
|
.session-summary { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > :only-child { grid-column: 1 / -1; }
|
||||||
|
.stage-reach { color: var(--accent); font-weight: 600; }
|
||||||
|
.stage-drop { color: var(--muted); font-size: 11px; margin-top: 5px; }
|
||||||
|
.card-head:has(.profile-history-controls) { flex-wrap: wrap; padding-bottom: 12px; }
|
||||||
|
.profile-history-controls { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
|
||||||
|
.profile-history-controls .control-label { flex: 1 1 130px; min-width: 0; }
|
||||||
|
.profile-history-controls .select { width: 100%; min-width: 0; }
|
||||||
|
.join-session { white-space: nowrap; }
|
||||||
|
.join-session:disabled { color: var(--muted); filter: grayscale(1); cursor: not-allowed; }
|
||||||
|
.event-chart-controls { padding: 12px 18px; }
|
||||||
|
.event-picker { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
||||||
|
.event-picker summary { cursor: pointer; padding: 10px 12px; font-size: 12px; }
|
||||||
|
.event-picker summary .card-note { margin-left: 8px; }
|
||||||
|
.event-picker-panel { padding: 0 12px 12px; }
|
||||||
|
.event-picker-toolbar { display: flex; gap: 8px; padding-bottom: 10px; }
|
||||||
|
.event-picker-toolbar .filter-input { flex: 1; min-width: 0; }
|
||||||
|
.event-options { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,220px),1fr)); max-height: 240px; overflow-y: auto; gap: 4px 12px; }
|
||||||
|
.event-option { display: flex; align-items: center; gap: 8px; padding: 7px 5px; cursor: pointer; font-size: 12px; min-width: 0; border-radius: 6px; }
|
||||||
|
.event-option:hover { background: var(--surface-hover); }
|
||||||
|
.event-option input { accent-color: var(--accent); }
|
||||||
|
.event-option span { overflow-wrap: anywhere; }
|
||||||
|
.event-option[hidden] { display: none; }
|
||||||
2
mockups/mockup.js
Normal file
2
mockups/mockup.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Shared mockup entry point.
|
||||||
|
import("./data-ui.js").catch(console.error);
|
||||||
5
mockups/mockup_server.py
Normal file
5
mockups/mockup_server.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point for the PostgreSQL sample server."""
|
||||||
|
from server_pg import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
mockups/monetization-mockup.html
Normal file
3
mockups/monetization-mockup.html
Normal file
File diff suppressed because one or more lines are too long
3
mockups/overview-mockup.html
Normal file
3
mockups/overview-mockup.html
Normal file
File diff suppressed because one or more lines are too long
3
mockups/player-info-mockup.html
Normal file
3
mockups/player-info-mockup.html
Normal file
File diff suppressed because one or more lines are too long
3
mockups/players-mockup.html
Normal file
3
mockups/players-mockup.html
Normal file
File diff suppressed because one or more lines are too long
257
mockups/realistic_seed.py
Normal file
257
mockups/realistic_seed.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""Reproducible, benchmark-informed synthetic histories, never real player data.
|
||||||
|
|
||||||
|
See SAMPLE_DATA.md for public references, assumptions, and validation commands.
|
||||||
|
All writes belong to the caller's transaction. Existing game IDs are preserved.
|
||||||
|
"""
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import random
|
||||||
|
|
||||||
|
from psycopg import sql
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
SAMPLE_KEYS = ('willow-v1', 'orbit-v1', 'dungeon-v1')
|
||||||
|
DAY = timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_rows(conn, table, columns, records):
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
query = sql.SQL('COPY {} ({}) FROM STDIN').format(
|
||||||
|
sql.Identifier(table), sql.SQL(',').join(map(sql.Identifier, columns)))
|
||||||
|
with conn.cursor().copy(query) as stream:
|
||||||
|
for row in records:
|
||||||
|
stream.write_row(tuple(Jsonb(v) if isinstance(v, (dict, list)) else v for v in row))
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
'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,))
|
||||||
|
|
||||||
|
|
||||||
|
def populate(conn, players_per_game=6000):
|
||||||
|
rng = random.Random(20261103)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
first_day = midnight - 89 * DAY
|
||||||
|
conn.execute((Path(__file__).parent / 'db' / 'seed.sql').read_text(encoding='utf-8'))
|
||||||
|
games = conn.execute('SELECT * FROM games WHERE sample_key=ANY(%s) ORDER BY id', (list(SAMPLE_KEYS),)).fetchall()
|
||||||
|
for game in games:
|
||||||
|
gid = game['id']
|
||||||
|
# Different genres, not three copies of the same graph.
|
||||||
|
scale, session_scale = {'willow-v1': (1., 1.), 'orbit-v1': (.78, .78), 'dungeon-v1': (1.35, 1.35)}[game['sample_key']]
|
||||||
|
defs = {r['key']: r for r in conn.execute('SELECT * FROM activity_definitions WHERE game_id=%s', (gid,))}
|
||||||
|
products = conn.execute('SELECT * FROM products WHERE game_id=%s ORDER BY id', (gid,)).fetchall()
|
||||||
|
weights = []
|
||||||
|
for day in range(90):
|
||||||
|
date = first_day + day * DAY
|
||||||
|
# Gradual growth, weekend lift, acquisition campaign, and update bump.
|
||||||
|
weight = (.7 + day / 140) * (1.28 if date.weekday() >= 5 else 1)
|
||||||
|
weight *= 1 + .75 * math.exp(-((day-61)/3)**2) + .4 * math.exp(-((day-79)/2)**2)
|
||||||
|
if day == 89:
|
||||||
|
weight *= max(.02, (now-midnight).total_seconds()/86400)
|
||||||
|
weights.append(weight)
|
||||||
|
players = []
|
||||||
|
for index in range(players_per_game):
|
||||||
|
cohort = rng.choices(range(90), weights=weights)[0]
|
||||||
|
date = first_day + cohort * DAY
|
||||||
|
# Global audience with an evening peak, not a uniform hourly trace.
|
||||||
|
hour = rng.gauss(18, 4) % 24 if rng.random() < .7 else rng.uniform(0, 24)
|
||||||
|
if cohort == 89:
|
||||||
|
hour = min(hour, max(0, (now-midnight).total_seconds()/3600-.02)) * rng.uniform(.1, 1)
|
||||||
|
start = date + timedelta(hours=hour)
|
||||||
|
kind = rng.choices(['bounce', 'casual', 'regular', 'whale'], [.55, .32, .127, .003])[0]
|
||||||
|
name = rng.choice(['Pixel','Cloud','Nova','Maple','Moss','Luna','Echo','Aero','Fern','Snow'])
|
||||||
|
name += rng.choice(['Fox','Builder','Runner','Cat','Plays','_']) + str(rng.randrange(10, 9999))
|
||||||
|
players.append((gid, 1000000000+gid*100000+index, name if rng.random()>.008 else None,
|
||||||
|
start, start, {'seedVersion': 2, 'seedIndex': index, 'simulationGroup': kind}))
|
||||||
|
copy_rows(conn, 'players', ['game_id','roblox_user_id','display_name','first_seen','last_seen','metadata'], players)
|
||||||
|
stored = conn.execute('SELECT * FROM players WHERE game_id=%s ORDER BY id', (gid,)).fetchall()
|
||||||
|
session_rows, traits = [], {}
|
||||||
|
for player in stored:
|
||||||
|
pid, start = player['id'], player['first_seen']
|
||||||
|
kind = player['metadata']['simulationGroup']
|
||||||
|
affinity = rng.uniform(.7, 1.3)
|
||||||
|
payer = rng.random() < {'bounce': .0015, 'casual': .035, 'regular': .18, 'whale': 1}[kind]
|
||||||
|
traits[pid] = {'kind':kind, 'payer':payer, 'device':rng.choices(['phone','desktop','tablet'],[.65,.28,.07])[0],
|
||||||
|
'spendIntensity':min(12, rng.paretovariate(1.7)) if kind=='whale' else 1}
|
||||||
|
dates = [start]
|
||||||
|
age = (midnight - start.replace(hour=0, minute=0, second=0, microsecond=0)).days
|
||||||
|
for offset in range(1, age+1):
|
||||||
|
if kind == 'bounce':
|
||||||
|
break
|
||||||
|
if kind == 'casual':
|
||||||
|
probability = .20 * math.exp(-(offset-1)/1.8)
|
||||||
|
elif kind == 'regular':
|
||||||
|
probability = .40*math.exp(-(offset-1)/2.7) + .10*math.exp(-(offset-1)/15)
|
||||||
|
else:
|
||||||
|
probability = .85*math.exp(-(offset-1)/70)
|
||||||
|
date = start.replace(hour=0, minute=0, second=0, microsecond=0) + offset * DAY
|
||||||
|
weekend = 1.12 if date.weekday() >= 5 else .96
|
||||||
|
if rng.random() < min(.96, probability * affinity * scale * weekend):
|
||||||
|
dates.append(date + timedelta(hours=rng.gauss(18,4)%24))
|
||||||
|
last_end = None
|
||||||
|
for day_index, date in enumerate(dates):
|
||||||
|
count = 1
|
||||||
|
if kind != 'bounce' and rng.random() < (.65 if kind=='whale' else .18):
|
||||||
|
count += rng.choices([1,2], [.85,.15])[0]
|
||||||
|
previous_end = None
|
||||||
|
for number in range(count):
|
||||||
|
current = date if previous_end is None else previous_end+timedelta(minutes=rng.uniform(25,180))
|
||||||
|
if last_end is not None and current < last_end:
|
||||||
|
current = last_end + timedelta(minutes=20)
|
||||||
|
if current > now or current.date() != date.date():
|
||||||
|
continue
|
||||||
|
median = {'bounce':2.5,'casual':6,'regular':11,'whale':28}[kind]
|
||||||
|
if day_index:
|
||||||
|
median *= 1.2
|
||||||
|
duration = max(15, min(3*3600, rng.lognormvariate(math.log(median*60*session_scale), .85)))
|
||||||
|
end = current+timedelta(seconds=duration)
|
||||||
|
# Preserve genuinely open sessions, plus rare lost end reports.
|
||||||
|
recorded_end = None if end>now or rng.random()<.003 else end
|
||||||
|
session_rows.append([gid,pid,current,recorded_end,
|
||||||
|
rng.choice(['Main Island','Forest Arena','Trading Hub']),
|
||||||
|
{'device':traits[pid]['device'], 'source':'synthetic-v2'}])
|
||||||
|
previous_end = end
|
||||||
|
last_end = end
|
||||||
|
session_rows.sort(key=lambda row: row[2])
|
||||||
|
ids = [r['id'] for r in conn.execute("SELECT nextval(pg_get_serial_sequence('sessions','id')) id FROM generate_series(1,%s)", (len(session_rows),))]
|
||||||
|
copy_rows(conn, 'sessions', ['id','game_id','player_id','started_at','ended_at','place_name','stats'],
|
||||||
|
[[sid]+row for sid,row in zip(ids,session_rows)])
|
||||||
|
events, sales, snapshots, runs = [], [], [], []
|
||||||
|
balances, spent, units, playtime, levels, visits = defaultdict(int), defaultdict(int), defaultdict(int), defaultdict(float), defaultdict(lambda:1), Counter()
|
||||||
|
cash_earned, cash_spent = defaultdict(int), defaultdict(int)
|
||||||
|
intervals = []
|
||||||
|
|
||||||
|
def event(name, pid, sid, at, props=None, category='events'):
|
||||||
|
events.append((gid,category,name,pid,sid,at,props or {}))
|
||||||
|
|
||||||
|
def activity(key, pid, sid, start, end, outcome, progress, captured, prompts=0):
|
||||||
|
definition=defs[key]
|
||||||
|
runs.append((gid,definition['id'],pid,sid,start,end,outcome,
|
||||||
|
sum(e in definition['configuration']['steps'] for e in progress),prompts,captured,progress))
|
||||||
|
# The ordered observations also appear as individually inspectable events.
|
||||||
|
for i, name in enumerate(progress):
|
||||||
|
at=start+(end-start)*(i/max(1,len(progress)-1))
|
||||||
|
event(name,pid,sid,at,captured,'activities')
|
||||||
|
|
||||||
|
for sid, row in zip(ids, session_rows):
|
||||||
|
_,pid,start,end,place,stats=row
|
||||||
|
trait=traits[pid]
|
||||||
|
visits[start.replace(hour=0,minute=0,second=0,microsecond=0)] += 1
|
||||||
|
intervals.append((start,end or min(now,start+timedelta(minutes=10))))
|
||||||
|
event('session_start',pid,sid,start,{'place':place,'device':trait['device']})
|
||||||
|
if end is None:
|
||||||
|
continue
|
||||||
|
seconds=(end-start).total_seconds()
|
||||||
|
playtime[pid] += seconds
|
||||||
|
earned=round(seconds*rng.uniform(1.2,3.5))
|
||||||
|
cash_earned[pid] += earned
|
||||||
|
balances[pid] += earned
|
||||||
|
expense=round(balances[pid]*rng.uniform(0,.25))
|
||||||
|
cash_spent[pid] += expense
|
||||||
|
balances[pid] -= expense
|
||||||
|
before=levels[pid]
|
||||||
|
levels[pid]=1+int((playtime[pid]/240)**.72)
|
||||||
|
if levels[pid]>before:
|
||||||
|
event('level_up',pid,sid,start+(end-start)*.6,{'level':levels[pid],'levelsGained':levels[pid]-before})
|
||||||
|
if rng.random()<.18 and seconds>300:
|
||||||
|
event('daily_reward_claimed',pid,sid,start+timedelta(seconds=50),{'reward':100})
|
||||||
|
buys=[]
|
||||||
|
if trait['payer'] and seconds>90 and rng.random()<(.82 if trait['kind']=='whale' else .35):
|
||||||
|
n=rng.randint(1,max(2,round(2*trait['spendIntensity']))) if trait['kind']=='whale' else 1
|
||||||
|
for j in range(n):
|
||||||
|
product=rng.choices(products,weights=[.03,.12,.3,.55] if trait['kind']=='whale' else [.52,.27,.16,.05])[0]
|
||||||
|
quantity=rng.randint(1,4) if trait['kind']=='whale' else 1
|
||||||
|
# A temporary promotion changes historical sale prices.
|
||||||
|
price=round(product['current_price_robux']*(.8 if (start-first_day).days in range(60,64) else 1))
|
||||||
|
at=start+(end-start)*(.2+.5*(j+1)/(n+1))
|
||||||
|
sales.append((gid,product['id'],pid,sid,f'synthetic-v2-{sid}-{j}',at,price,quantity,{'source':'synthetic_receipt'}))
|
||||||
|
spent[pid] += price*quantity
|
||||||
|
units[pid] += quantity
|
||||||
|
buys.append((product,quantity,price,at))
|
||||||
|
for product,quantity,price,at in buys:
|
||||||
|
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]
|
||||||
|
activity('shop',pid,sid,start+(end-start)*.15,start+(end-start)*.3,'closed',
|
||||||
|
progress+[definition['end_event']],{'currency_balance':balances[pid]},int(count==2))
|
||||||
|
for key, chance in [('minigame',.6),('quest',.35)]:
|
||||||
|
if seconds<180 or rng.random()>chance:
|
||||||
|
continue
|
||||||
|
definition=defs[key]
|
||||||
|
maximum=len(definition['configuration']['steps'])
|
||||||
|
count=maximum if rng.random()<(.7 if seconds>600 else .4) else rng.randrange(maximum)
|
||||||
|
progress=[definition['start_event']]+definition['configuration']['steps'][:count]+[definition['end_event']]
|
||||||
|
outcome=('won' if rng.random()<.4 else 'lost') if key=='minigame' and count==maximum else 'completed' if count==maximum else 'abandoned'
|
||||||
|
captured={'score':round(seconds*rng.uniform(.3,2)),'rounds':1} if key=='minigame' else {'quest_id':f'quest_{rng.randrange(12)}','reward':100 if count==maximum else 0}
|
||||||
|
activity(key,pid,sid,start+(end-start)*.35,start+(end-start)*.9,outcome,progress,captured)
|
||||||
|
event('session_end',pid,sid,end,{'playtime':round(seconds),'level':levels[pid]})
|
||||||
|
data={'level':levels[pid],'cash':balances[pid], 'cashEarned':cash_earned[pid],
|
||||||
|
'cashSpent':cash_spent[pid], 'robux':spent[pid],'purchases':units[pid],
|
||||||
|
'levelsGained':levels[pid]-1,'quests':int(playtime[pid]/1800),'achievements':int(math.sqrt(playtime[pid]/600))}
|
||||||
|
if game['sample_key']=='orbit-v1':
|
||||||
|
data['roundWins']=int(playtime[pid]/1200)
|
||||||
|
# Realistic partial payloads: a small stable set lacks a field or all snapshots.
|
||||||
|
if pid%47==0:
|
||||||
|
data.pop('cash')
|
||||||
|
if pid%61:
|
||||||
|
snapshots.append((gid,pid,end,data))
|
||||||
|
copy_rows(conn,'events',['game_id','category','name','player_id','session_id','occurred_at','properties'],events)
|
||||||
|
copy_rows(conn,'product_sales',['game_id','product_id','player_id','session_id','receipt_id','purchased_at','unit_price_robux','quantity','properties'],sales)
|
||||||
|
copy_rows(conn,'player_snapshots',['game_id','player_id','captured_at','data'],snapshots)
|
||||||
|
copy_rows(conn,'activity_runs',['game_id','definition_id','player_id','session_id','started_at','ended_at','outcome','step_count','prompt_count','captured_values','stage_progress'],runs)
|
||||||
|
conn.execute('''UPDATE players p SET first_seen=s.first_seen,last_seen=s.last_seen FROM
|
||||||
|
(SELECT player_id,min(started_at) first_seen,max(coalesce(ended_at,started_at)) last_seen
|
||||||
|
FROM sessions WHERE game_id=%s GROUP BY player_id) s WHERE p.game_id=%s AND p.id=s.player_id''',(gid,gid))
|
||||||
|
# CCU and visits come from these same sessions, instead of unrelated sine-wave values.
|
||||||
|
starts=sorted(s for s,e in intervals)
|
||||||
|
ends=sorted(e for s,e in intervals)
|
||||||
|
metrics=[]
|
||||||
|
from bisect import bisect_right
|
||||||
|
for hour in range(90*24):
|
||||||
|
at=first_day+timedelta(hours=hour)
|
||||||
|
if at>now:
|
||||||
|
break
|
||||||
|
ccu=bisect_right(starts,at)-bisect_right(ends,at)
|
||||||
|
metrics.append((gid,'ccu','concurrent_players',at,None if rng.random()<.004 else ccu,{'source':'synthetic_session_intervals'}))
|
||||||
|
if hour%3==0:
|
||||||
|
metrics.append((gid,'performance','server_fps',at,round(max(35,min(60,59-ccu*.12+rng.gauss(0,1.1))),1),{}))
|
||||||
|
metrics.extend((gid,'visits','daily_visits',day,count,{'source':'session_starts'}) for day,count in visits.items())
|
||||||
|
copy_rows(conn,'game_metrics',['game_id','category','name','recorded_at','value','properties'],metrics)
|
||||||
|
root_events=[]
|
||||||
|
for day in range(90):
|
||||||
|
at=first_day+day*DAY+timedelta(hours=17)
|
||||||
|
if at<=now:
|
||||||
|
root_events.append((gid,'server','weather_changed',at,{'weather':rng.choice(['clear','rain','fog'])}))
|
||||||
|
if day%7==4:
|
||||||
|
root_events.append((gid,'events','global_announcement',at,{'message':'Weekend event'}))
|
||||||
|
copy_rows(conn,'events',['game_id','category','name','occurred_at','properties'],root_events)
|
||||||
|
conn.execute('''INSERT INTO gamepasses(game_id,roblox_gamepass_id,name)
|
||||||
|
SELECT %s,9000000+%s*100+n,(ARRAY['VIP','Double cash','Extra inventory','Early access'])[n]
|
||||||
|
FROM generate_series(1,4) n''',(gid,gid))
|
||||||
|
passes=conn.execute('SELECT * FROM gamepasses WHERE game_id=%s ORDER BY id',(gid,)).fetchall()
|
||||||
|
ownership=[]
|
||||||
|
for player in stored:
|
||||||
|
pid=player['id']
|
||||||
|
for index,gp in enumerate(passes):
|
||||||
|
# Previously owned / gifted passes do not require a receipt in this 90-day window.
|
||||||
|
probability=([.82,.92,.78,.6] if traits[pid]['kind']=='whale' else [.25,.4,.2,.08] if spent[pid] else [.012,.018,.009,.003])[index]
|
||||||
|
if rng.random() < (.04 if index==3 else 0):
|
||||||
|
continue
|
||||||
|
ownership.append((gid,gp['id'],pid,rng.random()<probability,now))
|
||||||
|
copy_rows(conn,'gamepass_ownership',['game_id','gamepass_id','player_id','is_owned','checked_at'],ownership)
|
||||||
|
conn.execute("UPDATE games SET properties=properties || %s WHERE id=%s",
|
||||||
|
(Jsonb({'sampleAsOf':now.isoformat(),'sampleVersion':2,'sampleData':'benchmark-informed synthetic; not actual Roblox users'}),gid))
|
||||||
|
print(f"Generated {game['name']}: {len(stored):,} players, {len(session_rows):,} sessions, {len(sales):,} receipts.",flush=True)
|
||||||
1
mockups/requirements.txt
Normal file
1
mockups/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
psycopg[binary]==3.3.6
|
||||||
3
mockups/retention-mockup.html
Normal file
3
mockups/retention-mockup.html
Normal file
@@ -0,0 +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>
|
||||||
345
mockups/sample_api.py
Normal file
345
mockups/sample_api.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""Read-only PostgreSQL analytics for the HTML development UI."""
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
from database import connect
|
||||||
|
from metrics_api import engagement, monetization, gamepasses
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
def __init__(self, status, message):
|
||||||
|
self.status, self.message = status, message
|
||||||
|
|
||||||
|
|
||||||
|
def integer(params, key, default, maximum=1000000000):
|
||||||
|
try:
|
||||||
|
value = int(params.get(key, [str(default)])[0])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ApiError(400, f"Invalid {key}.")
|
||||||
|
if not 1 <= value <= maximum:
|
||||||
|
raise ApiError(400, f"Invalid {key}.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def rows(conn, query, args=()):
|
||||||
|
return conn.execute(query, args).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def one(conn, query, args=()):
|
||||||
|
return conn.execute(query, args).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def label(day):
|
||||||
|
return f"{day:%b} {day.day}"
|
||||||
|
|
||||||
|
|
||||||
|
def game(conn, game_id):
|
||||||
|
row = one(conn, "SELECT id,name,roblox_experience_id,timezone,properties FROM games WHERE id=%s", (game_id,))
|
||||||
|
if not row:
|
||||||
|
raise ApiError(404, "Game not found.")
|
||||||
|
return {"id":row["id"],"name":row["name"],"robloxExperienceId":row["roblox_experience_id"],"timezone":row["timezone"],"properties":row["properties"]}
|
||||||
|
|
||||||
|
|
||||||
|
def fields_templates(conn, game_id):
|
||||||
|
fields = rows(conn,"SELECT key,label,format,source FROM player_fields WHERE game_id=%s ORDER BY key",(game_id,))
|
||||||
|
templates = {r["key"]:{"label":r["label"],"columns":r["columns"]} for r in rows(conn,"SELECT key,label,columns FROM column_templates WHERE game_id=%s ORDER BY key",(game_id,))}
|
||||||
|
templates["all"]={"label":"All values","columns":[f["key"] for f in fields]}
|
||||||
|
return fields,templates
|
||||||
|
|
||||||
|
|
||||||
|
def player_values(r):
|
||||||
|
now=datetime.now(timezone.utc)
|
||||||
|
p=dict(r["latest_data"] or {})
|
||||||
|
p.update({"playerId":r["id"],"gameId":r["game_id"],"robloxUserId":r["roblox_user_id"],"name":r["display_name"],
|
||||||
|
"id":f"User ID {r['roblox_user_id']}","firstSeen":r["first_seen"],"lastSeenAt":r["last_seen"],"snapshotAt":r["snapshot_at"],
|
||||||
|
"firstSeenDays":(now-r["first_seen"]).total_seconds()/86400,
|
||||||
|
"lastSeen":round((now-r["last_seen"]).total_seconds()/60,1) if r["last_seen"] else None,
|
||||||
|
"sessions":r["session_count"],"playtime":r["playtime"],"avgSession":r["average_session"],
|
||||||
|
"medianSession":r["median_session"],"activeDays":r["active_days"],
|
||||||
|
"robux":r["robux_recorded"],"purchases":r["products_bought"]})
|
||||||
|
p["robuxPerHour"]=round(p["robux"]/(p["playtime"]/3600),2) if p.get("robux") is not None and p["playtime"] else None
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def all_players(conn, gid):
|
||||||
|
return [player_values(r) for r in rows(conn,"SELECT * FROM player_statistics WHERE game_id=%s",(gid,))]
|
||||||
|
|
||||||
|
|
||||||
|
def segment_players(players, segment):
|
||||||
|
if segment=="all": return players
|
||||||
|
if segment=="first": return [p for p in players if p["firstSeenDays"]<=1]
|
||||||
|
key={"whales":"robux","playtime":"playtime"}.get(segment)
|
||||||
|
if not key: raise ApiError(400,"Unknown player group.")
|
||||||
|
known=sorted([p[key] for p in players if p.get(key) is not None and p[key]>0],reverse=True)
|
||||||
|
cutoff=known[max(0,math.ceil(len(known)*.2)-1)] if known else None
|
||||||
|
return [p for p in players if cutoff is not None and p.get(key) is not None and p[key]>=cutoff]
|
||||||
|
|
||||||
|
|
||||||
|
def player_list(conn,gid,params):
|
||||||
|
fields,templates=fields_templates(conn,gid)
|
||||||
|
players=all_players(conn,gid)
|
||||||
|
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),
|
||||||
|
"averageLevel":round(statistics.mean(known_levels),1) if known_levels else None,"totalPlaytime":sum(known_times) if known_times else None}
|
||||||
|
players=segment_players(players,params.get("segment",["all"])[0])
|
||||||
|
needle=params.get("q",[""])[0].strip().lower()
|
||||||
|
players=[p for p in players if needle in f"{p['name'] or ''} {p['robloxUserId']}".lower()]
|
||||||
|
key=params.get("sort",["level"])[0]
|
||||||
|
if key not in {f["key"] for f in fields}: raise ApiError(400,"Unknown player sort field.")
|
||||||
|
direction=params.get("direction",["desc"])[0]
|
||||||
|
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)
|
||||||
|
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],
|
||||||
|
"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"}}
|
||||||
|
|
||||||
|
|
||||||
|
def sessions(conn,gid,pid=None,limit=50):
|
||||||
|
clause,args=("AND s.player_id=%s",(gid,pid,limit)) if pid else ("",(gid,limit))
|
||||||
|
return rows(conn,f"""SELECT s.id,s.player_id,p.display_name,p.roblox_user_id,s.started_at,s.ended_at,s.place_name,s.stats,
|
||||||
|
extract(epoch FROM s.ended_at-s.started_at)::double precision duration,
|
||||||
|
(s.ended_at IS NULL AND s.started_at>now()-interval '6 hours' AND s.started_at<=now()) AS "joinAvailable" FROM sessions s
|
||||||
|
JOIN players p ON p.game_id=s.game_id AND p.id=s.player_id WHERE s.game_id=%s {clause}
|
||||||
|
ORDER BY s.started_at DESC,s.id DESC LIMIT %s""",args)
|
||||||
|
|
||||||
|
|
||||||
|
def events(conn,gid,pid=None,limit=50):
|
||||||
|
clause,args=("AND e.player_id=%s",(gid,pid,limit)) if pid else ("",(gid,limit))
|
||||||
|
return rows(conn,f"""SELECT e.id,e.category,e.name,e.player_id,p.roblox_user_id,e.occurred_at,e.properties
|
||||||
|
FROM events e LEFT JOIN players p ON p.game_id=e.game_id AND p.id=e.player_id
|
||||||
|
WHERE e.game_id=%s {clause} ORDER BY e.occurred_at DESC,e.id DESC LIMIT %s""",args)
|
||||||
|
|
||||||
|
|
||||||
|
def retention(conn,gid,days):
|
||||||
|
visits=rows(conn,"SELECT player_id,array_agg(DISTINCT (started_at AT TIME ZONE 'UTC')::date) dates FROM sessions WHERE game_id=%s GROUP BY player_id",(gid,))
|
||||||
|
cohorts=defaultdict(list)
|
||||||
|
today=datetime.now(timezone.utc).date()
|
||||||
|
for row in visits:
|
||||||
|
dates=set(row["dates"])
|
||||||
|
cohorts[min(dates)].append(dates)
|
||||||
|
def rate(offset):
|
||||||
|
eligible=[(d,members) for d,members in cohorts.items() if today-timedelta(days=days-1)<=d and d+timedelta(days=offset)<today]
|
||||||
|
denominator=sum(len(members) for _,members in eligible)
|
||||||
|
count=sum(sum(d+timedelta(days=offset) in dates for dates in members) for d,members in eligible)
|
||||||
|
return round(100*count/denominator,1) if denominator else None
|
||||||
|
starts=[today-timedelta(days=i) for i in reversed(range(days))]
|
||||||
|
grid=[]
|
||||||
|
for start in starts:
|
||||||
|
members=cohorts.get(start,[])
|
||||||
|
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},
|
||||||
|
"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]}]}
|
||||||
|
|
||||||
|
|
||||||
|
def root_metrics(conn,gid,days):
|
||||||
|
latest=rows(conn,"SELECT DISTINCT ON(category,name) category,name,recorded_at,value,properties FROM game_metrics WHERE game_id=%s ORDER BY category,name,recorded_at DESC",(gid,))
|
||||||
|
trend=rows(conn,"""SELECT (recorded_at AT TIME ZONE 'UTC')::date AS day,avg(value) value FROM game_metrics
|
||||||
|
WHERE game_id=%s AND category='ccu' AND recorded_at>=current_date-(%s-1) GROUP BY day ORDER BY day""",(gid,days))
|
||||||
|
return {"rows":latest,"labels":[label(r["day"]) for r in trend],"series":[{"name":"Average CCU","values":[round(r["value"],1) if r["value"] is not None else None for r in trend]}]}
|
||||||
|
|
||||||
|
|
||||||
|
def overview(conn,gid,days,player_ids=None):
|
||||||
|
metrics=one(conn,"""SELECT count(*) sessions,count(DISTINCT player_id) players,
|
||||||
|
avg(extract(epoch FROM ended_at-started_at))::double precision "avgSession",
|
||||||
|
percentile_cont(.5) WITHIN GROUP(ORDER BY extract(epoch FROM ended_at-started_at))::double precision "medianSession",
|
||||||
|
count(*) FILTER(WHERE ended_at IS NULL AND started_at>now()-interval '6 hours') active,
|
||||||
|
count(DISTINCT player_id) FILTER(WHERE player_id IN (SELECT id FROM players WHERE game_id=%s AND first_seen>=current_date-(%s-1))) "newPlayers"
|
||||||
|
FROM sessions WHERE game_id=%s AND started_at>=current_date-(%s-1)
|
||||||
|
AND (%s::bigint[] IS NULL OR player_id=ANY(%s))""",(gid,days,gid,days,player_ids,player_ids))
|
||||||
|
metrics["returningPlayers"]=metrics["players"]-metrics["newPlayers"]
|
||||||
|
metrics.update(one(conn,'SELECT count(*) events,count(DISTINCT name) "eventTypes",max(occurred_at) "latestEvent" FROM events WHERE game_id=%s AND occurred_at>=current_date-(%s-1)',(gid,days)))
|
||||||
|
metrics["retention"]=retention(conn,gid,days)["retention"]
|
||||||
|
metrics["visits"]=one(conn,"SELECT sum(value) value FROM game_metrics WHERE game_id=%s AND category='visits' AND recorded_at>=current_date-(%s-1)",(gid,days))["value"]
|
||||||
|
ccu=one(conn,"SELECT value FROM game_metrics WHERE game_id=%s AND category='ccu' AND name='concurrent_players' ORDER BY recorded_at DESC LIMIT 1",(gid,))
|
||||||
|
metrics["ccu"]=ccu["value"] if ccu else None
|
||||||
|
daily=rows(conn,"SELECT (started_at AT TIME ZONE 'UTC')::date AS day,count(*) sessions,count(DISTINCT player_id) players FROM sessions WHERE game_id=%s AND started_at>=current_date-(%s-1) AND (%s::bigint[] IS NULL OR player_id=ANY(%s)) GROUP BY day ORDER BY day",(gid,days,player_ids,player_ids))
|
||||||
|
lookup={r["day"]:r for r in daily}
|
||||||
|
dates=[datetime.now(timezone.utc).date()-timedelta(days=i) for i in reversed(range(days))]
|
||||||
|
top=rows(conn,"SELECT name,count(*) count FROM events WHERE game_id=%s AND occurred_at>=current_date-(%s-1) GROUP BY name ORDER BY count DESC LIMIT 7",(gid,days))
|
||||||
|
return {"metrics":metrics,"labels":[label(d) for d in dates],"series":[{"name":key.capitalize(),"values":[lookup.get(d,{}).get(key,0) for d in dates]} for key in ("players","sessions")],
|
||||||
|
"eventLabels":[r["name"] for r in top],"eventValues":[r["count"] for r in top]}
|
||||||
|
|
||||||
|
|
||||||
|
def event_analytics(conn,gid,days):
|
||||||
|
catalog=rows(conn,'SELECT name,array_agg(DISTINCT category ORDER BY category) categories FROM events WHERE game_id=%s GROUP BY name ORDER BY name',(gid,))
|
||||||
|
daily=rows(conn,"SELECT name,(occurred_at AT TIME ZONE 'UTC')::date AS day,count(*) total FROM events WHERE game_id=%s AND occurred_at>=current_date-(%s-1) GROUP BY name,day",(gid,days))
|
||||||
|
totals={r['name']:r for r in rows(conn,"SELECT name,count(*) total,count(DISTINCT player_id) players,max(occurred_at) latest FROM events WHERE game_id=%s AND occurred_at>=current_date-(%s-1) GROUP BY name",(gid,days))}
|
||||||
|
lookup={(r['name'],r['day']):r['total'] for r in daily}
|
||||||
|
dates=[datetime.now(timezone.utc).date()-timedelta(days=i) for i in reversed(range(days))]
|
||||||
|
summary=[{'name':r['name'],'categories':r['categories'],'total':totals.get(r['name'],{}).get('total',0),
|
||||||
|
'players':totals.get(r['name'],{}).get('players',0),'average':totals.get(r['name'],{}).get('total',0)/days,
|
||||||
|
'latest':totals.get(r['name'],{}).get('latest')} for r in catalog]
|
||||||
|
unique=one(conn,'SELECT count(DISTINCT player_id) players,max(occurred_at) latest FROM events WHERE game_id=%s AND occurred_at>=current_date-(%s-1)',(gid,days))
|
||||||
|
return {'metrics':{'events':sum(r['total'] for r in summary),'eventTypes':len(catalog),'players':unique['players'],'latestEvent':unique['latest']},
|
||||||
|
'labels':[label(d) for d in dates],
|
||||||
|
'series':[{'name':r['name'],'values':[lookup.get((r['name'],d),0) for d in dates]} for r in catalog],
|
||||||
|
'eventSummary':summary}
|
||||||
|
|
||||||
|
|
||||||
|
def stage_funnel(runs,definition):
|
||||||
|
"""Sequential stage reach; unknown observations never become drop-offs."""
|
||||||
|
stages=[('Start',definition['start_event'])]+[('Step',s) for s in definition['configuration'].get('steps',[])]
|
||||||
|
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=[]
|
||||||
|
for index,(kind,event) in enumerate(stages):
|
||||||
|
reached=[]
|
||||||
|
for progress,cursor in survivors:
|
||||||
|
try: reached.append((progress,progress.index(event,cursor)+1))
|
||||||
|
except ValueError: pass
|
||||||
|
survivors=reached
|
||||||
|
current=count if index==0 else len(reached)
|
||||||
|
known=index==0 or unknown==0
|
||||||
|
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,
|
||||||
|
'conversionPercent':100*current/previous if index and previous and known else None,
|
||||||
|
'droppedRuns':previous-current if index and known else None,'unknownRuns':unknown if index else 0})
|
||||||
|
previous=current
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def activities(conn,gid,params,days):
|
||||||
|
definitions=rows(conn,"SELECT id,key,name,start_event,end_event,configuration FROM activity_definitions WHERE game_id=%s ORDER BY id",(gid,))
|
||||||
|
if not definitions: return {"definitions":[],"metrics":{},"outcomes":[],"captured":[],"journey":[],"labels":[],"series":[]}
|
||||||
|
aid=integer(params,"activity_id",definitions[0]["id"])
|
||||||
|
definition=next((d for d in definitions if d["id"]==aid),None)
|
||||||
|
if not definition: raise ApiError(404,"Activity does not belong to this game.")
|
||||||
|
clause,args="",[gid,aid,days]
|
||||||
|
segment=params.get("segment",["all"])[0]
|
||||||
|
if segment!="all":
|
||||||
|
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)
|
||||||
|
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]
|
||||||
|
count=len(runs)
|
||||||
|
metrics={"runs":count,"average":statistics.mean(times) if times else None,"median":statistics.median(times) if times else None,
|
||||||
|
"completion":round(100*len(completed)/count,1) if count else None,"promptRate":round(100*len(prompts)/count,1) if count else None,
|
||||||
|
"purchaseRate":round(100*sum(r["outcome"]=="purchased" for r in runs)/count,1) if count else None,
|
||||||
|
"promptPurchaseRate":round(100*sum(r["outcome"]=="purchased" for r in prompts)/len(prompts),1) if prompts else None}
|
||||||
|
if not any('prompt' in step or 'purchase' in step for step in definition['configuration'].get('steps',[])):
|
||||||
|
metrics.update(promptRate=None,purchaseRate=None,promptPurchaseRate=None)
|
||||||
|
outcomes=[]
|
||||||
|
for outcome in sorted({r["outcome"] for r in runs},key=lambda v:v or ""):
|
||||||
|
members=[r for r in runs if r["outcome"]==outcome]
|
||||||
|
durations=[r["duration"] for r in members if r["duration"] is not None]
|
||||||
|
outcomes.append({"outcome":outcome,"runs":len(members),"share":round(100*len(members)/count,1),"average":statistics.mean(durations) if durations else None,"median":statistics.median(durations) if durations else None})
|
||||||
|
captured=[]
|
||||||
|
for key in sorted({k for r in runs for k in r["captured_values"]}):
|
||||||
|
values=[r["captured_values"][key] for r in runs if r["captured_values"].get(key) is not None]
|
||||||
|
numeric=values and all(isinstance(v,(int,float)) for v in values)
|
||||||
|
value=round(statistics.mean(values),2) if numeric else Counter(str(v) for v in values).most_common(1)[0][0] if values else None
|
||||||
|
captured.append({"key":key,"value":value,"summary":"Average reported value" if numeric else "Most frequent reported value"})
|
||||||
|
daily=defaultdict(list)
|
||||||
|
for r in completed: daily[r["started_at"].date()].append(r["duration"])
|
||||||
|
dates=sorted(daily)
|
||||||
|
journey=stage_funnel(runs,definition)
|
||||||
|
return {"definitions":definitions,"definition":definition,"metrics":metrics,"outcomes":outcomes,"captured":captured,"journey":journey,
|
||||||
|
"labels":[label(d) for d in dates],"series":[{"name":"Average run duration (seconds)","values":[statistics.mean(daily[d]) for d in dates]}]}
|
||||||
|
|
||||||
|
|
||||||
|
def player_history(conn,gid,pid,fields,params):
|
||||||
|
snapshots=rows(conn,"SELECT captured_at,data FROM player_snapshots WHERE game_id=%s AND player_id=%s ORDER BY captured_at,id",(gid,pid))
|
||||||
|
history=rows(conn,"""SELECT ps.captured_at,ps.data,st.*,sp.robux,sp.purchases FROM player_snapshots ps
|
||||||
|
CROSS JOIN LATERAL(SELECT count(*)::int sessions,
|
||||||
|
sum(extract(epoch FROM ended_at-started_at)) FILTER(WHERE ended_at<=ps.captured_at)::double precision playtime,
|
||||||
|
avg(extract(epoch FROM ended_at-started_at)) FILTER(WHERE ended_at<=ps.captured_at)::double precision "avgSession",
|
||||||
|
percentile_cont(.5) WITHIN GROUP(ORDER BY extract(epoch FROM ended_at-started_at)) FILTER(WHERE ended_at<=ps.captured_at)::double precision "medianSession",
|
||||||
|
count(DISTINCT (started_at AT TIME ZONE 'UTC')::date) FILTER(WHERE started_at>=ps.captured_at-interval '30 days')::int "activeDays"
|
||||||
|
FROM sessions WHERE game_id=ps.game_id AND player_id=ps.player_id AND started_at<=ps.captured_at) st
|
||||||
|
CROSS JOIN LATERAL(SELECT coalesce(sum(unit_price_robux*quantity),0)::double precision robux,
|
||||||
|
coalesce(sum(quantity),0)::int purchases FROM product_sales
|
||||||
|
WHERE game_id=ps.game_id AND player_id=ps.player_id AND purchased_at<=ps.captured_at AND unit_price_robux>0) sp
|
||||||
|
WHERE ps.game_id=%s AND ps.player_id=%s ORDER BY ps.captured_at,ps.id""",(gid,pid))
|
||||||
|
supported={'sessions','playtime','avgSession','medianSession','activeDays','robuxPerHour','robux','purchases'}
|
||||||
|
graph_fields=[dict(f) for f in fields if f['source']=='snapshot' or f['key'] in supported]
|
||||||
|
for key,title,fmt in [('sessions','Sessions','number'),('playtime','Lifetime playtime','duration'),
|
||||||
|
('avgSession','Average session','duration'),('medianSession','Median session','duration'),
|
||||||
|
('activeDays','Active days (30d)','number'),('robux','Robux spent','robux'),
|
||||||
|
('purchases','Products bought','number'),('robuxPerHour','Robux per playtime hour','decimal')]:
|
||||||
|
if not any(f['key']==key for f in graph_fields):
|
||||||
|
graph_fields.append({'key':key,'label':title,'format':fmt,'source':'derived'})
|
||||||
|
known={f['key'] for f in graph_fields}
|
||||||
|
for key in sorted({key for s in snapshots for key,value in s['data'].items() if type(value) in (int,float)}-known):
|
||||||
|
graph_fields.append({'key':key,'label':key,'format':'decimal','source':'snapshot'})
|
||||||
|
for point in history:
|
||||||
|
point['values']=dict(point['data'])
|
||||||
|
point['values'].update({key:point.get(key) for key in supported if key!='robuxPerHour'})
|
||||||
|
point['values']['robuxPerHour']=point['robux']*3600/point['playtime'] if point['playtime'] else None
|
||||||
|
selected=[params.get('value',['cash'])[0],params.get('value2',[''])[0]]
|
||||||
|
keys=list(dict.fromkeys(key for key in selected if key))
|
||||||
|
by_key={f['key']:f for f in graph_fields}
|
||||||
|
if not keys or any(key not in by_key for key in keys): raise ApiError(400,'Unknown player history value.')
|
||||||
|
return {'snapshots':snapshots,'historyFields':graph_fields,
|
||||||
|
'labels':[s['captured_at'].strftime('%d %b %Y %H:%M UTC') for s in history],
|
||||||
|
'series':[{'name':by_key[key]['label'],'key':key,'format':by_key[key]['format'],
|
||||||
|
'values':[s['values'].get(key) for s in history]} for key in keys]}
|
||||||
|
|
||||||
|
|
||||||
|
def player_detail(conn,gid,pid,params):
|
||||||
|
record=one(conn,"SELECT * FROM player_statistics WHERE game_id=%s AND id=%s",(gid,pid))
|
||||||
|
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))
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(path,params):
|
||||||
|
parts=path.strip('/').split('/')
|
||||||
|
with connect() as conn:
|
||||||
|
if parts==['api','games']:
|
||||||
|
return {"games":[game(conn,r["id"]) for r in rows(conn,"SELECT id FROM games ORDER BY id")]}
|
||||||
|
if len(parts)<3 or parts[:2]!=['api','games'] or not parts[2].isdigit(): raise ApiError(404,"Use /api/games/{internal_game_id}/{dataset}.")
|
||||||
|
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')}}
|
||||||
|
days=integer(params,'days',14,90); route=parts[3]
|
||||||
|
if route=='players':
|
||||||
|
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=='metrics': return base|root_metrics(conn,gid,days)
|
||||||
|
if route=='engagement': return base|engagement(conn,gid,days)
|
||||||
|
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)
|
||||||
|
if route not in ('overview','sessions','events','retention'): raise ApiError(404,'Dataset not found.')
|
||||||
|
selected_ids=None
|
||||||
|
if route=='sessions' and params.get('segment',['all'])[0]!='all':
|
||||||
|
selected_ids=[p['playerId'] for p in segment_players(all_players(conn,gid),params['segment'][0])]
|
||||||
|
data=overview(conn,gid,days,selected_ids)
|
||||||
|
if route in ('overview','sessions'):
|
||||||
|
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)
|
||||||
|
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')})
|
||||||
|
return base|data
|
||||||
47
mockups/sample_report.py
Normal file
47
mockups/sample_report.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Read-only checks and a compact report for the generated sample histories."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
from database import connect
|
||||||
|
|
||||||
|
|
||||||
|
def report():
|
||||||
|
with connect() as conn:
|
||||||
|
for game in conn.execute("SELECT id,name FROM games WHERE properties->>'sampleVersion'='2' ORDER BY id").fetchall():
|
||||||
|
gid=game['id']
|
||||||
|
summary=conn.execute('''WITH counts AS(SELECT player_id,count(*) n FROM sessions WHERE game_id=%s GROUP BY player_id)
|
||||||
|
SELECT count(*) players,100.0*count(*) FILTER(WHERE n=1)/count(*) one_session FROM counts''',(gid,)).fetchone()
|
||||||
|
minutes=conn.execute('SELECT avg(extract(epoch FROM ended_at-started_at))/60 minutes FROM sessions WHERE game_id=%s',(gid,)).fetchone()['minutes']
|
||||||
|
returns=[]
|
||||||
|
for day in [1,7,30]:
|
||||||
|
row=conn.execute('''SELECT 100.0*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 AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date+%s)) / nullif(count(*),0) rate
|
||||||
|
FROM players p WHERE p.game_id=%s AND (p.first_seen AT TIME ZONE 'UTC')::date+%s<current_date''',(day,gid,day)).fetchone()
|
||||||
|
returns.append(float(row['rate']))
|
||||||
|
spending=conn.execute('''SELECT player_id,sum(unit_price_robux*quantity) total FROM product_sales
|
||||||
|
WHERE game_id=%s AND unit_price_robux>0 GROUP BY player_id ORDER BY total DESC''',(gid,)).fetchall()
|
||||||
|
total=sum(r['total'] for r in spending)
|
||||||
|
top_share=100*sum(r['total'] for r in spending[:max(1,math.ceil(len(spending)*.01))])/total
|
||||||
|
bad_sales=conn.execute('''SELECT count(*) n FROM product_sales p JOIN sessions s ON s.game_id=p.game_id AND s.id=p.session_id
|
||||||
|
WHERE p.game_id=%s AND (p.purchased_at<s.started_at OR p.purchased_at>s.ended_at)''',(gid,)).fetchone()['n']
|
||||||
|
overlaps=conn.execute('''WITH ordered AS(SELECT started_at,lag(ended_at) OVER(PARTITION BY player_id ORDER BY started_at) previous_end
|
||||||
|
FROM sessions WHERE game_id=%s) SELECT count(*) n FROM ordered WHERE started_at<previous_end''',(gid,)).fetchone()['n']
|
||||||
|
bad_balances=conn.execute('''SELECT count(*) n FROM player_snapshots WHERE game_id=%s AND data ? 'cash'
|
||||||
|
AND (data->>'cash')::numeric<>(data->>'cashEarned')::numeric-(data->>'cashSpent')::numeric''',(gid,)).fetchone()['n']
|
||||||
|
bad_snapshots=conn.execute('''SELECT count(*) n FROM player_statistics WHERE game_id=%s AND latest_data IS NOT NULL
|
||||||
|
AND ((latest_data->>'robux')::numeric<>robux_recorded OR (latest_data->>'purchases')::numeric<>products_bought)''',(gid,)).fetchone()['n']
|
||||||
|
assert returns[0]>returns[1]>returns[2], (game,returns)
|
||||||
|
assert 3<float(minutes)<30, (game,minutes)
|
||||||
|
assert summary['one_session']>50, (game,summary)
|
||||||
|
assert len(spending)<summary['players']*.1, (game,len(spending))
|
||||||
|
assert top_share>10, (game,top_share)
|
||||||
|
assert bad_sales==0, (game,bad_sales)
|
||||||
|
assert overlaps==0, (game,overlaps)
|
||||||
|
assert bad_balances==0, (game,bad_balances)
|
||||||
|
assert bad_snapshots==0, (game,bad_snapshots)
|
||||||
|
print(f"{game['name']}: {summary['players']:,} players | one session {summary['one_session']:.1f}% | average session {minutes:.1f} min")
|
||||||
|
print(f" D1/D7/D30: {' / '.join(f'{r:.2f}%' for r in returns)} | payers {len(spending):,} ({100*len(spending)/summary['players']:.1f}%) | top 1% payer revenue share {top_share:.1f}%")
|
||||||
|
print('Sample distribution and timeline checks passed.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
report()
|
||||||
62
mockups/server_pg.py
Normal file
62
mockups/server_pg.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Local static asset and read-only PostgreSQL API server."""
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
import psycopg
|
||||||
|
from database import connect
|
||||||
|
from sample_api import ApiError, dispatch
|
||||||
|
|
||||||
|
STATIC_ROOT=Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def encode(value):
|
||||||
|
if isinstance(value,(date,datetime)): return value.isoformat()
|
||||||
|
if isinstance(value,Decimal): return float(value)
|
||||||
|
raise TypeError(f'Unsupported JSON type: {type(value).__name__}')
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self,*args,**kwargs):
|
||||||
|
super().__init__(*args,directory=str(STATIC_ROOT),**kwargs)
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header('Cache-Control','no-store')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def send_json(self,data,status=200):
|
||||||
|
body=json.dumps(data,default=encode,allow_nan=False).encode('utf-8')
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header('Content-Type','application/json; charset=utf-8')
|
||||||
|
self.send_header('Content-Length',str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed=urlparse(self.path)
|
||||||
|
if parsed.path.startswith('/api/'):
|
||||||
|
try: return self.send_json(dispatch(parsed.path,parse_qs(parsed.query)))
|
||||||
|
except ApiError as error: return self.send_json({'error':error.message},error.status)
|
||||||
|
except (psycopg.Error,RuntimeError):
|
||||||
|
logging.exception('Database request failed')
|
||||||
|
return self.send_json({'error':'Sample database unavailable. Start PostgreSQL and run setup-database.ps1.'},503)
|
||||||
|
if parsed.path=='/':
|
||||||
|
self.send_response(302); self.send_header('Location','/mockups/overview-mockup.html'); self.end_headers(); return
|
||||||
|
name=unquote(parsed.path.removeprefix('/mockups/'))
|
||||||
|
if not parsed.path.startswith('/mockups/') or '/' in name or '\\' in name or Path(name).suffix not in ('.html','.css','.js'):
|
||||||
|
return self.send_error(404)
|
||||||
|
self.path='/'+name
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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()
|
||||||
3
mockups/sessions-mockup.html
Normal file
3
mockups/sessions-mockup.html
Normal file
File diff suppressed because one or more lines are too long
3
mockups/settings-mockup.html
Normal file
3
mockups/settings-mockup.html
Normal file
@@ -0,0 +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>
|
||||||
40
mockups/setup-database.ps1
Normal file
40
mockups/setup-database.ps1
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
param([switch]$SkipDownload)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repoRoot = Split-Path $PSScriptRoot -Parent
|
||||||
|
Set-Location -LiteralPath $repoRoot
|
||||||
|
$localRoot = Join-Path $repoRoot '.local'
|
||||||
|
$pythonExe = Join-Path $repoRoot '.venv\Scripts\python.exe'
|
||||||
|
New-Item -ItemType Directory -Force -Path $localRoot | Out-Null
|
||||||
|
if (!(Test-Path -LiteralPath $pythonExe)) { py -3 -m venv .venv; if ($LASTEXITCODE) { throw 'Could not create Python environment.' } }
|
||||||
|
& $pythonExe -m pip install -r mockups/requirements.txt
|
||||||
|
if ($LASTEXITCODE) { throw 'Python dependency installation failed.' }
|
||||||
|
|
||||||
|
if (!$env:DATABASE_URL) {
|
||||||
|
$postgresBin = Join-Path $localRoot 'pgsql\bin'
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $postgresBin 'initdb.exe'))) {
|
||||||
|
$archive = Join-Path $localRoot 'postgresql.zip'
|
||||||
|
if (!(Test-Path -LiteralPath $archive)) {
|
||||||
|
if ($SkipDownload) { throw 'Local PostgreSQL binaries and archive are missing.' }
|
||||||
|
Write-Host 'Downloading PostgreSQL 18.6 portable binaries from EDB...'
|
||||||
|
Invoke-WebRequest 'https://get.enterprisedb.com/postgresql/postgresql-18.6-5-windows-x64-binaries.zip' -OutFile $archive
|
||||||
|
}
|
||||||
|
& $pythonExe -c "import zipfile; z=zipfile.ZipFile(r'.local/postgresql.zip'); [z.extract(n,r'.local') for n in z.namelist() if n.startswith(('pgsql/bin/','pgsql/lib/','pgsql/share/'))]"
|
||||||
|
if ($LASTEXITCODE) { throw 'Could not extract PostgreSQL binaries.' }
|
||||||
|
}
|
||||||
|
$configPath = Join-Path $localRoot 'database.json'
|
||||||
|
$dataPath = Join-Path $localRoot 'pgdata'
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $dataPath 'PG_VERSION'))) {
|
||||||
|
$samplePassword = & $pythonExe -c "import secrets; print(secrets.token_hex(24))"
|
||||||
|
$passwordFile = Join-Path $localRoot 'init-password.txt'
|
||||||
|
[System.IO.File]::WriteAllText($passwordFile,$samplePassword,[System.Text.UTF8Encoding]::new($false))
|
||||||
|
& (Join-Path $postgresBin 'initdb.exe') -D $dataPath -U sample_owner --encoding=UTF8 --locale=C --auth-host=scram-sha-256 --auth-local=scram-sha-256 --pwfile=$passwordFile
|
||||||
|
if ($LASTEXITCODE) { throw 'PostgreSQL initialization failed.' }
|
||||||
|
Remove-Item -LiteralPath $passwordFile
|
||||||
|
$settings = @{ url = "postgresql://sample_owner:${samplePassword}@127.0.0.1:55432/stat_dashboard_sample" } | ConvertTo-Json
|
||||||
|
[System.IO.File]::WriteAllText($configPath,$settings,[System.Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
& "$PSScriptRoot/start-postgres.ps1"
|
||||||
|
}
|
||||||
|
& $pythonExe mockups/manage_database.py init
|
||||||
|
if ($LASTEXITCODE) { throw 'Database migration or sample seeding failed.' }
|
||||||
|
Write-Host 'Ready. Run mockups/start-mockups.ps1 to open the database-backed mockups.'
|
||||||
6
mockups/shortcut.url
Normal file
6
mockups/shortcut.url
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[{000214A0-0000-0000-C000-000000000046}]
|
||||||
|
Prop3=19,2
|
||||||
|
[InternetShortcut]
|
||||||
|
IDList=
|
||||||
|
URL=http://127.0.0.1:8011/mockups/overview-mockup.html
|
||||||
|
HotKey=0
|
||||||
5
mockups/start-mockups.ps1
Normal file
5
mockups/start-mockups.ps1
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repoRoot = Split-Path $PSScriptRoot -Parent
|
||||||
|
Set-Location -LiteralPath $repoRoot
|
||||||
|
& "$PSScriptRoot/start-postgres.ps1"
|
||||||
|
& '.\.venv\Scripts\python.exe' mockups/mockup_server.py
|
||||||
14
mockups/start-postgres.ps1
Normal file
14
mockups/start-postgres.ps1
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repoRoot = Split-Path $PSScriptRoot -Parent
|
||||||
|
if ($env:DATABASE_URL) { return }
|
||||||
|
$localRoot = Join-Path $repoRoot '.local'
|
||||||
|
$pgCtl = Join-Path $localRoot 'pgsql\bin\pg_ctl.exe'
|
||||||
|
$dataPath = Join-Path $localRoot 'pgdata'
|
||||||
|
if (!(Test-Path -LiteralPath $pgCtl)) { throw 'Run mockups/setup-database.ps1 first.' }
|
||||||
|
& $pgCtl status -D $dataPath *> $null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$argsForPg = @('start','-D',('"'+$dataPath+'"'),'-l',('"'+(Join-Path $localRoot 'postgres.log')+'"'),'-o','"-h 127.0.0.1 -p 55432"','-w','-t','30')
|
||||||
|
$startResult = Start-Process -FilePath $pgCtl -ArgumentList $argsForPg -WindowStyle Hidden -PassThru
|
||||||
|
if (!$startResult.WaitForExit(35000)) { throw 'PostgreSQL start timed out. See .local/postgres.log.' }
|
||||||
|
if ($startResult.ExitCode -ne 0) { throw 'PostgreSQL could not start. See .local/postgres.log.' }
|
||||||
|
}
|
||||||
213
mockups/tests/test_database.py
Normal file
213
mockups/tests/test_database.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""Integration checks use rolled-back fixture rows in the configured sample DB."""
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitions(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn=connect()
|
||||||
|
self.game=self.conn.execute("INSERT INTO games(name) VALUES('Metric fixture') RETURNING id").fetchone()['id']
|
||||||
|
today=self.conn.execute('SELECT current_date AS day').fetchone()['day']
|
||||||
|
self.yesterday=datetime.combine(today-timedelta(days=1),time(1),timezone.utc)
|
||||||
|
self.today=datetime.combine(today,time(1),timezone.utc)
|
||||||
|
self.players=[]
|
||||||
|
for i in range(2):
|
||||||
|
self.players.append(self.conn.execute('INSERT INTO players(game_id,roblox_user_id,first_seen,last_seen) VALUES(%s,%s,%s,%s) RETURNING id',
|
||||||
|
(self.game,700+i,self.yesterday,self.today)).fetchone()['id'])
|
||||||
|
self.session_ids=[]
|
||||||
|
for pid,start,length in [(self.players[0],self.yesterday,300),(self.players[1],self.yesterday+timedelta(hours=1),120),(self.players[0],self.today,600)]:
|
||||||
|
self.session_ids.append(self.conn.execute('INSERT INTO sessions(game_id,player_id,started_at,ended_at) VALUES(%s,%s,%s,%s) RETURNING id',
|
||||||
|
(self.game,pid,start,start+timedelta(seconds=length))).fetchone()['id'])
|
||||||
|
self.products=[]
|
||||||
|
for i,price in enumerate((100,50)):
|
||||||
|
product=self.conn.execute('INSERT INTO products(game_id,roblox_product_id,name,current_price_robux) VALUES(%s,%s,%s,%s) RETURNING id',
|
||||||
|
(self.game,200+i,f'Product {i}',price)).fetchone()['id']
|
||||||
|
self.products.append(product)
|
||||||
|
self.conn.execute('INSERT INTO product_sales(game_id,product_id,player_id,session_id,receipt_id,purchased_at,unit_price_robux,quantity) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)',
|
||||||
|
(self.game,product,self.players[i],self.session_ids[i],f'fixture-{i}',self.yesterday+timedelta(minutes=1),price,2 if i==0 else 1))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.rollback()
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_revenue_arppu_arpdau_and_decimal_product_counts(self):
|
||||||
|
data=monetization(self.conn,self.game,2)
|
||||||
|
m=data['metrics']
|
||||||
|
self.assertEqual(m['revenue'],250)
|
||||||
|
self.assertEqual(m['payingUsers'],2)
|
||||||
|
self.assertEqual(m['payerConversion'],100)
|
||||||
|
self.assertEqual(m['arppu'],125)
|
||||||
|
self.assertAlmostEqual(m['arpdau'],250/3)
|
||||||
|
self.assertEqual(m['productsPerPayer'],1.5)
|
||||||
|
by_product={p['id']:p for p in data['products']}
|
||||||
|
self.assertEqual(by_product[self.products[0]]['perPayer'],1)
|
||||||
|
self.assertEqual(by_product[self.products[1]]['perPayer'],.5)
|
||||||
|
self.assertEqual(sum(d['robux'] for d in data['daily']),250)
|
||||||
|
self.assertEqual(sum(h['robux'] for h in data['hourly']),250)
|
||||||
|
|
||||||
|
def test_rolling_mau_new_returning_and_five_minute_first_session(self):
|
||||||
|
m=engagement(self.conn,self.game,2)['metrics']
|
||||||
|
self.assertEqual(m['dau'],1)
|
||||||
|
self.assertEqual(m['mau'],2)
|
||||||
|
self.assertEqual(m['newUsersToday'],0)
|
||||||
|
self.assertEqual(m['returningUsersToday'],1)
|
||||||
|
self.assertEqual(m['firstSession5min'],50)
|
||||||
|
self.assertEqual(m['newSessionTime'],210)
|
||||||
|
self.assertEqual(m['returningSessionTime'],600)
|
||||||
|
self.assertEqual(m['totalPlaytime'],1020)
|
||||||
|
self.assertEqual(m['averagePlaytime'],510)
|
||||||
|
|
||||||
|
def test_gamepass_ownership_denominators_and_unknown_checks(self):
|
||||||
|
third=self.conn.execute('INSERT INTO players(game_id,roblox_user_id,first_seen) VALUES(%s,998,%s) RETURNING id',(self.game,self.today)).fetchone()['id']
|
||||||
|
gp=self.conn.execute("INSERT INTO gamepasses(game_id,roblox_gamepass_id,name) VALUES(%s,123,'VIP') RETURNING id",(self.game,)).fetchone()['id']
|
||||||
|
for pid,owned in [(self.players[0],True),(self.players[1],False),(third,True)]:
|
||||||
|
self.conn.execute('INSERT INTO gamepass_ownership(game_id,gamepass_id,player_id,is_owned,checked_at) VALUES(%s,%s,%s,%s,now())',(self.game,gp,pid,owned))
|
||||||
|
result=gamepasses(self.conn,self.game)['gamepasses'][0]
|
||||||
|
self.assertAlmostEqual(result['playerPercent'],200/3)
|
||||||
|
self.assertEqual(result['payerPercent'],50)
|
||||||
|
self.conn.execute('DELETE FROM gamepass_ownership WHERE game_id=%s AND player_id=%s',(self.game,third))
|
||||||
|
result=gamepasses(self.conn,self.game)['gamepasses'][0]
|
||||||
|
self.assertIsNone(result['playerPercent'])
|
||||||
|
self.assertEqual(result['payerPercent'],50)
|
||||||
|
self.conn.execute('DELETE FROM product_sales WHERE game_id=%s',(self.game,))
|
||||||
|
self.assertIsNone(gamepasses(self.conn,self.game)['gamepasses'][0]['payerPercent'])
|
||||||
|
|
||||||
|
def test_selected_player_session_aggregates(self):
|
||||||
|
m=engagement(self.conn,self.game,2,[self.players[1]])['metrics']
|
||||||
|
self.assertEqual(m['totalPlaytime'],120)
|
||||||
|
self.assertEqual(m['averagePlaytime'],120)
|
||||||
|
self.assertEqual(m['mau'],1)
|
||||||
|
self.assertEqual(m['dau'],0)
|
||||||
|
self.assertEqual(m['firstSession5min'],0)
|
||||||
|
self.assertEqual(engagement(self.conn,self.game,2,[])['metrics']['totalPlaytime'],0)
|
||||||
|
|
||||||
|
def test_two_history_values_use_actual_historical_sales(self):
|
||||||
|
pid=self.players[0]
|
||||||
|
self.conn.execute("INSERT INTO player_fields(game_id,key,label,format,source) VALUES(%s,'cash','Cash','number','snapshot')",(self.game,))
|
||||||
|
for at,data in [(self.yesterday+timedelta(seconds=30),{'cash':0,'customScore':17,'flag':True}),
|
||||||
|
(self.today+timedelta(minutes=12),{'cash':500,'customScore':99})]:
|
||||||
|
self.conn.execute('INSERT INTO player_snapshots(game_id,player_id,captured_at,data) VALUES(%s,%s,%s,%s)',(self.game,pid,at,Jsonb(data)))
|
||||||
|
detail=player_detail(self.conn,self.game,pid,{'value':['cash'],'value2':['robux']})
|
||||||
|
self.assertEqual([s['values'] for s in detail['series']],[[0,500],[0,200]])
|
||||||
|
fields={f['key'] for f in detail['historyFields']}
|
||||||
|
self.assertTrue({'robux','customScore','sessions','playtime','purchases','avgSession'}<=fields)
|
||||||
|
self.assertNotIn('flag',fields)
|
||||||
|
detail=player_detail(self.conn,self.game,pid,{'value':['playtime'],'value2':['sessions']})
|
||||||
|
self.assertEqual(detail['series'][0]['values'],[None,900])
|
||||||
|
self.assertEqual(detail['series'][1]['values'],[1,2])
|
||||||
|
with self.assertRaises(ApiError):
|
||||||
|
player_detail(self.conn,self.game,pid,{'value':['not_a_field']})
|
||||||
|
|
||||||
|
def test_join_session_only_for_recent_unended_sessions(self):
|
||||||
|
for age in [15,24*60]:
|
||||||
|
self.conn.execute("INSERT INTO sessions(game_id,player_id,started_at) VALUES(%s,%s,now()-%s*interval '1 minute')",(self.game,self.players[0],age))
|
||||||
|
records=sessions(self.conn,self.game,self.players[0])
|
||||||
|
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_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)
|
||||||
|
|
||||||
|
def test_unknown_session_end_is_not_a_failed_five_minute_session(self):
|
||||||
|
pid=self.conn.execute('INSERT INTO players(game_id,roblox_user_id,first_seen) VALUES(%s,999,%s) RETURNING id',(self.game,self.today)).fetchone()['id']
|
||||||
|
self.conn.execute('INSERT INTO sessions(game_id,player_id,started_at) VALUES(%s,%s,%s)',(self.game,pid,self.today))
|
||||||
|
self.assertEqual(engagement(self.conn,self.game,2)['metrics']['firstSession5min'],50)
|
||||||
|
row=self.conn.execute('SELECT * FROM player_statistics WHERE game_id=%s AND id=%s',(self.game,pid)).fetchone()
|
||||||
|
player=player_values(row)
|
||||||
|
self.assertIsNone(player['avgSession'])
|
||||||
|
self.assertIsNone(player['playtime'])
|
||||||
|
self.assertIsNone(player.get('level'))
|
||||||
|
self.assertEqual(player['robux'],0)
|
||||||
|
|
||||||
|
def test_cross_game_player_reference_is_rejected(self):
|
||||||
|
other=self.conn.execute("INSERT INTO games(name) VALUES('Other fixture') RETURNING id").fetchone()['id']
|
||||||
|
with self.assertRaises(psycopg.errors.ForeignKeyViolation):
|
||||||
|
with self.conn.transaction():
|
||||||
|
self.conn.execute('INSERT INTO sessions(game_id,player_id,started_at) VALUES(%s,%s,%s)',(other,self.players[0],self.today))
|
||||||
|
|
||||||
|
def test_duplicate_receipt_is_rejected(self):
|
||||||
|
with self.assertRaises(psycopg.errors.UniqueViolation):
|
||||||
|
with self.conn.transaction():
|
||||||
|
self.conn.execute('INSERT INTO product_sales(game_id,product_id,receipt_id,purchased_at,unit_price_robux) VALUES(%s,%s,%s,%s,100)',
|
||||||
|
(self.game,self.products[0],'fixture-0',self.today))
|
||||||
|
|
||||||
|
|
||||||
|
class SeededApi(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.games=dispatch('/api/games',{})['games']
|
||||||
|
cls.game=cls.games[0]['id']
|
||||||
|
|
||||||
|
def test_daily_event_lines_match_summary(self):
|
||||||
|
data=dispatch(f'/api/games/{self.game}/events',{'days':['7']})
|
||||||
|
self.assertNotIn('events',data)
|
||||||
|
self.assertEqual(len(data['labels']),7)
|
||||||
|
counts={s['name']:sum(s['values']) for s in data['series']}
|
||||||
|
self.assertEqual(counts,{r['name']:r['total'] for r in data['eventSummary']})
|
||||||
|
self.assertEqual(sum(counts.values()),data['metrics']['events'])
|
||||||
|
|
||||||
|
def test_session_groups_filter_counts_and_chart(self):
|
||||||
|
all_data=dispatch(f'/api/games/{self.game}/sessions',{})
|
||||||
|
for segment in ['whales','playtime','first']:
|
||||||
|
data=dispatch(f'/api/games/{self.game}/sessions',{'segment':[segment]})
|
||||||
|
self.assertLessEqual(data['metrics']['sessions'],all_data['metrics']['sessions'])
|
||||||
|
self.assertNotIn('sessions',data)
|
||||||
|
line=next(s for s in data['series'] if s['name']=='Sessions')
|
||||||
|
self.assertEqual(sum(line['values']),data['metrics']['sessions'])
|
||||||
|
|
||||||
|
def test_internal_game_root_and_dataset_scope(self):
|
||||||
|
for game in self.games:
|
||||||
|
root=dispatch(f"/api/games/{game['id']}",{})
|
||||||
|
self.assertEqual(root['gameId'],game['id'])
|
||||||
|
self.assertIn('robloxExperienceId',root['game'])
|
||||||
|
self.assertTrue(all(f"/games/{game['id']}/" in route for route in root['datasets'].values()))
|
||||||
|
players=dispatch(f"/api/games/{game['id']}/players",{})
|
||||||
|
self.assertTrue(all(p['gameId']==game['id'] for p in players['playerRows']))
|
||||||
|
|
||||||
|
def test_real_player_detail_and_cross_game_lookup(self):
|
||||||
|
player=dispatch(f'/api/games/{self.game}/players',{})['playerRows'][0]
|
||||||
|
detail=dispatch(f"/api/games/{self.game}/players/{player['playerId']}",{})
|
||||||
|
self.assertEqual(detail['player']['playerId'],player['playerId'])
|
||||||
|
self.assertTrue(all(s['player_id']==player['playerId'] for s in detail['sessions']))
|
||||||
|
with self.assertRaises(ApiError) as error:
|
||||||
|
dispatch(f"/api/games/{self.games[1]['id']}/players/{player['playerId']}",{})
|
||||||
|
self.assertEqual(error.exception.status,404)
|
||||||
|
|
||||||
|
def test_missing_stats_sort_last_and_zero_is_preserved(self):
|
||||||
|
page=dispatch(f'/api/games/{self.game}/players',{'sort':['cash'],'direction':['asc'],'page':['999999']})
|
||||||
|
self.assertIsNone(page['playerRows'][-1].get('cash'))
|
||||||
|
page=dispatch(f'/api/games/{self.game}/players',{'sort':['robux'],'direction':['asc']})
|
||||||
|
self.assertEqual(page['playerRows'][0]['robux'],0)
|
||||||
|
|
||||||
|
def test_game_level_categories_and_missing_root_property(self):
|
||||||
|
with connect() as conn:
|
||||||
|
self.assertGreater(conn.execute('SELECT count(*) n FROM events WHERE game_id=%s AND player_id IS NULL',(self.game,)).fetchone()['n'],0)
|
||||||
|
self.assertGreater(conn.execute('SELECT count(*) n FROM game_metrics WHERE game_id=%s',(self.game,)).fetchone()['n'],0)
|
||||||
|
self.assertTrue(any(g['robloxExperienceId'] is None for g in self.games))
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityStages(unittest.TestCase):
|
||||||
|
def test_ordered_reach_and_dropoff(self):
|
||||||
|
definition={'start_event':'start','end_event':'end','configuration':{'steps':['prompt','purchase']}}
|
||||||
|
runs=[{'stage_progress':p} for p in [['start','prompt','purchase','end'],['start','prompt'],['start'],['start','purchase','prompt','end']]]
|
||||||
|
stages=stage_funnel(runs,definition)
|
||||||
|
self.assertEqual([s['runs'] for s in stages],[4,3,1,1])
|
||||||
|
self.assertEqual([s['reachPercent'] for s in stages],[100,75,25,25])
|
||||||
|
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.assertIsNone(stage_funnel([],definition)[0]['reachPercent'])
|
||||||
|
|
||||||
|
if __name__=='__main__': unittest.main()
|
||||||
Reference in New Issue
Block a user