diff --git a/app/features/StarHunter/RangeForm.tsx b/app/features/StarHunter/RangeForm.tsx new file mode 100644 index 00000000..596c7837 --- /dev/null +++ b/app/features/StarHunter/RangeForm.tsx @@ -0,0 +1,239 @@ +import { + addDays, + addMonths, + addYears, + endOfQuarter, + endOfYear, + format, + startOfQuarter, + startOfYear, + subDays, +} from "date-fns"; +import { useState, type LabelHTMLAttributes } from "react"; + +const Label = (props: LabelHTMLAttributes) => ( + +); + +const DATE_FORMAT = "yyyy-MM-dd"; +const DISPLAY_FORMAT = "MMM d, yyyy"; + +export type PresetKey = "30d" | "quarter" | "half" | "year" | "custom"; + +const presets: { key: PresetKey; label: string }[] = [ + { key: "30d", label: "30 days" }, + { key: "quarter", label: "Quarter" }, + { key: "half", label: "6 months" }, + { key: "year", label: "Year" }, + { key: "custom", label: "Custom" }, +]; + +// Get start of the half-year (H1: Jan-Jun, H2: Jul-Dec) +function startOfHalf(date: Date): Date { + const month = date.getMonth(); + const year = date.getFullYear(); + return month < 6 ? new Date(year, 0, 1) : new Date(year, 6, 1); +} + +function endOfHalf(date: Date): Date { + const month = date.getMonth(); + const year = date.getFullYear(); + return month < 6 ? new Date(year, 5, 30) : new Date(year, 11, 31); +} + +function shiftDatesByInterval( + start: Date, + _end: Date, + interval: Exclude, + direction: "back" | "forward", +): { start: string; end: string } { + const shift = direction === "back" ? -1 : 1; + + switch (interval) { + case "30d": + return { + start: format(addDays(start, shift * 30), DATE_FORMAT), + end: format(addDays(start, shift * 30 + 29), DATE_FORMAT), + }; + case "quarter": { + const newStart = startOfQuarter(addMonths(start, shift * 3)); + return { + start: format(newStart, DATE_FORMAT), + end: format(endOfQuarter(newStart), DATE_FORMAT), + }; + } + case "half": { + const newStart = startOfHalf(addMonths(start, shift * 6)); + return { + start: format(newStart, DATE_FORMAT), + end: format(endOfHalf(newStart), DATE_FORMAT), + }; + } + case "year": { + const newStart = startOfYear(addYears(start, shift)); + return { + start: format(newStart, DATE_FORMAT), + end: format(endOfYear(newStart), DATE_FORMAT), + }; + } + } +} + +function getPresetDates(key: Exclude): { + start: string; + end: string; +} { + const today = new Date(); + + switch (key) { + case "30d": + return { + start: format(subDays(today, 30), DATE_FORMAT), + end: format(today, DATE_FORMAT), + }; + case "quarter": + return { + start: format(startOfQuarter(today), DATE_FORMAT), + end: format(endOfQuarter(today), DATE_FORMAT), + }; + case "half": + return { + start: format(startOfHalf(today), DATE_FORMAT), + end: format(endOfHalf(today), DATE_FORMAT), + }; + case "year": + return { + start: format(startOfYear(today), DATE_FORMAT), + end: format(endOfYear(today), DATE_FORMAT), + }; + } +} + +function navigateTo(start: string, end: string, interval?: PresetKey) { + const url = new URL(window.location.href); + url.searchParams.set("start", start); + url.searchParams.set("end", end); + if (interval) { + url.searchParams.set("interval", interval); + } else { + url.searchParams.delete("interval"); + } + window.location.href = url.toString(); +} + +export function RangeForm({ + values, + interval: currentInterval, +}: { + values: { start?: string; end?: string }; + interval?: PresetKey; +}) { + const [showCustom, setShowCustom] = useState(currentInterval === "custom"); + const [selectedInterval, setSelectedInterval] = useState( + currentInterval ?? "30d", + ); + + const handlePresetChange = (e: React.ChangeEvent) => { + const key = e.target.value as PresetKey; + setSelectedInterval(key); + if (key === "custom") { + setShowCustom(true); + return; + } + setShowCustom(false); + const { start, end } = getPresetDates(key); + navigateTo(start, end, key); + }; + + const handleNav = (direction: "back" | "forward") => { + if (!values.start || !values.end || selectedInterval === "custom") return; + const { start, end } = shiftDatesByInterval( + new Date(values.start), + new Date(values.end), + selectedInterval, + direction, + ); + navigateTo(start, end, selectedInterval); + }; + + const rangeLabel = + values.start && values.end + ? `${format(new Date(values.start), DISPLAY_FORMAT)} – ${format(new Date(values.end), DISPLAY_FORMAT)}` + : null; + + const canNavigate = + values.start && values.end && selectedInterval !== "custom"; + + return ( +
+
+ + + {canNavigate && ( +
+ + +
+ )} +
+ + {rangeLabel &&
{rangeLabel}
} + + {showCustom && ( +
+ + + + +
+ )} +
+ ); +} diff --git a/app/helpers/cohortAnalysis.ts b/app/helpers/cohortAnalysis.ts new file mode 100644 index 00000000..1fe87c84 --- /dev/null +++ b/app/helpers/cohortAnalysis.ts @@ -0,0 +1,550 @@ +import { sql } from "kysely"; +import { partition } from "lodash-es"; + +import type { CodeStats } from "#~/helpers/discord"; +import { descriptiveStats, percentile } from "#~/helpers/statistics"; +import { createMessageStatsQuery } from "#~/models/activity.server"; + +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; + +interface 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: { + 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: { 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 (const { messageCount } of sortedActivity) { + const hasActivity = 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 CodeStats[]; + } 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 = 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 + ? JSON.stringify(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 = 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/helpers/discord.ts b/app/helpers/discord.ts index ae02fe5d..a667744c 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -204,7 +204,7 @@ export const isModalCommand = (config: AnyCommand): config is ModalCommand => "type" in config.command && config.command.type === InteractionType.ModalSubmit; -interface CodeStats { +export interface CodeStats { chars: number; words: number; lines: number; diff --git a/app/models/activity.server.ts b/app/models/activity.server.ts index dad874c3..57d10526 100644 --- a/app/models/activity.server.ts +++ b/app/models/activity.server.ts @@ -1,6 +1,7 @@ import { sql } from "kysely"; import db, { type DB } from "#~/db.server"; +import { getUserCohortAnalysis } from "#~/helpers/cohortAnalysis"; import { fillDateGaps } from "#~/helpers/dateUtils"; import { getOrFetchUser } from "#~/helpers/userInfoCache.js"; @@ -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 2da00b32..77dfae08 100644 --- a/app/routes/__auth/dashboard.tsx +++ b/app/routes/__auth/dashboard.tsx @@ -1,6 +1,11 @@ -import type { LabelHTMLAttributes, PropsWithChildren } from "react"; +import type { PropsWithChildren } from "react"; import { data, Link, useSearchParams } from "react-router"; +import { RangeForm, type PresetKey } from "#~/features/StarHunter/RangeForm.js"; +import { + calculateCohortBenchmarks, + getCohortMetrics, +} from "#~/helpers/cohortAnalysis"; import { log, trackPerformance } from "#~/helpers/observability"; import { getTopParticipants } from "#~/models/activity.server"; @@ -14,6 +19,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, @@ -37,16 +43,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, @@ -56,70 +63,67 @@ export async function loader({ params, request }: Route.LoaderArgs) { ); } -const Label = (props: LabelHTMLAttributes) => ( - -); - const percentFormatter = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0, }); const percent = percentFormatter.format.bind(percentFormatter); -function RangeForm({ values }: { values: { start?: string; end?: string } }) { - return ( -
- - - -
- ); -} - -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, -}: Route.ComponentProps) { +export default function DashboardPage({ loaderData }: Route.ComponentProps) { const [qs] = useSearchParams(); const start = qs.get("start") ?? undefined; const end = qs.get("end") ?? undefined; + const interval = (qs.get("interval") as PresetKey) ?? undefined; - if (!data) { + if (!loaderData) { return (
- +
); } + const { userResults, cohortMetrics, benchmarks } = loaderData; + return (
- +
+ + + - - 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
+ {userResults.map((d) => ( +
{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}
diff --git a/app/routes/__auth/sh-user.tsx b/app/routes/__auth/sh-user.tsx index a2d7ac5e..040d8436 100644 --- a/app/routes/__auth/sh-user.tsx +++ b/app/routes/__auth/sh-user.tsx @@ -19,6 +19,7 @@ import { YAxis, } from "recharts"; +import { getUserCohortAnalysis } from "#~/helpers/cohortAnalysis.js"; import { getUserMessageAnalytics } from "#~/models/activity.server"; import type { Route } from "./+types/sh-user"; @@ -32,13 +33,21 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const url = new URL(request.url); const start = url.searchParams.get("start"); const end = url.searchParams.get("end"); + const minThreshold = Number(url.searchParams.get("minThreshold") ?? 10); if (!start || !end) { throw new Error("cannot load data without start and end range"); } - // Use shared analytics function with channel filtering disabled for user view - return await getUserMessageAnalytics(guildId, userId, start, end); + const [analysis, data] = await Promise.all([ + getUserCohortAnalysis(guildId, userId, start, end, minThreshold), + getUserMessageAnalytics(guildId, userId, start, end), + ]); + + return { + analysis, + data, + }; } const num = new Intl.NumberFormat("en-US", { @@ -48,7 +57,7 @@ const num = new Intl.NumberFormat("en-US", { export default function UserProfile({ params, - loaderData: data, + loaderData: { data, analysis }, }: Route.ComponentProps) { const [qs] = useSearchParams(); const start = qs.get("start"); @@ -130,6 +139,7 @@ text {

Received {num.format(derivedData.totalReactions)} reactions.

+
{JSON.stringify(analysis, null, 2)}