Commit d9aedb66 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(analytics): every day a player opens the app, for the manager

player_daily_activity (one row per player per Cairo day: first and last
seen, time in the app, platform) is written by the presence pulse the app
already sends, inside el3ab_social_pulse (no extra call); the app now sends
its platform (android, ios, web). Backfilled from the games, coins and
messages already on record. manager_insights(p_days) computes the
dashboard's numbers in one call. Service role only.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 7267057e
...@@ -160,7 +160,8 @@ if ($method === 'POST') { ...@@ -160,7 +160,8 @@ if ($method === 'POST') {
if ($action === 'pulse') { if ($action === 'pulse') {
$game = in_array($input['game'] ?? null, SOCIAL_GAMES, true) ? $input['game'] : null; $game = in_array($input['game'] ?? null, SOCIAL_GAMES, true) ? $input['game'] : null;
$online = ($input['online'] ?? true) !== false; $online = ($input['online'] ?? true) !== false;
$res = supabaseRpc('el3ab_social_pulse', ['p_uid' => $userId, 'p_game' => $game, 'p_online' => $online]); $platform = in_array($input['platform'] ?? null, ['android', 'ios', 'web'], true) ? $input['platform'] : null;
$res = supabaseRpc('el3ab_social_pulse', ['p_uid' => $userId, 'p_game' => $game, 'p_online' => $online, 'p_platform' => $platform]);
if (!is_array($res) || isset($res['code']) || isset($res['message'])) jsonError('pulse failed', 502); if (!is_array($res) || isset($res['code']) || isset($res['message'])) jsonError('pulse failed', 502);
$dm = (array)($res['dm'] ?? []); $dm = (array)($res['dm'] ?? []);
$groups = (array)($res['groups'] ?? []); $groups = (array)($res['groups'] ?? []);
......
-- Real game analytics for the manager's dashboard.
--
-- 1. player_daily_activity: one row per player per Cairo day they had the app
-- open. Written by the presence pulse every open app already sends
-- (~25 s, el3ab_social_pulse): first and last sight, pulses, an estimate
-- of the time in the app (gaps over 90 s are not counted) and the
-- platform (android | ios | web). Backfilled from what the database
-- already shows a player did on a day (games, coins, messages, sign-up).
-- 2. manager_insights(p_days): every number the dashboard shows, in one
-- call: active players (DAU/WAU/MAU), new players, retention (D1/D7/D30),
-- matches per game with completion and length, time in the app, the
-- economy, platforms and countries. Test accounts never count.
-- Service role only; nothing here is readable by players.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS public.player_daily_activity (
player_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
day date NOT NULL,
first_seen timestamptz NOT NULL DEFAULT now(),
last_seen timestamptz NOT NULL DEFAULT now(),
pulses integer NOT NULL DEFAULT 0,
seconds integer NOT NULL DEFAULT 0,
platform text,
PRIMARY KEY (player_id, day)
);
CREATE INDEX IF NOT EXISTS player_daily_activity_day_idx ON public.player_daily_activity (day);
ALTER TABLE public.player_daily_activity ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.player_daily_activity FROM anon, authenticated;
-- The pulse records the day. Same arguments as before plus p_platform.
DROP FUNCTION IF EXISTS public.el3ab_social_pulse(uuid, text, boolean);
CREATE OR REPLACE FUNCTION public.el3ab_social_pulse(p_uid uuid, p_game text DEFAULT NULL::text, p_online boolean DEFAULT true, p_platform text DEFAULT NULL::text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_notif int;
v_requests int;
v_dm jsonb;
v_groups jsonb;
v_latest jsonb;
v_platform text := CASE WHEN p_platform IN ('android', 'ios', 'web') THEN p_platform ELSE NULL END;
BEGIN
UPDATE profiles
SET last_seen_at = now(),
is_online = p_online,
current_game = CASE WHEN p_online THEN p_game ELSE NULL END
WHERE id = p_uid;
-- The day this player had the app open (manager_insights reads it).
IF p_online THEN
INSERT INTO player_daily_activity AS a (player_id, day, first_seen, last_seen, pulses, seconds, platform)
VALUES (p_uid, (now() AT TIME ZONE 'Africa/Cairo')::date, now(), now(), 1, 0, v_platform)
ON CONFLICT (player_id, day) DO UPDATE
SET last_seen = now(),
pulses = a.pulses + 1,
seconds = a.seconds + CASE WHEN now() - a.last_seen <= interval '90 seconds'
THEN GREATEST(0, extract(epoch FROM now() - a.last_seen))::int ELSE 0 END,
platform = coalesce(v_platform, a.platform);
END IF;
SELECT count(*) INTO v_notif FROM notifications WHERE user_id = p_uid AND is_read IS NOT TRUE;
SELECT count(*) INTO v_requests FROM friendships WHERE addressee_id = p_uid AND status = 'pending';
SELECT coalesce(jsonb_object_agg(sender_id, n), '{}'::jsonb) INTO v_dm FROM (
SELECT m.sender_id, count(*) AS n
FROM friend_messages m
JOIN friendships f ON f.id = m.friendship_id
WHERE f.status = 'accepted'
AND (f.requester_id = p_uid OR f.addressee_id = p_uid)
AND m.sender_id <> p_uid
AND m.read_at IS NULL
AND NOT EXISTS (SELECT 1 FROM player_blocks b WHERE b.player_id = p_uid AND b.blocked_id = m.sender_id)
GROUP BY m.sender_id
) s;
SELECT to_jsonb(x) INTO v_latest FROM (
SELECT m.id, m.sender_id, m.content, m.message_type, m.created_at, p.display_name, p.username
FROM friend_messages m
JOIN friendships f ON f.id = m.friendship_id
LEFT JOIN profiles p ON p.id = m.sender_id
WHERE f.status = 'accepted'
AND (f.requester_id = p_uid OR f.addressee_id = p_uid)
AND m.sender_id <> p_uid
AND m.read_at IS NULL
AND NOT EXISTS (SELECT 1 FROM player_blocks b WHERE b.player_id = p_uid AND b.blocked_id = m.sender_id)
ORDER BY m.created_at DESC
LIMIT 1
) x;
SELECT coalesce(jsonb_object_agg(group_id, n), '{}'::jsonb) INTO v_groups FROM (
SELECT gm.group_id, count(c.id) AS n
FROM group_members gm
JOIN chat_messages c
ON c.channel_type = 'group'
AND c.channel_id = gm.group_id
AND c.created_at > gm.last_read_at
AND c.sender_id IS DISTINCT FROM p_uid
AND c.message_type <> 'system'
WHERE gm.user_id = p_uid
AND NOT EXISTS (SELECT 1 FROM player_blocks b WHERE b.player_id = p_uid AND b.blocked_id = c.sender_id)
GROUP BY gm.group_id
) g;
RETURN jsonb_build_object(
'notifications', v_notif,
'requests', v_requests,
'dm', v_dm,
'groups', v_groups,
'latest_dm', v_latest,
'now', now()
);
END;
$function$;
REVOKE EXECUTE ON FUNCTION public.el3ab_social_pulse(uuid, text, boolean, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.el3ab_social_pulse(uuid, text, boolean, text) TO service_role;
-- Backfill: the days a player was demonstrably at the app.
INSERT INTO public.player_daily_activity (player_id, day, first_seen, last_seen, pulses, seconds)
SELECT pid, d, min(at), max(at), 0, 0 FROM (
SELECT id AS pid, created_at AS at FROM profiles
UNION ALL SELECT id, last_seen_at FROM profiles WHERE last_seen_at IS NOT NULL
UNION ALL SELECT white_player_id, created_at FROM matches WHERE white_player_id IS NOT NULL
UNION ALL SELECT black_player_id, created_at FROM matches WHERE black_player_id IS NOT NULL
UNION ALL SELECT (p.v)::uuid, m.created_at FROM ludo_matches m, jsonb_array_elements_text(CASE WHEN jsonb_typeof(m.players) = 'array' THEN m.players ELSE '[]' END) p(v) WHERE p.v ~ '^[0-9a-f-]{36}$'
UNION ALL SELECT (p.v)::uuid, m.created_at FROM domino_matches m, jsonb_array_elements_text(CASE WHEN jsonb_typeof(m.players) = 'array' THEN m.players ELSE '[]' END) p(v) WHERE p.v ~ '^[0-9a-f-]{36}$'
UNION ALL SELECT (p.v)::uuid, m.created_at FROM backgammon_matches m, jsonb_array_elements_text(CASE WHEN jsonb_typeof(m.players) = 'array' THEN m.players ELSE '[]' END) p(v) WHERE p.v ~ '^[0-9a-f-]{36}$'
UNION ALL SELECT player_id, created_at FROM economy_transactions
UNION ALL SELECT sender_id, created_at FROM friend_messages
UNION ALL SELECT sender_id, created_at FROM chat_messages WHERE sender_id IS NOT NULL
UNION ALL SELECT player_id, ts FROM mp_log WHERE player_id IS NOT NULL
) s(pid, at)
CROSS JOIN LATERAL (SELECT (at AT TIME ZONE 'Africa/Cairo')::date) x(d)
WHERE pid IN (SELECT id FROM profiles) AND at IS NOT NULL
GROUP BY pid, d
ON CONFLICT (player_id, day) DO NOTHING;
CREATE OR REPLACE FUNCTION public.manager_insights(p_days integer DEFAULT 30)
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
WITH
today AS (SELECT (now() AT TIME ZONE 'Africa/Cairo')::date AS d),
span AS (SELECT greatest(7, least(coalesce(p_days, 30), 120)) AS n),
real_players AS (
SELECT id, created_at, (created_at AT TIME ZONE 'Africa/Cairo')::date AS joined, username, country_code, preferred_language, last_seen_at
FROM profiles WHERE coalesce(is_test, false) = false
),
act AS (
SELECT a.* FROM player_daily_activity a JOIN real_players p ON p.id = a.player_id
),
days AS (
SELECT generate_series((SELECT d FROM today) - (SELECT n FROM span) + 1, (SELECT d FROM today), interval '1 day')::date AS d
),
games AS (
SELECT 'chess'::text AS game, created_at, completed_at, status::text AS status, result::text AS result,
(bot_id IS NOT NULL OR match_type = 'bot') AS vs_bot, ARRAY[white_player_id, black_player_id] AS players
FROM matches WHERE created_at > now() - interval '120 days'
UNION ALL
SELECT 'ludo', created_at, completed_at, status, result, coalesce(game_state->>'mode', '') IN ('bot', 'ghost'),
ARRAY(SELECT v::uuid FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(players) = 'array' THEN players ELSE '[]' END) v WHERE v ~ '^[0-9a-f-]{36}$')
FROM ludo_matches WHERE created_at > now() - interval '120 days'
UNION ALL
SELECT 'domino', created_at, completed_at, status, result, coalesce(game_state->>'mode', '') IN ('bot', 'ghost'),
ARRAY(SELECT v::uuid FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(players) = 'array' THEN players ELSE '[]' END) v WHERE v ~ '^[0-9a-f-]{36}$')
FROM domino_matches WHERE created_at > now() - interval '120 days'
UNION ALL
SELECT 'backgammon', created_at, completed_at, status, result,
EXISTS (SELECT 1 FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(players) = 'array' THEN players ELSE '[]' END) v WHERE v LIKE 'bot%'),
ARRAY(SELECT v::uuid FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(players) = 'array' THEN players ELSE '[]' END) v WHERE v ~ '^[0-9a-f-]{36}$')
FROM backgammon_matches WHERE created_at > now() - interval '120 days'
),
real_games AS (
SELECT g.*, (g.created_at AT TIME ZONE 'Africa/Cairo')::date AS d
FROM games g
WHERE NOT EXISTS (SELECT 1 FROM unnest(g.players) u JOIN profiles p ON p.id = u WHERE p.is_test)
AND cardinality(g.players) > 0
),
cohorts AS (
SELECT p.id, p.joined,
EXISTS (SELECT 1 FROM act a WHERE a.player_id = p.id AND a.day = p.joined + 1) AS d1,
EXISTS (SELECT 1 FROM act a WHERE a.player_id = p.id AND a.day = p.joined + 7) AS d7,
EXISTS (SELECT 1 FROM act a WHERE a.player_id = p.id AND a.day = p.joined + 30) AS d30
FROM real_players p
WHERE p.joined > (SELECT d FROM today) - 90
)
SELECT jsonb_build_object(
'generated_at', now(),
'days', (SELECT n FROM span),
'players', jsonb_build_object(
'total', (SELECT count(*) FROM real_players),
'guests', (SELECT count(*) FROM real_players WHERE username LIKE 'guest\_%'),
'online_now', (SELECT count(*) FROM real_players WHERE last_seen_at > now() - interval '75 seconds'),
'dau', (SELECT count(DISTINCT player_id) FROM act WHERE day = (SELECT d FROM today)),
'wau', (SELECT count(DISTINCT player_id) FROM act WHERE day > (SELECT d FROM today) - 7),
'mau', (SELECT count(DISTINCT player_id) FROM act WHERE day > (SELECT d FROM today) - 30),
'avg_dau_30', (SELECT round(avg(c)::numeric, 1) FROM (SELECT count(DISTINCT a.player_id) c FROM days dd LEFT JOIN act a ON a.day = dd.d WHERE dd.d > (SELECT d FROM today) - 30 GROUP BY dd.d) z),
'new_today', (SELECT count(*) FROM real_players WHERE joined = (SELECT d FROM today)),
'new_7', (SELECT count(*) FROM real_players WHERE joined > (SELECT d FROM today) - 7),
'new_30', (SELECT count(*) FROM real_players WHERE joined > (SELECT d FROM today) - 30),
'tracked_since', (SELECT min(day) FROM player_daily_activity WHERE pulses > 0)
),
'retention', jsonb_build_object(
'd1', (SELECT jsonb_build_object('cohort', count(*), 'back', count(*) FILTER (WHERE d1)) FROM cohorts WHERE joined <= (SELECT d FROM today) - 1),
'd7', (SELECT jsonb_build_object('cohort', count(*), 'back', count(*) FILTER (WHERE d7)) FROM cohorts WHERE joined <= (SELECT d FROM today) - 7),
'd30', (SELECT jsonb_build_object('cohort', count(*), 'back', count(*) FILTER (WHERE d30)) FROM cohorts WHERE joined <= (SELECT d FROM today) - 30)
),
'series', (SELECT jsonb_agg(jsonb_build_object(
'day', dd.d,
'active', (SELECT count(DISTINCT player_id) FROM act WHERE day = dd.d),
'new', (SELECT count(*) FROM real_players WHERE joined = dd.d),
'minutes', (SELECT coalesce(sum(seconds), 0) / 60 FROM act WHERE day = dd.d),
'matches', (SELECT jsonb_object_agg(g, c) FROM (SELECT game AS g, count(*) c FROM real_games WHERE d = dd.d GROUP BY game) z)
) ORDER BY dd.d) FROM days dd),
'games', (SELECT jsonb_object_agg(game, x) FROM (
SELECT game, jsonb_build_object(
'matches', count(*),
'vs_bot', count(*) FILTER (WHERE vs_bot),
'online', count(*) FILTER (WHERE NOT vs_bot),
'completed', count(*) FILTER (WHERE status = 'completed' AND coalesce(result, '') <> 'too_short'),
'abandoned', count(*) FILTER (WHERE status IN ('abandoned', 'aborted')),
'too_short', count(*) FILTER (WHERE result = 'too_short'),
'players', (SELECT count(DISTINCT u) FROM real_games r2, unnest(r2.players) u WHERE r2.game = r.game AND r2.d > (SELECT d FROM today) - 30),
'avg_minutes', round((avg(extract(epoch FROM completed_at - created_at)) FILTER (WHERE status = 'completed' AND completed_at > created_at AND completed_at - created_at < interval '4 hours') / 60)::numeric, 1)
) AS x
FROM real_games r WHERE d > (SELECT d FROM today) - 30 GROUP BY game) z),
'session', jsonb_build_object(
'avg_minutes_per_active', (SELECT round((sum(seconds)::numeric / 60) / nullif(count(*), 0), 1) FROM act WHERE pulses > 0 AND day > (SELECT d FROM today) - 30),
'matches_per_active', (SELECT round(count(*)::numeric / nullif((SELECT count(*) FROM act WHERE day > (SELECT d FROM today) - 30), 0), 2) FROM real_games WHERE d > (SELECT d FROM today) - 30)
),
'platforms', (SELECT coalesce(jsonb_object_agg(pl, c), '{}'::jsonb) FROM (
SELECT coalesce(platform, 'unknown') pl, count(DISTINCT player_id) c FROM act WHERE day > (SELECT d FROM today) - 30 AND pulses > 0 GROUP BY 1) z),
'countries', (SELECT coalesce(jsonb_agg(jsonb_build_object('code', cc, 'players', c) ORDER BY c DESC), '[]'::jsonb) FROM (
SELECT coalesce(nullif(btrim(country_code), ''), '--') cc, count(*) c FROM real_players GROUP BY 1 ORDER BY 2 DESC LIMIT 8) z),
'economy', (SELECT jsonb_build_object(
'earned', coalesce(sum(amount) FILTER (WHERE amount > 0), 0),
'spent', coalesce(-sum(amount) FILTER (WHERE amount < 0), 0),
'by_type', (SELECT coalesce(jsonb_object_agg(type, s), '{}'::jsonb) FROM (SELECT type, sum(amount) s FROM economy_transactions e JOIN real_players p ON p.id = e.player_id WHERE e.created_at > now() - interval '30 days' GROUP BY type) z),
'coins_held', (SELECT coalesce(sum(coins), 0) FROM profiles WHERE coalesce(is_test, false) = false))
FROM economy_transactions e JOIN real_players p ON p.id = e.player_id WHERE e.created_at > now() - interval '30 days'),
'social', jsonb_build_object(
'friendships_30', (SELECT count(*) FROM friendships WHERE created_at > now() - interval '30 days'),
'messages_30', (SELECT count(*) FROM friend_messages WHERE created_at > now() - interval '30 days') + (SELECT count(*) FROM chat_messages WHERE created_at > now() - interval '30 days' AND channel_type = 'group'),
'org_members', (SELECT count(*) FROM org_members WHERE status = 'active')
),
'tournaments', jsonb_build_object(
'running', (SELECT count(*) FROM el3ab_tournaments WHERE status IN ('in_progress', 'paused')),
'open', (SELECT count(*) FROM el3ab_tournaments WHERE status = 'registration'),
'registrations_30', (SELECT count(*) FROM tournament_registrations WHERE registered_at > now() - interval '30 days' AND coalesce(is_bot, false) = false),
'page_views_30', (SELECT count(*) FROM tournament_page_views WHERE viewed_at > now() - interval '30 days')
)
);
$function$;
REVOKE EXECUTE ON FUNCTION public.manager_insights(integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.manager_insights(integer) TO service_role;
COMMIT;
NOTIFY pgrst, 'reload schema';
...@@ -64,7 +64,7 @@ async function pulse() { ...@@ -64,7 +64,7 @@ async function pulse() {
if (inFlight) return inFlight; if (inFlight) return inFlight;
inFlight = (async () => { inFlight = (async () => {
try { try {
const res = await net.post('social.php', { action: 'pulse', game: currentGame() }); const res = await net.post('social.php', { action: 'pulse', game: currentGame(), platform: platformName() });
if (!res || res.error) return null; if (!res || res.error) return null;
apply(res); apply(res);
openPendingLink(); openPendingLink();
...@@ -137,3 +137,9 @@ async function openPendingLink() { ...@@ -137,3 +137,9 @@ async function openPendingLink() {
toast(t('social.link_broken'), { type: 'error' }); toast(t('social.link_broken'), { type: 'error' });
} }
} }
/** Where the app runs, for the manager's analytics: the store app (android / ios) or the web. */
function platformName() {
if (!(window.IS_NATIVE_APP || window.flutter_bridge)) return 'web';
return /iphone|ipad|ipod|ios/i.test(navigator.userAgent || '') ? 'ios' : 'android';
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment