diff --git a/app/helpers/statistics.test.ts b/app/helpers/statistics.test.ts new file mode 100644 index 0000000..b884255 --- /dev/null +++ b/app/helpers/statistics.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from "vitest"; +import { + descriptiveStats, + correlation, + linearRegression, + zScore, + percentile, + outliers, + movingAverage, + histogram, + confidence, + tTest, +} from "./statistics"; + +describe("statistics helpers", () => { + const sampleData = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const smallData = [1, 2, 3]; + + describe("descriptiveStats", () => { + it("calculates basic statistics correctly", () => { + const stats = descriptiveStats(sampleData); + + expect(stats.mean).toBe(5.5); + expect(stats.median).toBe(5.5); + expect(stats.min).toBe(1); + expect(stats.max).toBe(10); + expect(stats.count).toBe(10); + expect(stats.sum).toBe(55); + expect(stats.range).toBe(9); + }); + + it("throws error for empty data", () => { + expect(() => descriptiveStats([])).toThrow( + "Cannot calculate statistics for empty dataset", + ); + }); + }); + + describe("correlation", () => { + it("calculates correlation correctly", () => { + const x = [1, 2, 3, 4, 5]; + const y = [2, 4, 6, 8, 10]; + + const result = correlation(x, y); + + expect(result.coefficient).toBeCloseTo(1, 10); + expect(result.direction).toBe("positive"); + expect(result.strength).toBe("very strong"); + }); + + it("throws error for mismatched array lengths", () => { + expect(() => correlation([1, 2], [1, 2, 3])).toThrow( + "Arrays must have the same length", + ); + }); + }); + + describe("linearRegression", () => { + it("calculates linear regression correctly", () => { + const x = [1, 2, 3, 4, 5]; + const y = [2, 4, 6, 8, 10]; + + const result = linearRegression(x, y); + + expect(result.slope).toBe(2); + expect(result.intercept).toBe(0); + expect(result.predict(6)).toBe(12); + }); + + it("throws error for insufficient data points", () => { + expect(() => linearRegression([1], [2])).toThrow( + "Need at least 2 data points for regression", + ); + }); + }); + + describe("zScore", () => { + it("calculates z-score correctly", () => { + const score = zScore(7, 5, 2); + expect(score).toBe(1); + }); + + it("throws error for zero standard deviation", () => { + expect(() => zScore(5, 5, 0)).toThrow( + "Standard deviation cannot be zero", + ); + }); + }); + + describe("percentile", () => { + it("calculates percentiles correctly", () => { + const p50 = percentile(sampleData, 0.5); + const p25 = percentile(sampleData, 0.25); + + expect(p50).toBe(5.5); + expect(p25).toBeCloseTo(3.25, 0); + }); + + it("throws error for invalid percentile values", () => { + expect(() => percentile(sampleData, 1.5)).toThrow( + "Percentile must be between 0 and 1", + ); + expect(() => percentile(sampleData, -0.1)).toThrow( + "Percentile must be between 0 and 1", + ); + }); + }); + + describe("outliers", () => { + it("detects outliers using IQR method", () => { + const dataWithOutliers = [1, 2, 3, 4, 5, 100]; + const result = outliers(dataWithOutliers, "iqr"); + + expect(result).toContain(100); + }); + + it("detects outliers using z-score method", () => { + const dataWithOutliers = [1, 2, 3, 4, 5, 100]; + const result = outliers(dataWithOutliers, "zscore"); + + expect(result).toContain(100); + }); + + it("returns empty array for empty data", () => { + expect(outliers([])).toEqual([]); + }); + }); + + describe("movingAverage", () => { + it("calculates moving average correctly", () => { + const result = movingAverage([1, 2, 3, 4, 5], 3); + + expect(result).toEqual([2, 3, 4]); + }); + + it("throws error for invalid window size", () => { + expect(() => movingAverage(sampleData, 0)).toThrow("Invalid window size"); + expect(() => movingAverage(sampleData, 15)).toThrow( + "Invalid window size", + ); + }); + }); + + describe("histogram", () => { + it("creates histogram correctly", () => { + const result = histogram(smallData, 2); + + expect(result).toHaveLength(2); + expect(result[0].count + result[1].count).toBe(3); + }); + + it("returns empty array for empty data", () => { + expect(histogram([])).toEqual([]); + }); + }); + + describe("confidence", () => { + it("calculates confidence interval correctly", () => { + const result = confidence(sampleData, 0.95); + + expect(result.mean).toBe(5.5); + expect(result.lower).toBeLessThan(result.mean); + expect(result.upper).toBeGreaterThan(result.mean); + }); + + it("throws error for empty data", () => { + expect(() => confidence([])).toThrow( + "Cannot calculate confidence interval for empty dataset", + ); + }); + + it("throws error for invalid confidence level", () => { + expect(() => confidence(sampleData, 0)).toThrow( + "Confidence level must be between 0 and 1", + ); + expect(() => confidence(sampleData, 1)).toThrow( + "Confidence level must be between 0 and 1", + ); + }); + }); + + describe("tTest", () => { + it("performs t-test correctly", () => { + const sample1 = [1, 2, 3, 4, 5]; + const sample2 = [6, 7, 8, 9, 10]; + + const result = tTest(sample1, sample2); + + expect(typeof result.tStatistic).toBe("number"); + expect(typeof result.pValue).toBe("number"); + expect(typeof result.significant).toBe("boolean"); + }); + + it("throws error for empty samples", () => { + expect(() => tTest([], [1, 2, 3])).toThrow( + "Cannot perform t-test on empty samples", + ); + expect(() => tTest([1, 2, 3], [])).toThrow( + "Cannot perform t-test on empty samples", + ); + }); + }); +}); diff --git a/app/helpers/statistics.ts b/app/helpers/statistics.ts new file mode 100644 index 0000000..86318e2 --- /dev/null +++ b/app/helpers/statistics.ts @@ -0,0 +1,262 @@ +import * as ss from "simple-statistics"; + +export interface StatisticalSummary { + mean: number; + median: number; + mode: number | null; + standardDeviation: number; + variance: number; + min: number; + max: number; + range: number; + count: number; + sum: number; + quantiles: { + q1: number; + q3: number; + iqr: number; + }; +} + +export interface CorrelationResult { + coefficient: number; + strength: "very weak" | "weak" | "moderate" | "strong" | "very strong"; + direction: "positive" | "negative" | "none"; +} + +export interface RegressionResult { + slope: number; + intercept: number; + rSquared: number; + predict: (x: number) => number; +} + +export function descriptiveStats(data: number[]): StatisticalSummary { + if (data.length === 0) { + throw new Error("Cannot calculate statistics for empty dataset"); + } + + const mean = ss.mean(data); + const median = ss.median(data); + const standardDeviation = ss.standardDeviation(data); + const variance = ss.variance(data); + const min = ss.min(data); + const max = ss.max(data); + const sum = ss.sum(data); + const q1 = ss.quantile(data, 0.25); + const q3 = ss.quantile(data, 0.75); + + let mode: number | null = null; + try { + mode = ss.mode(data); + } catch { + mode = null; + } + + return { + mean, + median, + mode, + standardDeviation, + variance, + min, + max, + range: max - min, + count: data.length, + sum, + quantiles: { + q1, + q3, + iqr: q3 - q1, + }, + }; +} + +export function correlation(x: number[], y: number[]): CorrelationResult { + if (x.length !== y.length) { + throw new Error("Arrays must have the same length"); + } + if (x.length === 0) { + throw new Error("Cannot calculate correlation for empty datasets"); + } + + const coefficient = ss.sampleCorrelation(x, y); + const absCoeff = Math.abs(coefficient); + + let strength: CorrelationResult["strength"]; + if (absCoeff < 0.2) strength = "very weak"; + else if (absCoeff < 0.4) strength = "weak"; + else if (absCoeff < 0.6) strength = "moderate"; + else if (absCoeff < 0.8) strength = "strong"; + else strength = "very strong"; + + const direction = + coefficient > 0 ? "positive" : coefficient < 0 ? "negative" : "none"; + + return { + coefficient, + strength, + direction, + }; +} + +export function linearRegression(x: number[], y: number[]): RegressionResult { + if (x.length !== y.length) { + throw new Error("Arrays must have the same length"); + } + if (x.length < 2) { + throw new Error("Need at least 2 data points for regression"); + } + + const points = x.map((xi, i) => [xi, y[i]]); + const regression = ss.linearRegression(points); + const rSquared = ss.rSquared(points, ss.linearRegressionLine(regression)); + + return { + slope: regression.m, + intercept: regression.b, + rSquared, + predict: (xVal: number) => regression.m * xVal + regression.b, + }; +} + +export function zScore( + value: number, + mean: number, + standardDeviation: number, +): number { + if (standardDeviation === 0) { + throw new Error("Standard deviation cannot be zero"); + } + return (value - mean) / standardDeviation; +} + +export function percentile(data: number[], p: number): number { + if (p < 0 || p > 1) { + throw new Error("Percentile must be between 0 and 1"); + } + return ss.quantile(data, p); +} + +export function outliers( + data: number[], + method: "iqr" | "zscore" = "iqr", +): number[] { + if (data.length === 0) { + return []; + } + + if (method === "iqr") { + const q1 = ss.quantile(data, 0.25); + const q3 = ss.quantile(data, 0.75); + const iqr = q3 - q1; + const lowerBound = q1 - 1.5 * iqr; + const upperBound = q3 + 1.5 * iqr; + + return data.filter((value) => value < lowerBound || value > upperBound); + } + + if (method === "zscore") { + const mean = ss.mean(data); + const stdDev = ss.standardDeviation(data); + return data.filter((value) => Math.abs(zScore(value, mean, stdDev)) > 2); + } + + return []; +} + +export function movingAverage(data: number[], windowSize: number): number[] { + if (windowSize <= 0 || windowSize > data.length) { + throw new Error("Invalid window size"); + } + + const result: number[] = []; + for (let i = 0; i <= data.length - windowSize; i++) { + const window = data.slice(i, i + windowSize); + result.push(ss.mean(window)); + } + return result; +} + +export function histogram( + data: number[], + bins: number = 10, +): Array<{ bin: string; count: number; frequency: number }> { + if (data.length === 0) { + return []; + } + + const min = ss.min(data); + const max = ss.max(data); + const binWidth = (max - min) / bins; + const binCounts = new Array(bins).fill(0); + + data.forEach((value) => { + const binIndex = Math.min(Math.floor((value - min) / binWidth), bins - 1); + binCounts[binIndex]++; + }); + + return binCounts.map((count, index) => { + const binStart = min + index * binWidth; + const binEnd = binStart + binWidth; + return { + bin: `${binStart.toFixed(2)}-${binEnd.toFixed(2)}`, + count, + frequency: count / data.length, + }; + }); +} + +export function confidence( + data: number[], + level: number = 0.95, +): { mean: number; marginOfError: number; lower: number; upper: number } { + if (data.length === 0) { + throw new Error("Cannot calculate confidence interval for empty dataset"); + } + if (level <= 0 || level >= 1) { + throw new Error("Confidence level must be between 0 and 1"); + } + + const mean = ss.mean(data); + const standardError = ss.standardDeviation(data) / Math.sqrt(data.length); + const criticalValue = 1.96; + const marginOfError = criticalValue * standardError; + + return { + mean, + marginOfError, + lower: mean - marginOfError, + upper: mean + marginOfError, + }; +} + +export function tTest( + sample1: number[], + sample2: number[], +): { tStatistic: number; pValue: number; significant: boolean } { + if (sample1.length === 0 || sample2.length === 0) { + throw new Error("Cannot perform t-test on empty samples"); + } + + const mean1 = ss.mean(sample1); + const mean2 = ss.mean(sample2); + const var1 = ss.variance(sample1); + const var2 = ss.variance(sample2); + const n1 = sample1.length; + const n2 = sample2.length; + + const pooledVariance = ((n1 - 1) * var1 + (n2 - 1) * var2) / (n1 + n2 - 2); + const standardError = Math.sqrt(pooledVariance * (1 / n1 + 1 / n2)); + const tStatistic = (mean1 - mean2) / standardError; + + const pValue = + 2 * (1 - ss.cumulativeStdNormalProbability(Math.abs(tStatistic))); + const significant = pValue < 0.05; + + return { + tStatistic, + pValue, + significant, + }; +} diff --git a/app/models/activity.server.ts b/app/models/activity.server.ts index 2eda603..29f5aec 100644 --- a/app/models/activity.server.ts +++ b/app/models/activity.server.ts @@ -1,15 +1,143 @@ import type { DB } from "#~/db.server"; import db from "#~/db.server"; import { getOrFetchUser } from "#~/helpers/userInfoCache.js"; +import { fillDateGaps } from "#~/helpers/dateUtils"; +import { sql } from "kysely"; type MessageStats = DB["message_stats"]; +// // Default allowed channel categories for analytics +// const ALLOWED_CATEGORIES: string[] = [ +// "Need Help", +// "React General", +// "Advanced Topics", +// ]; + +// // Default allowed channels (currently empty but can be configured) +// const ALLOWED_CHANNELS: string[] = []; + +/** + * Creates a base query for message_stats filtered by guild, date range, and optionally user + */ +export function createMessageStatsQuery( + guildId: MessageStats["guild_id"], + start: string, + end: string, + userId?: MessageStats["author_id"], +) { + let query = db + .selectFrom("message_stats") + .where("guild_id", "=", guildId) + .where("sent_at", ">=", new Date(start).getTime()) + .where("sent_at", "<=", new Date(end + "T23:59:59").getTime()); + + if (userId) { + query = query.where("author_id", "=", userId); + } + + return query; +} + +/** + * Gets complete user message analytics using composed queries + */ +export async function getUserMessageAnalytics( + guildId: string, + userId: string, + start: string, + end: string, +) { + // Build daily stats query + const dailyQuery = createMessageStatsQuery(guildId, start, end, userId) + .select(({ fn, eb, lit }) => [ + fn.countAll().as("messages"), + fn.sum("word_count").as("word_count"), + fn.sum("react_count").as("react_count"), + fn("round", [fn.avg("word_count"), lit(3)]).as("avg_words"), + eb + .fn("date", [eb("sent_at", "/", lit(1000)), sql.lit("unixepoch")]) + .as("date"), + ]) + // .where((eb) => + // eb.or([ + // eb("channel_id", "in", ALLOWED_CHANNELS), + // eb("channel_category", "in", ALLOWED_CATEGORIES), + // ]), + // ) + .orderBy("date", "asc") + .groupBy("date"); + + // Build category stats query + const categoryQuery = createMessageStatsQuery(guildId, start, end, userId) + .select(({ fn }) => [ + fn.count("channel_category").as("messages"), + "channel_category", + ]) + // .where((eb) => + // eb.or([ + // eb("channel_id", "in", ALLOWED_CHANNELS), + // eb("channel_category", "in", ALLOWED_CATEGORIES), + // ]), + // ) + .groupBy("channel_category"); + + // Build channel stats query + const channelQuery = createMessageStatsQuery(guildId, start, end, userId) + // @ts-expect-error - Kysely selector typing is complex + .select(({ fn }) => [ + fn.count("channel_id").as("messages"), + "channel_id", + "channel.name", + ]) + .leftJoin( + "channel_info as channel", + "channel.id", + "message_stats.channel_id", + ) + // .where((eb) => + // eb.or([ + // eb("channel_id", "in", ALLOWED_CHANNELS), + // eb("channel_category", "in", ALLOWED_CATEGORIES), + // ]), + // ) + .orderBy("messages", "desc") + .groupBy("channel_id"); + + const [dailyResults, categoryBreakdown, channelBreakdown, userInfo] = + await Promise.all([ + dailyQuery.execute(), + categoryQuery.execute(), + channelQuery.execute(), + getOrFetchUser(userId), + ]); + + type DailyBreakdown = { + messages: number; + word_count: number; + react_count: number; + avg_words: number; + date: string; + }; + // Only daily breakdown needs date gap filling + const dailyBreakdown = fillDateGaps( + dailyResults as DailyBreakdown[], + start, + end, + { + messages: 0, + word_count: 0, + react_count: 0, + avg_words: 0, + }, + ); + + return { dailyBreakdown, categoryBreakdown, channelBreakdown, userInfo }; +} + export async function getTopParticipants( guildId: MessageStats["guild_id"], intervalStart: string, intervalEnd: string, - channels: string[], - channelCategories: string[], ) { const config = { count: 100, @@ -17,29 +145,25 @@ export async function getTopParticipants( wordThreshold: 2200, }; - const baseQuery = db - .selectFrom("message_stats") + const baseQuery = createMessageStatsQuery(guildId, intervalStart, intervalEnd) .selectAll() - .select(({ fn, val, eb }) => [ - fn("date", [eb("sent_at", "/", 1000), val("unixepoch")]).as("date"), - ]) - .where("guild_id", "=", guildId) - .where(({ between, and, or, eb }) => - and([ - between( - "sent_at", - new Date(intervalStart).getTime(), - new Date(intervalEnd).getTime(), - ), - or([ - eb("channel_id", "in", channels), - eb("channel_category", "in", channelCategories), - ]), - ]), - ); + .select(({ fn, eb, lit }) => [ + fn("date", [eb("sent_at", "/", lit(1000)), sql.lit("unixepoch")]).as( + "date", + ), + ]); - // get shortlist, volume threshold of 1000 words + // Apply channel filtering inline + // const filteredQuery = baseQuery.where((eb) => + // eb.or([ + // eb("channel_id", "in", ALLOWED_CHANNELS), + // eb("channel_category", "in", ALLOWED_CATEGORIES), + // ]), + // ); + + // get shortlist using inline selectors const topMembersQuery = db + // .with("interval_message_stats", () => filteredQuery) .with("interval_message_stats", () => baseQuery) .selectFrom("interval_message_stats") .select(({ fn }) => [ @@ -54,8 +178,8 @@ export async function getTopParticipants( .groupBy("author_id") .having(({ eb, or, fn }) => or([ - eb(fn.count("author_id"), ">=", config.messageThreshold), - eb(fn.sum("word_count"), ">=", config.wordThreshold), + eb(fn.count("author_id"), ">=", config.messageThreshold), + eb(fn.sum("word_count"), ">=", config.wordThreshold), ]), ) .limit(config.count); @@ -63,6 +187,7 @@ export async function getTopParticipants( const topMembers = await topMembersQuery.execute(); const dailyParticipationQuery = db + // .with("interval_message_stats", () => filteredQuery) .with("interval_message_stats", () => baseQuery) .selectFrom("interval_message_stats") .select(({ fn }) => [ @@ -82,15 +207,48 @@ export async function getTopParticipants( topMembers.map((m) => m.author_id), ); console.log(dailyParticipationQuery.compile().sql); - const dailyParticipation = fillDateGaps( - groupByAuthor(await dailyParticipationQuery.execute()), - intervalStart, - intervalEnd, - ); + const rawDailyParticipation = await dailyParticipationQuery.execute(); + // Group by author and fill date gaps inline + const groupedData = rawDailyParticipation.reduce((acc, record) => { + const { author_id, date } = record; + if (!acc[author_id]) acc[author_id] = []; + acc[author_id].push({ ...record, date: date as string }); + return acc; + }, {} as GroupedResult); - const scores = topMembers.map((m) => - scoreMember(m, dailyParticipation[m.author_id]), - ); + const dailyParticipation: GroupedResult = {}; + for (const authorId in groupedData) { + dailyParticipation[authorId] = fillDateGaps( + groupedData[authorId], + intervalStart, + intervalEnd, + { message_count: 0, word_count: 0, channel_count: 0, category_count: 0 }, + ); + } + + const scores = topMembers.map((m) => { + const member = m as MemberData; + const participation = dailyParticipation[member.author_id]; + const categoryCounts = participation + .map((p) => p.category_count) + .sort((a, b) => a - b); + const zeroDays = participation.filter((p) => p.message_count === 0).length; + + return { + score: { + channelScore: scoreValue(member.channel_count, scoreLookups.channels), + messageScore: scoreValue(member.message_count, scoreLookups.messages), + wordScore: scoreValue(member.total_word_count, scoreLookups.words), + consistencyScore: Math.ceil( + categoryCounts[Math.floor(categoryCounts.length / 2)], + ), + }, + metadata: { + percentZeroDays: zeroDays / participation.length, + }, + data: { participation, member }, + }; + }); const withUsernames = await Promise.all( scores.map(async (scores) => { @@ -116,94 +274,18 @@ type MemberData = { category_count: number; channel_count: number; }; -function isBetween(test: number, a: number, b: number) { - return test >= a && test < b; -} -function scoreValue(test: number, lookup: [number, number][], x?: string) { - return lookup.reduce((score, _, i, list) => { - const check = isBetween( - test, - list[i][0] ?? Infinity, - list[i + 1]?.[0] ?? Infinity, - ); - if (check && x) - console.log( - test, - "is between", - list[i][0], - "and", - list[i + 1]?.[0] ?? Infinity, - "scoring", - list[i][1], - ); - return check ? list[i][1] : score; +function scoreValue(test: number, lookup: [number, number][]) { + return lookup.reduce((score, [min, value], i, list) => { + const max = list[i + 1]?.[0] ?? Infinity; + return test >= min && test < max ? value : score; }, 0); } -function median(list: number[]) { - const mid = list.length / 2; - return list.length % 2 === 1 - ? (list[Math.floor(mid)] + list[Math.ceil(mid)]) / 2 - : list[mid]; -} +// prettier-ignore const scoreLookups = { - words: [ - [0, 0], - [2000, 1], - [5000, 2], - [7500, 3], - [20000, 4], - ], - messages: [ - [0, 0], - [150, 1], - [350, 2], - [800, 3], - [1500, 4], - ], - channels: [ - [0, 0], - [3, 1], - [7, 2], - [9, 3], - ], -} as Record; -function scoreMember(member: MemberData, participation: ParticipationData[]) { - return { - score: { - channelScore: scoreValue(member.channel_count, scoreLookups.channels), - messageScore: scoreValue(member.message_count, scoreLookups.messages), - wordScore: scoreValue( - member.total_word_count, - scoreLookups.words, - "words", - ), - consistencyScore: Math.ceil( - median(participation.map((p) => p.category_count)), - ), - }, - metadata: { - percentZeroDays: - participation.reduce( - (count, val) => (val.message_count === 0 ? count + 1 : count), - 0, - ) / participation.length, - }, - data: { - participation, - member, - }, - }; -} - -type RawParticipationData = { - author_id: string; - // hack fix for weird types coming out of query - date: string | unknown; - message_count: number; - word_count: number; - channel_count: number; - category_count: number; -}; + words: [ [0, 0], [2000, 1], [5000, 2], [7500, 3], [20000, 4], ], + messages: [ [0, 0], [150, 1], [350, 2], [800, 3], [1500, 4], ], + channels: [ [0, 0], [3, 1], [7, 2], [9, 3], ], +} as { words: [number, number][]; messages: [number, number][]; channels: [number, number][] }; type ParticipationData = { date: string; @@ -214,66 +296,3 @@ type ParticipationData = { }; type GroupedResult = Record; - -function groupByAuthor(records: RawParticipationData[]): GroupedResult { - return records.reduce((acc, record) => { - const { author_id, date } = record; - - if (!acc[author_id]) { - acc[author_id] = []; - } - - // hack fix for weird types coming out of query - acc[author_id].push({ ...record, date: date as string }); - - return acc; - }, {} as GroupedResult); -} - -const generateDateRange = (start: string, end: string): string[] => { - const dates: string[] = []; - const currentDate = new Date(start); - - while (currentDate <= new Date(end)) { - dates.push(currentDate.toISOString().split("T")[0]); - currentDate.setDate(currentDate.getDate() + 1); - } - return dates; -}; - -function fillDateGaps( - groupedResult: GroupedResult, - startDate: string, - endDate: string, -): GroupedResult { - // Helper to generate a date range in YYYY-MM-DD format - - const dateRange = generateDateRange(startDate, endDate); - - const filledResult: GroupedResult = {}; - - for (const authorId in groupedResult) { - const authorData = groupedResult[authorId]; - const dateToEntryMap: Record = {}; - - // Map existing entries by date - authorData.forEach((entry) => { - dateToEntryMap[entry.date] = entry; - }); - - // Fill missing dates with zeroed-out data - filledResult[authorId] = dateRange.map((date) => { - return ( - dateToEntryMap[date] || { - date, - message_count: 0, - word_count: 0, - channel_count: 0, - category_count: 0, - } - ); - }); - } - - return filledResult; -} diff --git a/app/routes/__auth/dashboard.tsx b/app/routes/__auth/dashboard.tsx index 8e8b17e..4c43508 100644 --- a/app/routes/__auth/dashboard.tsx +++ b/app/routes/__auth/dashboard.tsx @@ -14,13 +14,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { return data(null, { status: 400 }); } - const output = await getTopParticipants( - guildId, - start, - end, - [], - ["Need Help", "React General", "Advanced Topics"], - ); + const output = await getTopParticipants(guildId, start, end); return output; } diff --git a/app/routes/__auth/sh-user.tsx b/app/routes/__auth/sh-user.tsx index ec430ff..df9870b 100644 --- a/app/routes/__auth/sh-user.tsx +++ b/app/routes/__auth/sh-user.tsx @@ -1,5 +1,4 @@ import type { Route } from "./+types/sh-user"; -import db from "#~/db.server"; import { type LoaderFunctionArgs, Link, useSearchParams } from "react-router"; import { ComposedChart, @@ -20,9 +19,7 @@ import { Radar, } from "recharts"; import { useMemo } from "react"; -import { sql } from "kysely"; -import { getOrFetchUser } from "#~/helpers/userInfoCache"; -import { fillDateGaps } from "#~/helpers/dateUtils"; +import { getUserMessageAnalytics } from "#~/models/activity.server"; export async function loader({ request, params }: LoaderFunctionArgs) { const { guildId, userId } = params; @@ -34,90 +31,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const start = url.searchParams.get("start"); const end = url.searchParams.get("end"); - // TODO: this should be configurable - // const allowedChannels: string[] = []; - // const allowedCategories = ["Need Help", "React General", "Advanced Topics"]; if (!start || !end) { throw new Error("cannot load data without start and end range"); } - const reportSlice = db - .selectFrom("message_stats") - .where("guild_id", "=", guildId) - .where("author_id", "=", userId) - .where("sent_at", ">=", new Date(start).getTime()) - .where("sent_at", "<=", new Date(end + "T23:59:59").getTime()); - - const dailyBreakdownQuery = reportSlice - .select((eb) => [ - eb.fn.countAll().as("messages"), - eb.fn.sum("word_count").as("word_count"), - eb.fn.sum("react_count").as("react_count"), - eb - .fn("round", [eb.fn.avg("word_count"), eb.lit(3)]) - .as("avg_words"), - eb - .fn("date", [ - eb("sent_at", "/", eb.lit(1000)), - sql.lit("unixepoch"), - ]) - .as("date"), - ]) - // .where((eb) => - // eb.or([ - // eb("channel_id", "in", allowedChannels), - // eb("channel_category", "in", allowedCategories), - // ]), - // ) - .orderBy("date", "asc") - .groupBy("date"); - - const categoryBreakdownQuery = reportSlice - .select((eb) => [ - eb.fn.count("channel_category").as("messages"), - "channel_category", - ]) - // .orderBy("messages", "desc") - .groupBy("channel_category"); - - const channelBreakdownQuery = reportSlice - .leftJoin( - "channel_info as channel", - "channel.id", - "message_stats.channel_id", - ) - .select((eb) => [ - eb.fn.count("channel_id").as("messages"), - "channel.name", - "channel_id", - ]) - .orderBy("messages", "desc") - .groupBy("channel_id"); - - const [dailyBreakdown, categoryBreakdown, channelBreakdown, userInfo] = - await Promise.all([ - dailyBreakdownQuery.execute(), - categoryBreakdownQuery.execute(), - channelBreakdownQuery.execute(), - getOrFetchUser(userId), - ]); - - // Fill date gaps in daily breakdown data with zero values - const filledDailyBreakdown = fillDateGaps(dailyBreakdown, start, end, { - messages: 0, - word_count: 0, - react_count: 0, - avg_words: 0, - }); - - return { - dailyBreakdown: filledDailyBreakdown, - categoryBreakdown, - channelBreakdown, - userInfo, - }; + // Use shared analytics function with channel filtering disabled for user view + return await getUserMessageAnalytics(guildId, userId, start, end); } +const num = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, + minimumFractionDigits: 0, +}); + export default function UserProfile({ params, loaderData: data, @@ -127,168 +53,193 @@ export default function UserProfile({ const end = qs.get("end"); const derivedData = useMemo(() => { - const totalMessages = data.categoryBreakdown.reduce( - (a, c) => a + Number(c.messages), - 0, - ); - const totalReactions = data.dailyBreakdown.reduce( - (a, c) => a + Number(c.react_count), - 0, - ); - const totalWords = data.dailyBreakdown.reduce( - (a, c) => a + Number(c.word_count), - 0, - ); - return { totalMessages, totalWords, totalReactions }; - }, [data]); + // Calculate days between start and end dates + const startDate = start ? new Date(start) : null; + const endDate = end ? new Date(end) : null; + const daysBetween = + startDate && endDate + ? Math.ceil( + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + ) + 1 + : 0; + + return { + totalMessages: data.categoryBreakdown.reduce( + (a, c) => a + Number(c.messages), + 0, + ), + totalReactions: data.dailyBreakdown.reduce( + (a, c) => a + Number(c.react_count), + 0, + ), + totalWords: data.dailyBreakdown.reduce( + (a, c) => a + Number(c.word_count), + 0, + ), + daysBetween, + }; + }, [data, start, end]); return ( -
+ <> -
-

- {data.userInfo?.username} -

- {data.userInfo?.global_name && - data.userInfo?.global_name !== data.userInfo?.username && ( -
- ({data.userInfo?.global_name}) -
- )} +text { + fill: #ccc; +} +.recharts-default-tooltip { + background-color: rgb(55,65,81) !important; +}`} + +
- โ† Back to Dashboard + โ† Dashboard -
- raw data -