Add PostgreSQL analytics dashboard prototype and realistic sample data

This commit is contained in:
maxwes08
2026-11-03 09:13:00 +01:00
commit 70b94f8672
38 changed files with 2873 additions and 0 deletions

130
mockups/db/001_initial.sql Normal file
View 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');

View 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');

View 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
View 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'));