Skip to content

Commit

Permalink
Member activity reports (#93)
Browse files Browse the repository at this point in the history
This is an early draft PR of a first revision of a feature to support
the Reactiflux Star Helper program — tho it will be useful for other
types of roles as well! That's the whole point of automating this.

<img width="567" alt="Screenshot 2024-12-16 at 2 15 19 AM"
src="https://github.com/user-attachments/assets/de4e3cad-daba-44eb-9539-78e0bebe1b22"
/>

This is ~ a proof of concept to get a query together for examining the
data, which it accomplishes. There's a lot lot lot of supporting code
needed to turn this into "a feature", my intentions with this PR are to
get enough code together to support the 2024 Q4 Star Helper program,
then productize in the next few months.

Some examples of work that will need to be done before this is a
production feature:

- Add configurable channels/categories, and intervals
- Flexible scoring criteria
- Clearer details around data collection (privacy policy — I believe
we're very light here, as we don't store any content, just metadata)
- Inline scoring/member review? (I don't think it's feasible to pull in
user history here, as I don't want to store it and Discord doesn't make
it convenient to retrieve)
- Exploration into the best reporting options (inline discord messages
from the bot? attached PDFs/PNGs? web app?)
  • Loading branch information
vcarl authored Dec 20, 2024
1 parent b7f34d7 commit 954d75e
Show file tree
Hide file tree
Showing 6 changed files with 517 additions and 19 deletions.
261 changes: 261 additions & 0 deletions app/models/activity.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import type { DB } from "~/db.server";
import db from "~/db.server";

type MessageStats = DB["message_stats"];

export async function getTopParticipants(
guildId: MessageStats["guild_id"],
intervalStart: string,
intervalEnd: string,
channels: string[],
channelCategories: string[],
) {
const config = {
count: 100,
messageThreshold: 250,
wordThreshold: 2200,
};

const baseQuery = db
.selectFrom("message_stats")
.selectAll()
.select(({ fn, val, eb }) => [
fn("date", [eb("sent_at", "/", "1000"), val("unixepoch")]).as("date"),
])
.where(({ between, and, or, eb }) =>
and([
between(
"sent_at",
new Date(intervalStart).getTime().toString(),
new Date(intervalEnd).getTime().toString(),
),
or([
eb("channel_id", "in", channels),
eb("channel_category", "in", channelCategories),
]),
]),
);

// get shortlist, volume threshold of 1000 words
const topMembersQuery = db
.with("interval_message_stats", () => baseQuery)
.selectFrom("interval_message_stats")
.select(({ fn }) => [
"author_id",
fn.sum<number>("word_count").as("total_word_count"),
fn.count<number>("author_id").as("message_count"),
fn.sum<number>("react_count").as("total_reaction_count"),
fn.count<number>("channel_category").distinct().as("category_count"),
fn.count<number>("channel_id").distinct().as("channel_count"),
])
.orderBy("message_count desc")
.groupBy("author_id")
.having(({ eb, or, fn }) =>
or([
eb(fn.count("author_id"), ">=", config.messageThreshold),
eb(fn.sum("word_count"), ">=", config.wordThreshold),
]),
)
.limit(config.count);
console.log(topMembersQuery.compile().sql);
const topMembers = await topMembersQuery.execute();

const dailyParticipationQuery = db
.with("interval_message_stats", () => baseQuery)
.selectFrom("interval_message_stats")
.select(({ fn }) => [
"author_id",
"date",
fn.count<number>("author_id").as("message_count"),
fn.sum<number>("word_count").as("word_count"),
fn.count<number>("channel_id").distinct().as("channel_count"),
fn.count<number>("channel_category").distinct().as("category_count"),
])
.distinct()
.groupBy("date")
.groupBy("author_id")
.where(
"author_id",
"in",
topMembers.map((m) => m.author_id),
);
console.log(dailyParticipationQuery.compile().sql);
const dailyParticipation = fillDateGaps(
groupByAuthor(await dailyParticipationQuery.execute()),
intervalStart,
intervalEnd,
);

return topMembers.map((m) => scoreMember(m, dailyParticipation[m.author_id]));
}

// copy-pasted out of TopMembers query result
type MemberData = {
author_id: string;
total_word_count: number;
message_count: number;
total_reaction_count: number;
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;
}, 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];
}
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<string, [number, number][]>;
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;
};

