Skip to content

Commit 4b2e9a0

Browse files
authored
Starhunter: User details page and feature scaffolding (#139)
This adds a new user details page with information about a member's participation over a specified time interval <img width="580" alt="Screenshot 2025-06-26 at 1 55 03 PM" src="https://github.com/user-attachments/assets/f5da854c-7329-4b3d-bba2-6877617f0408" /> This is a rough pass, proving out charts and data wiring. Charts are likely to change, and layout/etc is very simple. The "member list" navigation here is present but minimal, and raw JSON is exposed for debug purposes.
1 parent c8b9b66 commit 4b2e9a0

File tree

7 files changed

+717
-20
lines changed

7 files changed

+717
-20
lines changed

app/helpers/userInfoCache.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { LRUCache } from "lru-cache";
2+
import { rest } from "#~/discord/api.js";
3+
import { Routes } from "discord.js";
4+
5+
type DiscordUser = { username: string; global_name: string };
6+
7+
const cache = new LRUCache<string, DiscordUser>({
8+
ttl: 1000 * 60 * 60 * 24 * 14, // 14 days
9+
ttlAutopurge: true,
10+
max: 150,
11+
});
12+
13+
export async function getOrFetchUser(id: string) {
14+
if (cache.has(id)) return cache.get(id);
15+
16+
console.log("Fetching user from Discord API:", id);
17+
const { username, global_name } = (await rest.get(
18+
Routes.user(id),
19+
)) as DiscordUser;
20+
const result = { username, global_name };
21+
cache.set(id, result);
22+
return result;
23+
}

app/models/activity.server.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DB } from "#~/db.server";
22
import db from "#~/db.server";
3+
import { getOrFetchUser } from "#~/helpers/userInfoCache.js";
34

45
type MessageStats = DB["message_stats"];
56

@@ -86,7 +87,23 @@ export async function getTopParticipants(
8687
intervalEnd,
8788
);
8889

89-
return topMembers.map((m) => scoreMember(m, dailyParticipation[m.author_id]));
90+
const scores = topMembers.map((m) =>
91+
scoreMember(m, dailyParticipation[m.author_id]),
92+
);
93+
94+
const withUsernames = await Promise.all(
95+
scores.map(async (scores) => {
96+
const user = await getOrFetchUser(scores.data.member.author_id);
97+
return {
98+
...scores,
99+
data: {
100+
...scores.data,
101+
member: { ...scores.data.member, username: user?.global_name },
102+
},
103+
};
104+
}),
105+
);
106+
return withUsernames;
90107
}
91108

92109
// copy-pasted out of TopMembers query result

app/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { route, layout } from "@react-router/dev/routes";
33

44
export default [
55
layout("routes/__auth.tsx", [
6-
route("dashboard", "routes/__auth/dashboard.tsx"),
6+
route(":guildId/sh", "routes/__auth/dashboard.tsx"),
7+
route(":guildId/sh/:userId", "routes/__auth/sh-user.tsx"),
78
route("login", "routes/__auth/login.tsx"),
89
route("test", "routes/__auth/test.tsx"),
910
]),

app/routes/__auth/dashboard.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import type { Route } from "./+types/dashboard";
2-
import { data, useNavigation } from "react-router";
2+
import { data, useNavigation, useSearchParams, Link } from "react-router";
33
import type { LabelHTMLAttributes, PropsWithChildren } from "react";
44
import { getTopParticipants } from "#~/models/activity.server";
55

6-
export async function loader({ request }: Route.LoaderArgs) {
6+
export async function loader({ params, request }: Route.LoaderArgs) {
77
// const user = await getUser(request);
88
const url = new URL(request.url);
99
const start = url.searchParams.get("start");
1010
const end = url.searchParams.get("end");
11+
const guildId = params.guildId;
1112

12-
if (!start || !end) {
13+
if (!(guildId && start && end)) {
1314
return data(null, { status: 400 });
1415
}
1516

16-
const REACTIFLUX_GUILD_ID = "102860784329052160";
17-
1817
const output = await getTopParticipants(
19-
REACTIFLUX_GUILD_ID,
18+
guildId,
2019
start,
2120
end,
2221
[],
@@ -37,16 +36,16 @@ const percent = new Intl.NumberFormat("en-US", {
3736
maximumFractionDigits: 0,
3837
}).format;
3938

40-
function RangeForm() {
39+
function RangeForm({ values }: { values: { start?: string; end?: string } }) {
4140
return (
4241
<form method="GET">
4342
<Label>
4443
Start date
45-
<input name="start" type="date" />
44+
<input name="start" type="date" defaultValue={values.start} />
4645
</Label>
4746
<Label>
4847
End date
49-
<input name="end" type="date" />
48+
<input name="end" type="date" defaultValue={values.end} />
5049
</Label>
5150
<input type="submit" value="Submit" />
5251
</form>
@@ -65,16 +64,20 @@ export default function DashboardPage({
6564
loaderData: data,
6665
}: Route.ComponentProps) {
6766
const nav = useNavigation();
67+
const [qs] = useSearchParams();
6868

6969
if (nav.state === "loading") {
7070
return "loading…";
7171
}
7272

73+
const start = qs.get("start") ?? undefined;
74+
const end = qs.get("end") ?? undefined;
75+
7376
if (!data) {
7477
return (
7578
<div>
7679
<div className="flex min-h-full justify-center">
77-
<RangeForm />
80+
<RangeForm values={{ start, end }} />
7881
</div>
7982
<div></div>
8083
</div>
@@ -84,7 +87,7 @@ export default function DashboardPage({
8487
return (
8588
<div>
8689
<div className="flex min-h-full justify-center">
87-
<RangeForm />
90+
<RangeForm values={{ start, end }} />
8891
</div>
8992
<div>
9093
<textarea
@@ -115,7 +118,16 @@ ${data
115118
<tbody>
116119
{data.map((d) => (
117120
<tr key={d.data.member.author_id}>
118-
<td>{d.data.member.author_id}</td>
121+
<td>
122+
<Link
123+
to={{
124+
pathname: d.data.member.author_id,
125+
search: `?start=${start}&end=${end}`,
126+
}}
127+
>
128+
{d.data.member.username || d.data.member.author_id}
129+
</Link>
130+
</td>
119131
<td>{percent(d.metadata.percentZeroDays)}</td>
120132
<td>{d.data.member.total_word_count}</td>
121133
<td>{d.data.member.message_count}</td>

0 commit comments

Comments
 (0)