-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
6 changed files
with
517 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.