From a7271af8c2209f3958096aebb86b416a00430ac5 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 18 Oct 2025 00:27:10 -0400 Subject: [PATCH 1/8] Make it server-side Discord REST sdk clearer --- app/discord/api.ts | 2 +- app/discord/deployCommands.server.ts | 20 ++++++++++---------- app/helpers/guildData.server.ts | 8 +++++--- app/helpers/userInfoCache.ts | 4 ++-- app/models/discord.server.ts | 2 +- app/routes/__auth.tsx | 9 +-------- 6 files changed, 20 insertions(+), 25 deletions(-) diff --git a/app/discord/api.ts b/app/discord/api.ts index fc8ec76b..e01b99d0 100644 --- a/app/discord/api.ts +++ b/app/discord/api.ts @@ -1,4 +1,4 @@ import { REST } from "discord.js"; import { discordToken } from "#~/helpers/env.server"; -export const rest = new REST({ version: "10" }).setToken(discordToken); +export const ssrDiscordSdk = new REST({ version: "10" }).setToken(discordToken); diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts index 71e6bc40..c2944b0e 100644 --- a/app/discord/deployCommands.server.ts +++ b/app/discord/deployCommands.server.ts @@ -7,7 +7,7 @@ import type { } from "discord.js"; import { InteractionType, Routes } from "discord.js"; -import { rest } from "#~/discord/api"; +import { ssrDiscordSdk } from "#~/discord/api"; import type { AnyCommand } from "#~/helpers/discord"; import { isMessageComponentCommand, @@ -103,14 +103,14 @@ const applyCommandChanges = async ( del: (id: string) => `/${string}`, ) => { await Promise.allSettled( - toDelete.map((commandId) => rest.delete(del(commandId))), + toDelete.map((commandId) => ssrDiscordSdk.delete(del(commandId))), ); if (!didCommandsChange && remoteCount === localCommands.length) { return; } - await rest.put(put(), { body: localCommands }); + await ssrDiscordSdk.put(put(), { body: localCommands }); }; export const deployProdCommands = async ( @@ -129,7 +129,7 @@ export const deployProdCommands = async ( } return g; })(); - const randomGuildCommands = (await rest.get( + const randomGuildCommands = (await ssrDiscordSdk.get( Routes.applicationGuildCommands(applicationId, randomGuild.id), )) as APIApplicationCommand[]; if (randomGuildCommands.length > 0) { @@ -137,13 +137,13 @@ export const deployProdCommands = async ( // for each guild, guilds.map(async (g) => { // fetch all commands, - const commands = (await rest.get( + const commands = (await ssrDiscordSdk.get( Routes.applicationGuildCommands(applicationId, g.id), )) as APIApplicationCommand[]; // and delete each one await Promise.allSettled( commands.map(async (c) => - rest.delete( + ssrDiscordSdk.delete( Routes.applicationGuildCommand(applicationId, g.id, c.id), ), ), @@ -152,7 +152,7 @@ export const deployProdCommands = async ( ); } - const remoteCommands = (await rest.get( + const remoteCommands = (await ssrDiscordSdk.get( Routes.applicationCommands(applicationId), )) as APIApplicationCommand[]; const { didCommandsChange, toDelete } = calculateChangedCommands( @@ -188,13 +188,13 @@ export const deployTestCommands = async ( ) => { // Delete all global commands // This shouldn't happen, but ensures a consistent state esp in development - const globalCommands = (await rest.get( + const globalCommands = (await ssrDiscordSdk.get( Routes.applicationCommands(applicationId), )) as APIApplicationCommand[]; // and delete each one await Promise.allSettled( globalCommands.map(async (c) => - rest.delete(Routes.applicationCommand(applicationId, c.id)), + ssrDiscordSdk.delete(Routes.applicationCommand(applicationId, c.id)), ), ); @@ -203,7 +203,7 @@ export const deployTestCommands = async ( console.log(`Deploying test commands to ${guilds.size} guilds…`); await Promise.all( guilds.map(async (guild) => { - const guildCommands = (await rest.get( + const guildCommands = (await ssrDiscordSdk.get( Routes.applicationGuildCommands(applicationId, guild.id), )) as APIApplicationCommand[]; diff --git a/app/helpers/guildData.server.ts b/app/helpers/guildData.server.ts index 984c7405..ebbf6bb1 100644 --- a/app/helpers/guildData.server.ts +++ b/app/helpers/guildData.server.ts @@ -1,5 +1,5 @@ import { Routes } from "discord-api-types/v10"; -import { rest } from "#~/discord/api.js"; +import { ssrDiscordSdk } from "#~/discord/api.js"; import { log, trackPerformance } from "#~/helpers/observability"; export interface GuildRole { @@ -34,8 +34,10 @@ export async function fetchGuildData(guildId: string): Promise { "discord.fetchGuildData", () => Promise.all([ - rest.get(Routes.guildRoles(guildId)) as Promise, - rest.get(Routes.guildChannels(guildId)) as Promise, + ssrDiscordSdk.get(Routes.guildRoles(guildId)) as Promise, + ssrDiscordSdk.get(Routes.guildChannels(guildId)) as Promise< + GuildChannel[] + >, ]), ); diff --git a/app/helpers/userInfoCache.ts b/app/helpers/userInfoCache.ts index 15aa3ab3..2f1fa254 100644 --- a/app/helpers/userInfoCache.ts +++ b/app/helpers/userInfoCache.ts @@ -1,5 +1,5 @@ import { LRUCache } from "lru-cache"; -import { rest } from "#~/discord/api.js"; +import { ssrDiscordSdk } from "#~/discord/api.js"; import { Routes } from "discord.js"; import fs from "node:fs/promises"; @@ -18,7 +18,7 @@ export async function getOrFetchUser(id: string) { if (cache.has(id)) return cache.get(id); // @ts-expect-error FIXME: are there types available? schema validation? - const { username, global_name } = await rest.get(Routes.user(id)); + const { username, global_name } = await ssrDiscordSdk.get(Routes.user(id)); const result = { id, username, global_name } as DiscordUser; cache.set(id, result); console.log("Fetched user from Discord API:", id); diff --git a/app/models/discord.server.ts b/app/models/discord.server.ts index 3ef47ac8..0337fa44 100644 --- a/app/models/discord.server.ts +++ b/app/models/discord.server.ts @@ -187,7 +187,7 @@ export const fetchGuilds = async ( const botGuildIds = new Set(botGuilds.keys()); const userGuildIds = new Set(userGuilds.keys()); - const manageableGuilds = intersection(botGuildIds, userGuildIds); + const manageableGuilds = intersection(userGuildIds, botGuildIds); const invitableGuilds = complement(userGuildIds, botGuildIds); return [ diff --git a/app/routes/__auth.tsx b/app/routes/__auth.tsx index 3254e276..922d686d 100644 --- a/app/routes/__auth.tsx +++ b/app/routes/__auth.tsx @@ -48,7 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) { // Fetch guilds using both user token and bot token const guilds = await trackPerformance("discord.fetchGuilds", () => - fetchGuilds(userRest, rest), + fetchGuilds(userRest, ssrDiscordSdk), ); // Cache the result @@ -73,13 +73,6 @@ export default function Auth() { const { pathname, search, hash } = useLocation(); const { guilds } = useLoaderData(); - console.log("🏠 Auth component rendering:", { - hasUser: !!user, - guildsCount: guilds?.length || 0, - guilds, - pathname, - }); - if (!user) { return (
From fa72b39cacb663932ea6c80db942963e925f1ac6 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 18 Oct 2025 00:28:21 -0400 Subject: [PATCH 2/8] Change session func to take a full request, and do its own error handling --- app/models/session.server.ts | 27 ++++++++++++++++++++------- app/routes/discord-oauth.tsx | 20 +------------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/app/models/session.server.ts b/app/models/session.server.ts index f6ddad47..2ef2d7c8 100644 --- a/app/models/session.server.ts +++ b/app/models/session.server.ts @@ -212,12 +212,24 @@ export async function initOauthLogin({ }); } -export async function completeOauthLogin( - origin: string, - code: string, - reqCookie: string, - state?: string, -) { +export async function completeOauthLogin(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const cookie = request.headers.get("Cookie"); + + if (!code) { + console.error("No code provided by Discord"); + return redirect("/"); + } + if (!cookie) { + console.error("No cookie found when responding to Discord oauth"); + throw redirect("/login", 500); + } + + const origin: string = url.origin; + const reqCookie: string = cookie; + const state: string | undefined = url.searchParams.get("state") ?? undefined; + const [cookieSession, dbSession] = await Promise.all([ getCookieSession(reqCookie), getDbSession(reqCookie), @@ -293,9 +305,10 @@ export async function completeOauthLogin( throw redirect("/login"); } - dbSession.set(CookieSessionKeys.userId, userId); // @ts-expect-error token.toJSON() isn't in the types but it works dbSession.set(CookieSessionKeys.discordToken, token.toJSON()); + dbSession.set(CookieSessionKeys.userId, userId); + // Clean up session data cookieSession.unset(DbSessionKeys.authState); cookieSession.unset(DbSessionKeys.authFlow); diff --git a/app/routes/discord-oauth.tsx b/app/routes/discord-oauth.tsx index af4789f4..3f2a4110 100644 --- a/app/routes/discord-oauth.tsx +++ b/app/routes/discord-oauth.tsx @@ -1,24 +1,6 @@ import type { Route } from "./+types/discord-oauth"; -import { redirect } from "react-router"; import { completeOauthLogin } from "#~/models/session.server"; export async function loader({ request }: Route.LoaderArgs) { - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const cookie = request.headers.get("Cookie"); - if (!code) { - console.error("No code provided by Discord"); - return redirect("/"); - } - if (!cookie) { - console.error("No cookie found when responding to Discord oauth"); - throw redirect("/login", 500); - } - - return await completeOauthLogin( - url.origin, - code, - cookie, - url.searchParams.get("state") ?? undefined, - ); + return await completeOauthLogin(request); } From 7e9e0e4fa259beacdf705bd4c8dd18ac5e6e2d67 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 18 Oct 2025 00:29:01 -0400 Subject: [PATCH 3/8] Add a helper to get a user's Discord API access --- app/discord/api.ts | 8 ++++++++ app/routes/__auth.tsx | 10 +++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/discord/api.ts b/app/discord/api.ts index e01b99d0..05ae602e 100644 --- a/app/discord/api.ts +++ b/app/discord/api.ts @@ -1,4 +1,12 @@ import { REST } from "discord.js"; import { discordToken } from "#~/helpers/env.server"; +import { retrieveDiscordToken } from "#~/models/session.server.js"; export const ssrDiscordSdk = new REST({ version: "10" }).setToken(discordToken); + +export async function userDiscordSdkFromRequest(request: Request) { + const userToken = await retrieveDiscordToken(request); + return new REST({ version: "10", authPrefix: "Bearer" }).setToken( + userToken.token.access_token as string, + ); +} diff --git a/app/routes/__auth.tsx b/app/routes/__auth.tsx index 922d686d..5930f55d 100644 --- a/app/routes/__auth.tsx +++ b/app/routes/__auth.tsx @@ -2,10 +2,9 @@ import { Outlet, useLocation, useLoaderData } from "react-router"; import type { Route } from "./+types/__auth"; import { Login } from "#~/basics/login"; import { useOptionalUser } from "#~/utils"; -import { getUser, retrieveDiscordToken } from "#~/models/session.server"; +import { getUser } from "#~/models/session.server"; import { fetchGuilds } from "#~/models/discord.server"; -import { rest } from "#~/discord/api.js"; -import { REST } from "@discordjs/rest"; +import { ssrDiscordSdk, userDiscordSdkFromRequest } from "#~/discord/api.js"; import { log, trackPerformance } from "#~/helpers/observability"; import { DiscordLayout } from "#~/components/DiscordLayout"; import TTLCache from "@isaacs/ttlcache"; @@ -41,10 +40,7 @@ export async function loader({ request }: Route.LoaderArgs) { } // Get user's Discord token for user-specific guild fetching - const userToken = await retrieveDiscordToken(request); - const userRest = new REST({ version: "10", authPrefix: "Bearer" }).setToken( - userToken.token.access_token as string, - ); + const userRest = await userDiscordSdkFromRequest(request); // Fetch guilds using both user token and bot token const guilds = await trackPerformance("discord.fetchGuilds", () => From 5ee78e95ee661b715e314543e06c177145e56810 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 18 Oct 2025 00:29:13 -0400 Subject: [PATCH 4/8] Add a working redirect --- app/models/session.server.ts | 8 ++++---- app/routes.ts | 1 + app/routes/__auth/app.tsx | 29 +++++++++++++++++++++++++++++ app/routes/auth.tsx | 2 +- app/routes/index.tsx | 3 ++- 5 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 app/routes/__auth/app.tsx diff --git a/app/models/session.server.ts b/app/models/session.server.ts index 2ef2d7c8..427b1065 100644 --- a/app/models/session.server.ts +++ b/app/models/session.server.ts @@ -241,11 +241,11 @@ export async function completeOauthLogin(request: Request) { // Parse state to get UUID and redirectTo let cookieState; - let stateRedirectTo = "/guilds"; + let stateRedirectTo = "/app"; try { const parsedState = JSON.parse(cookieStateStr || "{}"); cookieState = parsedState.uuid; - stateRedirectTo = decodeURIComponent(parsedState.redirectTo) || "/guilds"; + stateRedirectTo = decodeURIComponent(parsedState.redirectTo) || "/app"; } catch (e) { console.error("Failed to parse state:", e); throw redirect("/login"); @@ -320,14 +320,14 @@ export async function completeOauthLogin(request: Request) { finalRedirectTo = `/onboard?guild_id=${guildId}`; } - const [cookie, dbCookie] = await Promise.all([ + const [clientCookie, dbCookie] = await Promise.all([ commitCookieSession(cookieSession, { maxAge: 60 * 60 * 24 * 7, // 7 days }), commitDbSession(dbSession), ]); const headers = new Headers(); - headers.append("Set-Cookie", cookie); + headers.append("Set-Cookie", clientCookie); headers.append("Set-Cookie", dbCookie); return redirect(finalRedirectTo, { headers }); diff --git a/app/routes.ts b/app/routes.ts index aa4088eb..1c3b8738 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -3,6 +3,7 @@ import { route, layout } from "@react-router/dev/routes"; export default [ layout("routes/__auth.tsx", [ + route("app/", "routes/__auth/app.tsx"), route("app/:guildId/onboard", "routes/onboard.tsx"), route("app/:guildId/settings", "routes/__auth/settings.tsx"), route("app/:guildId/sh", "routes/__auth/dashboard.tsx"), diff --git a/app/routes/__auth/app.tsx b/app/routes/__auth/app.tsx new file mode 100644 index 00000000..ad571492 --- /dev/null +++ b/app/routes/__auth/app.tsx @@ -0,0 +1,29 @@ +// import type { Route } from "../+types/index"; + +// export const loader = async ({ request }: Route.LoaderArgs) => { +// }; + +export default function Index() { + // Authenticated users are redirected in loader, so this only shows for guests + return ( +
+
+
+
+
+

+ + Euno + +

+

+ A community-in-a-box bot for large Discord servers with advanced + analytics and moderation tools +

+
+
+
+
+
+ ); +} diff --git a/app/routes/auth.tsx b/app/routes/auth.tsx index 338e2358..166c778a 100644 --- a/app/routes/auth.tsx +++ b/app/routes/auth.tsx @@ -32,7 +32,7 @@ export default function LoginPage() { return (
- +
); diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 1cd2b2f7..0124c237 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -46,8 +46,9 @@ const EmojiBackdrop = () => { export const loader = async ({ request }: Route.LoaderArgs) => { // If user is logged in, redirect to guilds page const user = await getUser(request); + if (user) { - throw redirect("/guilds"); + throw redirect("/app"); } return null; From 296ada1d7800b9eea018e479f64d3f758cd4ed02 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 24 Oct 2025 17:34:00 -0400 Subject: [PATCH 5/8] WIP --- app/routes/__auth/dashboard.tsx | 77 ++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/app/routes/__auth/dashboard.tsx b/app/routes/__auth/dashboard.tsx index 6b85bdf1..3cfe58e4 100644 --- a/app/routes/__auth/dashboard.tsx +++ b/app/routes/__auth/dashboard.tsx @@ -81,13 +81,22 @@ function RangeForm({ values }: { values: { start?: string; end?: string } }) { ); } -const DataHeading = ({ children }: PropsWithChildren) => { - return ( - - {children} - - ); -}; +const Td = ({ children, ...props }: PropsWithChildren) => ( + + {children} + +); +const Th = ({ children, ...props }: PropsWithChildren) => ( + + {children} + +); +const Tr = ({ children, ...props }: PropsWithChildren) => ( + {children} +); export default function DashboardPage({ loaderData: data, @@ -125,24 +134,24 @@ ${data > - - Author ID - Percent Zero Days - Word Count - Message Count - Channel Count - Category Count - Reaction Count - Word Score - Message Score - Channel Score - Consistency Score - + + + + + + + + + + + + + {data.map((d) => ( - - + - - - - - - - - - - - + + + + + + + + + + + + ))}
Author IDPercent Zero DaysWord CountMessage CountChannel CountCategory CountReaction CountWord ScoreMessage ScoreChannel ScoreConsistency Score
+
{d.data.member.username || d.data.member.author_id} - {percent(d.metadata.percentZeroDays)}{d.data.member.total_word_count}{d.data.member.message_count}{d.data.member.channel_count}{d.data.member.category_count}{d.data.member.total_reaction_count}{d.score.wordScore}{d.score.messageScore}{d.score.channelScore}{d.score.consistencyScore}
{percent(d.metadata.percentZeroDays)}{d.data.member.total_word_count}{d.data.member.message_count}{d.data.member.channel_count}{d.data.member.category_count}{d.data.member.total_reaction_count}{d.score.wordScore}{d.score.messageScore}{d.score.channelScore}{d.score.consistencyScore}
From df823f0454eb4aaef84a04daf1a00350cb88dc88 Mon Sep 17 00:00:00 2001 From: DanielFGray Date: Sun, 13 Jul 2025 01:10:03 -0500 Subject: [PATCH 6/8] user stats compared to cohort closes #167 --- app/helpers/cohortAnalysis.ts | 548 ++++++++++++++++++++++++++++++++ app/models/activity.server.ts | 18 ++ app/routes/__auth/dashboard.tsx | 38 ++- app/routes/__auth/sh-user.tsx | 16 +- 4 files changed, 605 insertions(+), 15 deletions(-) create mode 100644 app/helpers/cohortAnalysis.ts diff --git a/app/helpers/cohortAnalysis.ts b/app/helpers/cohortAnalysis.ts new file mode 100644 index 00000000..fa4aab80 --- /dev/null +++ b/app/helpers/cohortAnalysis.ts @@ -0,0 +1,548 @@ +import { createMessageStatsQuery } from "#~/models/activity.server"; +import { percentile, descriptiveStats } from "#~/helpers/statistics"; +import { sql } from "kysely"; +import { partition } from "lodash-es"; +import type { CodeStats } from "#~/discord/activityTracker.js"; +import { fillDateGaps } from "./dateUtils"; + +const performanceThresholds = [ + { min: 90, value: "top" }, + { min: 70, value: "above_average" }, + { min: 30, value: "average" }, + { min: 10, value: "below_average" }, + { min: -Infinity, value: "bottom" }, +] as const; + +type MetricConfig = { + key: "messageCount" | "reactionCount" | "codeChars" | "longestStreak"; + strength: string; + improvement: string; +}; + +const metricsConfig: MetricConfig[] = [ + { + key: "messageCount", + strength: "High message volume", + improvement: "Message frequency", + }, + { + key: "reactionCount", + strength: "Strong community engagement", + improvement: "Community engagement", + }, + { + key: "codeChars", + strength: "Significant code contributions", + improvement: "Code sharing", + }, + { + key: "longestStreak", + strength: "Excellent consistency", + improvement: "Activity consistency", + }, +] as const; + +export interface UserCohortMetrics { + userId: string; + messageCount: number; + wordCount: number; + reactionCount: number; + codeStats: { + totalChars: number; + totalLines: number; + languageBreakdown: Record; + topLanguages: Array<{ + language: string; + chars: number; + percentage: number; + }>; + }; + streakData: { + longestStreak: number; + currentStreak: number; + consistencyScore: number; + activeDays: number; + totalDays: number; + }; +} + +export interface CohortBenchmarks { + messageCount: PercentileBenchmarks; + wordCount: PercentileBenchmarks; + reactionCount: PercentileBenchmarks; + codeChars: PercentileBenchmarks; + codeLines: PercentileBenchmarks; + longestStreak: PercentileBenchmarks; + consistencyScore: PercentileBenchmarks; + languageDistribution: Record; +} + +export interface PercentileBenchmarks { + p10: number; + p25: number; + p50: number; // median + p75: number; + p90: number; + p95: number; + p99: number; + mean: number; + stdDev: number; + min: number; + max: number; +} + +export interface UserCohortComparison { + user: UserCohortMetrics; + percentiles: { + messageCount: number; + wordCount: number; + reactionCount: number; + codeChars: number; + codeLines: number; + longestStreak: number; + consistencyScore: number; + topLanguagePercentiles: Record; + }; + rankings: { + messageCount: { rank: number; total: number }; + wordCount: { rank: number; total: number }; + reactionCount: { rank: number; total: number }; + codeChars: { rank: number; total: number }; + longestStreak: { rank: number; total: number }; + }; + cohortInsights: { + overallPerformance: + | "top" + | "above_average" + | "average" + | "below_average" + | "bottom"; + strengths: string[]; + improvementAreas: string[]; + }; +} + +function calculatePercentileBenchmarks(data: number[]): PercentileBenchmarks { + if (data.length === 0) { + const empty = { + p10: 0, + p25: 0, + p50: 0, + p75: 0, + p90: 0, + p95: 0, + p99: 0, + mean: 0, + stdDev: 0, + min: 0, + max: 0, + }; + return empty; + } + + const stats = descriptiveStats(data); + + return { + p10: percentile(data, 0.1), + p25: percentile(data, 0.25), + p50: percentile(data, 0.5), + p75: percentile(data, 0.75), + p90: percentile(data, 0.9), + p95: percentile(data, 0.95), + p99: percentile(data, 0.99), + mean: stats.mean, + stdDev: stats.standardDeviation, + min: stats.min, + max: stats.max, + }; +} + +function calculateUserPercentile(value: number, data: number[]): number { + if (data.length === 0) return 0; + + const sortedData = data.slice(0).sort((a, b) => a - b); + const rank = sortedData.filter((x) => x <= value).length; + return (rank / sortedData.length) * 100; +} + +function calculateStreakData( + dailyActivity: Array<{ date: string; messageCount: number }>, +): UserCohortMetrics["streakData"] { + const sortedActivity = dailyActivity.sort((a, b) => + a.date.localeCompare(b.date), + ); + + let longestStreak = 0; + let currentStreak = 0; + let tempStreak = 0; + let activeDays = 0; + + for (let i = 0; i < sortedActivity.length; i++) { + const hasActivity = sortedActivity[i].messageCount > 0; + + if (hasActivity) { + activeDays++; + tempStreak++; + longestStreak = Math.max(longestStreak, tempStreak); + } else { + tempStreak = 0; + } + } + + // Calculate current streak from the end + for (let i = sortedActivity.length - 1; i >= 0; i--) { + if (sortedActivity[i].messageCount > 0) { + currentStreak++; + } else { + break; + } + } + + const totalDays = sortedActivity.length; + const consistencyScore = totalDays > 0 ? (activeDays / totalDays) * 100 : 0; + + return { + longestStreak, + currentStreak, + consistencyScore, + activeDays, + totalDays, + }; +} + +function aggregateCodeStats( + codeStatsJson: string[], +): UserCohortMetrics["codeStats"] { + const validCodeStats = codeStatsJson.flatMap((jsonStr) => { + try { + return JSON.parse(jsonStr) as Array; + } catch { + return []; + } + }); + + const { totalChars, totalLines, languageBreakdown } = validCodeStats.reduce( + (acc, stat) => ({ + totalChars: acc.totalChars + stat.chars, + totalLines: acc.totalLines + stat.lines, + languageBreakdown: { + ...acc.languageBreakdown, + ...(stat.lang && { + [stat.lang]: (acc.languageBreakdown[stat.lang] || 0) + stat.chars, + }), + }, + }), + { + totalChars: 0, + totalLines: 0, + languageBreakdown: {} as Record, + }, + ); + + const topLanguages = Object.entries(languageBreakdown) + .map(([language, chars]) => ({ + language, + chars, + percentage: totalChars > 0 ? (chars / totalChars) * 100 : 0, + })) + .sort((a, b) => b.chars - a.chars) + .slice(0, 5); + + return { + totalChars, + totalLines, + languageBreakdown, + topLanguages, + }; +} + +export async function getCohortMetrics( + guildId: string, + start: string, + end: string, + minMessageThreshold: number = 10, +): Promise { + // Get aggregated user data + const userStatsQuery = createMessageStatsQuery(guildId, start, end) + .select((eb) => [ + "author_id", + eb.fn.count("author_id").as("message_count"), + eb.fn.sum("word_count").as("word_count"), + eb.fn.sum("react_count").as("reaction_count"), + eb.fn("group_concat", ["code_stats"]).as("code_stats_json"), + eb + .fn("date", [eb("sent_at", "/", eb.lit(1000)), sql.lit("unixepoch")]) + .as("date"), + ]) + .groupBy("author_id") + .having((eb) => + eb(eb.fn.count("author_id"), ">=", minMessageThreshold), + ); + + const userStats = await userStatsQuery.execute(); + + // Get daily activity for streak calculation + const dailyActivityQuery = createMessageStatsQuery(guildId, start, end) + .select(({ fn, eb, lit }) => [ + "author_id", + fn.count("author_id").as("message_count"), + eb + .fn("date", [eb("sent_at", "/", lit(1000)), sql.lit("unixepoch")]) + .as("date"), + ]) + .groupBy(["author_id", "date"]) + .where( + "author_id", + "in", + userStats.map((u) => u.author_id), + ); + + const dailyActivity = await dailyActivityQuery.execute(); + + // Group daily activity by user + const dailyActivityByUser = dailyActivity.reduce( + (acc, record) => { + const userId = record.author_id; + if (!acc[userId]) acc[userId] = []; + acc[userId].push({ + date: record.date as string, + messageCount: record.message_count, + }); + return acc; + }, + {} as Record>, + ); + + return userStats.map((user) => { + const codeStatsArray = user.code_stats_json + ? String(user.code_stats_json).split(",").filter(Boolean) + : []; + + const userDailyActivity = fillDateGaps( + dailyActivityByUser[user.author_id] || [], + start, + end, + { messageCount: 0 }, + ); + + return { + userId: user.author_id, + messageCount: user.message_count, + wordCount: user.word_count || 0, + reactionCount: user.reaction_count || 0, + codeStats: aggregateCodeStats(codeStatsArray), + streakData: calculateStreakData(userDailyActivity), + }; + }); +} + +export function calculateCohortBenchmarks( + cohortMetrics: UserCohortMetrics[], +): CohortBenchmarks { + if (cohortMetrics.length === 0) { + const empty = { + p10: 0, + p25: 0, + p50: 0, + p75: 0, + p90: 0, + p95: 0, + p99: 0, + mean: 0, + stdDev: 0, + min: 0, + max: 0, + }; + return { + messageCount: empty, + wordCount: empty, + reactionCount: empty, + codeChars: empty, + codeLines: empty, + longestStreak: empty, + consistencyScore: empty, + languageDistribution: {}, + }; + } + + // Extract arrays for each metric + const messageCounts = cohortMetrics.map((u) => u.messageCount); + const wordCounts = cohortMetrics.map((u) => u.wordCount); + const reactionCounts = cohortMetrics.map((u) => u.reactionCount); + const codeChars = cohortMetrics.map((u) => u.codeStats.totalChars); + const codeLines = cohortMetrics.map((u) => u.codeStats.totalLines); + const longestStreaks = cohortMetrics.map((u) => u.streakData.longestStreak); + const consistencyScores = cohortMetrics.map( + (u) => u.streakData.consistencyScore, + ); + + // Calculate language distribution benchmarks + const allLanguages = new Set( + cohortMetrics.flatMap((user) => + Object.keys(user.codeStats.languageBreakdown), + ), + ); + + const languageDistribution = Array.from(allLanguages).reduce( + (acc, language) => { + acc[language] = calculatePercentileBenchmarks( + cohortMetrics.map((u) => u.codeStats.languageBreakdown[language]), + ); + return acc; + }, + {} as Record, + ); + + return { + messageCount: calculatePercentileBenchmarks(messageCounts), + wordCount: calculatePercentileBenchmarks(wordCounts), + reactionCount: calculatePercentileBenchmarks(reactionCounts), + codeChars: calculatePercentileBenchmarks(codeChars), + codeLines: calculatePercentileBenchmarks(codeLines), + longestStreak: calculatePercentileBenchmarks(longestStreaks), + consistencyScore: calculatePercentileBenchmarks(consistencyScores), + languageDistribution, + }; +} + +export function compareUserToCohort( + userMetrics: UserCohortMetrics, + cohortMetrics: UserCohortMetrics[], +): UserCohortComparison { + // Calculate percentiles + const messageCounts = cohortMetrics.map((u) => u.messageCount); + const wordCounts = cohortMetrics.map((u) => u.wordCount); + const reactionCounts = cohortMetrics.map((u) => u.reactionCount); + const codeChars = cohortMetrics.map((u) => u.codeStats.totalChars); + const codeLines = cohortMetrics.map((u) => u.codeStats.totalLines); + const longestStreaks = cohortMetrics.map((u) => u.streakData.longestStreak); + const consistencyScores = cohortMetrics.map( + (u) => u.streakData.consistencyScore, + ); + + const percentiles = { + messageCount: calculateUserPercentile( + userMetrics.messageCount, + messageCounts, + ), + wordCount: calculateUserPercentile(userMetrics.wordCount, wordCounts), + reactionCount: calculateUserPercentile( + userMetrics.reactionCount, + reactionCounts, + ), + codeChars: calculateUserPercentile( + userMetrics.codeStats.totalChars, + codeChars, + ), + codeLines: calculateUserPercentile( + userMetrics.codeStats.totalLines, + codeLines, + ), + longestStreak: calculateUserPercentile( + userMetrics.streakData.longestStreak, + longestStreaks, + ), + consistencyScore: calculateUserPercentile( + userMetrics.streakData.consistencyScore, + consistencyScores, + ), + // Calculate language percentiles for user's top languages + topLanguagePercentiles: userMetrics.codeStats.topLanguages.reduce( + (acc, { language }) => { + acc[language] = calculateUserPercentile( + userMetrics.codeStats.languageBreakdown[language] || 0, + cohortMetrics.map( + (u) => u.codeStats.languageBreakdown[language] || 0, + ), + ); + return acc; + }, + {} as Record, + ), + }; + + // Calculate rankings + const rankings = { + messageCount: { + rank: + messageCounts.filter((count) => count > userMetrics.messageCount) + .length + 1, + total: messageCounts.length, + }, + wordCount: { + rank: + wordCounts.filter((count) => count > userMetrics.wordCount).length + 1, + total: wordCounts.length, + }, + reactionCount: { + rank: + reactionCounts.filter((count) => count > userMetrics.reactionCount) + .length + 1, + total: reactionCounts.length, + }, + codeChars: { + rank: + codeChars.filter((chars) => chars > userMetrics.codeStats.totalChars) + .length + 1, + total: codeChars.length, + }, + longestStreak: { + rank: + longestStreaks.filter( + (streak) => streak > userMetrics.streakData.longestStreak, + ).length + 1, + total: longestStreaks.length, + }, + }; + + // Generate insights + const avgPercentile = + (percentiles.messageCount + + percentiles.wordCount + + percentiles.reactionCount + + percentiles.longestStreak) / + 4; + + const overallPerformance = performanceThresholds.find( + (t) => avgPercentile >= t.min, + )!.value; + + const [strengthConfigs, improvementConfigs] = partition( + metricsConfig, + (config) => percentiles[config.key] >= 50, + ); + + const strengths = strengthConfigs.map((config) => config.strength); + const improvementAreas = improvementConfigs.map( + (config) => config.improvement, + ); + + return { + user: userMetrics, + percentiles, + rankings, + cohortInsights: { + overallPerformance, + strengths, + improvementAreas, + }, + }; +} + +export async function getUserCohortAnalysis( + guildId: string, + userId: string, + start: string, + end: string, + minMessageThreshold: number = 10, +) { + const cohortMetrics = await getCohortMetrics( + guildId, + start, + end, + minMessageThreshold, + ); + const userMetrics = cohortMetrics.find((u) => u.userId === userId); + if (!userMetrics) return null; + return compareUserToCohort(userMetrics, cohortMetrics); +} diff --git a/app/models/activity.server.ts b/app/models/activity.server.ts index c51832dc..5be106d4 100644 --- a/app/models/activity.server.ts +++ b/app/models/activity.server.ts @@ -3,6 +3,7 @@ import db from "#~/db.server"; import { getOrFetchUser } from "#~/helpers/userInfoCache.js"; import { fillDateGaps } from "#~/helpers/dateUtils"; import { sql } from "kysely"; +import { getUserCohortAnalysis } from "#~/helpers/cohortAnalysis"; type MessageStats = DB["message_stats"]; @@ -136,6 +137,23 @@ export async function getUserMessageAnalytics( return { dailyBreakdown, categoryBreakdown, channelBreakdown, userInfo }; } +export async function getEnhancedUserAnalytics( + guildId: string, + userId: string, + start: string, + end: string, +) { + const [basicAnalytics, cohortComparison] = await Promise.all([ + getUserMessageAnalytics(guildId, userId, start, end), + getUserCohortAnalysis(guildId, userId, start, end), + ]); + + return { + ...basicAnalytics, + cohortComparison, + }; +} + export async function getTopParticipants( guildId: MessageStats["guild_id"], intervalStart: string, diff --git a/app/routes/__auth/dashboard.tsx b/app/routes/__auth/dashboard.tsx index 3cfe58e4..d478cad3 100644 --- a/app/routes/__auth/dashboard.tsx +++ b/app/routes/__auth/dashboard.tsx @@ -3,6 +3,10 @@ import { data, useSearchParams, Link } from "react-router"; import type { LabelHTMLAttributes, PropsWithChildren } from "react"; import { getTopParticipants } from "#~/models/activity.server"; import { log, trackPerformance } from "#~/helpers/observability"; +import { + getCohortMetrics, + calculateCohortBenchmarks, +} from "#~/helpers/cohortAnalysis"; export async function loader({ params, request }: Route.LoaderArgs) { return trackPerformance( @@ -12,6 +16,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { const start = url.searchParams.get("start"); const end = url.searchParams.get("end"); const guildId = params.guildId; + const minThreshold = Number(url.searchParams.get("minThreshold") || 10); log("info", "Dashboard", "Dashboard loader accessed", { guildId, @@ -35,16 +40,17 @@ export async function loader({ params, request }: Route.LoaderArgs) { return data(null, { status: 400 }); } - const output = await getTopParticipants(guildId, start, end); + const userResults = await getTopParticipants(guildId, start, end); - log("info", "Dashboard", "Dashboard data loaded successfully", { + // Return full cohort metrics and benchmarks + const cohortMetrics = await getCohortMetrics( guildId, start, end, - participantCount: output?.length || 0, - }); - - return output; + minThreshold, + ); + const benchmarks = calculateCohortBenchmarks(cohortMetrics); + return { cohortMetrics, benchmarks, userResults }; }, { guildId: params.guildId, @@ -98,15 +104,13 @@ const Tr = ({ children, ...props }: PropsWithChildren) => ( {children} ); -export default function DashboardPage({ - loaderData: data, -}: Route.ComponentProps) { +export default function DashboardPage({ loaderData }: Route.ComponentProps) { const [qs] = useSearchParams(); const start = qs.get("start") ?? undefined; const end = qs.get("end") ?? undefined; - if (!data) { + if (!loaderData) { return (
@@ -117,15 +121,25 @@ export default function DashboardPage({ ); } + const { userResults, cohortMetrics, benchmarks } = loaderData; + return (
+ + +