type ParticipationData = {
date: string;
message_count: number;
word_count: number;
channel_count: number;
category_count: number;
};

type GroupedResult = Record<string, ParticipationData[]>;

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[] = [];
let 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<string, typeof authorData[number]> = {};

// 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;
}
5 changes: 5 additions & 0 deletions app/routes/__auth.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Outlet, useLocation } from "@remix-run/react";

import { Login } from "~/components/login";
import { isProd } from "~/helpers/env";
import { getUser } from "~/models/session.server";
import { useOptionalUser } from "~/utils";

Expand All @@ -12,6 +13,10 @@ export default function Auth() {
const user = useOptionalUser();
const location = useLocation();

if (isProd()) {
return <div>nope</div>;
}

if (!user) {
return (
<div className="flex min-h-full flex-col justify-center">
Expand Down
114 changes: 114 additions & 0 deletions app/routes/__auth/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { LoaderArgs, ActionFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { LabelHTMLAttributes } from "react";
import { getTopParticipants } from "~/models/activity.server";

export const loader = async ({ request, context, params }: LoaderArgs) => {
// const user = await getUser(request);
const url = new URL(request.url);
const start = url.searchParams.get("start");
const end = url.searchParams.get("end");

if (!start || !end) {
return json(null, { status: 400 });
}

const REACTIFLUX_GUILD_ID = "102860784329052160";
const output = await getTopParticipants(
REACTIFLUX_GUILD_ID,
start,
end,
[],
["Need Help", "React General", "Advanced Topics"],
);

return json(output);
};

export const action: ActionFunction = async ({ request }) => {
console.log({ request });
};

const Label = (props: LabelHTMLAttributes<Element>) => (
<label {...props} className={`${props.className ?? ""} m-4`}>
{props.children}
</label>
);

const formatter = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 0,
});

export default function DashboardPage() {
const data = useLoaderData<typeof loader>();

if (!data) {
return "loading…";
}

return (
<div>
<div className="flex min-h-full justify-center">
<div>test butts</div>
<form method="GET">
<Label>
Start date
<input name="start" type="date" />
</Label>
<Label>
End date
<input name="end" type="date" />
</Label>
<input type="submit" value="Submit" />
</form>
</div>
<div>
<textarea>
{`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) =>
`${d.data.member.author_id},${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}`,
)
.join("\n")}`}
</textarea>
<table>
<thead>
<tr>
<th>Author ID</th>
<th>Percent Zero Days</th>
<th>Word Count</th>
<th>Message Count</th>
<th>Channel Count</th>
<th>Category Count</th>
<th>Reaction Count</th>
<th>Word Score</th>
<th>Message Score</th>
<th>Channel Score</th>
<th>Consistency Score</th>
</tr>
</thead>
<tbody>
{data.map((d) => (
<tr key={d.data.member.author_id}>
<td>{d.data.member.author_id}</td>
<td>{formatter.format(d.metadata.percentZeroDays)}</td>
<td>{d.data.member.total_word_count}</td>
<td>{d.data.member.message_count}</td>
<td>{d.data.member.channel_count}</td>
<td>{d.data.member.category_count}</td>
<td>{d.data.member.total_reaction_count}</td>
<td>{d.score.wordScore}</td>
<td>{d.score.messageScore}</td>
<td>{d.score.channelScore}</td>
<td>{d.score.consistencyScore}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
Loading

0 comments on commit 954d75e

Please sign in to comment.