Add PostgreSQL analytics dashboard prototype and realistic sample data
This commit is contained in:
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'));
|
||||
Reference in New Issue
Block a user