feat: implement real-time leaderboard system with XP and voice channel tracking features
This commit is contained in:
@@ -51,7 +51,10 @@ function getLeaderboardDatabase() {
|
||||
return drizzle(globalForLeaderboard.erikaLeaderboardReadOnlyClient);
|
||||
}
|
||||
|
||||
export async function getXpLeaderboardPage(requestedPage: number) {
|
||||
export async function getXpLeaderboardPage(
|
||||
requestedPage: number,
|
||||
searchQuery?: string,
|
||||
) {
|
||||
const rows = await getLeaderboardDatabase()
|
||||
.select({
|
||||
userId: userLevels.userId,
|
||||
@@ -63,20 +66,21 @@ export async function getXpLeaderboardPage(requestedPage: number) {
|
||||
.from(userLevels)
|
||||
.where(gte(userLevels.level, 1));
|
||||
|
||||
return buildXpLeaderboard(rows, requestedPage);
|
||||
return buildXpLeaderboard(rows, requestedPage, searchQuery);
|
||||
}
|
||||
|
||||
export async function getVoiceLeaderboardPage(
|
||||
currentChannelIds: string[],
|
||||
requestedPage: number,
|
||||
selectedChannelId?: string,
|
||||
searchQuery?: string,
|
||||
) {
|
||||
const scopedChannelIds = selectedChannelId
|
||||
? [selectedChannelId]
|
||||
: currentChannelIds;
|
||||
|
||||
if (scopedChannelIds.length === 0) {
|
||||
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId);
|
||||
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId, searchQuery);
|
||||
}
|
||||
|
||||
const rows: VoiceSourceRow[] = await getLeaderboardDatabase()
|
||||
@@ -101,6 +105,7 @@ export async function getVoiceLeaderboardPage(
|
||||
currentChannelIds,
|
||||
requestedPage,
|
||||
selectedChannelId,
|
||||
searchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,19 @@ describe("XP leaderboard", () => {
|
||||
|
||||
expect(result.rows.map((row) => row.userId)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
test("searches identities while preserving overall ranks", () => {
|
||||
const result = buildXpLeaderboard([
|
||||
xpRow("top", "1000", 4, "Alpha"),
|
||||
xpRow("target", "900", 3, "Beta"),
|
||||
xpRow("third", "800", 2, "Gamma"),
|
||||
], 1, "target-user");
|
||||
|
||||
expect(result.totalRows).toBe(1);
|
||||
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
|
||||
["target", 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice leaderboard", () => {
|
||||
@@ -131,6 +144,20 @@ describe("voice leaderboard", () => {
|
||||
expect(unresolved?.username).toBeNull();
|
||||
expect(shortenDiscordId("123456789012345678")).toBe("12345…5678");
|
||||
});
|
||||
|
||||
test("searches aggregated voice rows", () => {
|
||||
const result = buildVoiceLeaderboard(
|
||||
rows,
|
||||
["voice-1", "voice-2"],
|
||||
1,
|
||||
undefined,
|
||||
"missing",
|
||||
);
|
||||
|
||||
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
|
||||
["missing", 3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaderboard formatting", () => {
|
||||
|
||||
@@ -66,9 +66,23 @@ function compareIdentities(a: LeaderboardIdentity, b: LeaderboardIdentity) {
|
||||
return byName || a.userId.localeCompare(b.userId);
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string | undefined) {
|
||||
return value?.trim().toLocaleLowerCase("th-TH") ?? "";
|
||||
}
|
||||
|
||||
function matchesSearch(identity: LeaderboardIdentity, searchQuery: string) {
|
||||
if (!searchQuery) return true;
|
||||
return [
|
||||
identity.displayName,
|
||||
identity.username,
|
||||
identity.userId,
|
||||
].some((value) => value?.toLocaleLowerCase("th-TH").includes(searchQuery));
|
||||
}
|
||||
|
||||
function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
|
||||
sourceRows: T[],
|
||||
requestedPage: number,
|
||||
searchQuery?: string,
|
||||
): LeaderboardPage<T & { rank: number }> {
|
||||
const sorted = [...sourceRows].sort((a, b) => {
|
||||
const scoreA = asBigInt(a.score);
|
||||
@@ -87,14 +101,19 @@ function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
|
||||
return { ...row, rank: sharedRank };
|
||||
});
|
||||
|
||||
const totalRows = ranked.length;
|
||||
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
||||
const filtered = normalizedSearchQuery
|
||||
? ranked.filter((row) => matchesSearch(row, normalizedSearchQuery))
|
||||
: ranked;
|
||||
|
||||
const totalRows = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / LEADERBOARD_PAGE_SIZE));
|
||||
const page = Math.min(Math.max(1, requestedPage), totalPages);
|
||||
const start = (page - 1) * LEADERBOARD_PAGE_SIZE;
|
||||
|
||||
return {
|
||||
rows: ranked.slice(start, start + LEADERBOARD_PAGE_SIZE),
|
||||
podium: ranked.slice(0, 3),
|
||||
rows: filtered.slice(start, start + LEADERBOARD_PAGE_SIZE),
|
||||
podium: filtered.slice(0, 3),
|
||||
page,
|
||||
pageSize: LEADERBOARD_PAGE_SIZE,
|
||||
totalRows,
|
||||
@@ -105,6 +124,7 @@ function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
|
||||
export function buildXpLeaderboard(
|
||||
rows: XpSourceRow[],
|
||||
requestedPage: number,
|
||||
searchQuery?: string,
|
||||
): LeaderboardPage<XpLeaderboardRow> {
|
||||
return rankAndPaginate(
|
||||
rows
|
||||
@@ -115,6 +135,7 @@ export function buildXpLeaderboard(
|
||||
score: row.totalXp,
|
||||
})),
|
||||
requestedPage,
|
||||
searchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +144,7 @@ export function buildVoiceLeaderboard(
|
||||
currentChannelIds: string[],
|
||||
requestedPage: number,
|
||||
selectedChannelId?: string,
|
||||
searchQuery?: string,
|
||||
): LeaderboardPage<VoiceLeaderboardRow> {
|
||||
const allowedChannels = new Set(
|
||||
selectedChannelId ? [selectedChannelId] : currentChannelIds,
|
||||
@@ -152,6 +174,7 @@ export function buildVoiceLeaderboard(
|
||||
score: row.totalSeconds.toString(),
|
||||
})),
|
||||
requestedPage,
|
||||
searchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,6 +184,11 @@ export function normalizeRequestedPage(value: string | string[] | undefined) {
|
||||
return Number.isSafeInteger(page) ? page : 1;
|
||||
}
|
||||
|
||||
export function normalizeLeaderboardSearch(value: string | string[] | undefined) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().slice(0, 80);
|
||||
}
|
||||
|
||||
export function shortenDiscordId(userId: string) {
|
||||
return userId.length > 10
|
||||
? `${userId.slice(0, 5)}…${userId.slice(-4)}`
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
answersByTemplateIndex,
|
||||
normalizeSubmissionAnswers,
|
||||
submissionUpdateText,
|
||||
type SubmissionQuestion,
|
||||
} from "@/lib/submission-intake";
|
||||
|
||||
const questions: SubmissionQuestion[] = [
|
||||
{
|
||||
id: "q-name",
|
||||
type: "text",
|
||||
label: "<b>Name</b>",
|
||||
required: true,
|
||||
options: [],
|
||||
allowOther: false,
|
||||
},
|
||||
{
|
||||
id: "q-skill",
|
||||
type: "radio",
|
||||
label: "Skill",
|
||||
required: true,
|
||||
options: ["Build", "Fight"],
|
||||
allowOther: true,
|
||||
},
|
||||
{
|
||||
id: "q-tags",
|
||||
type: "checkbox",
|
||||
label: "Tags",
|
||||
required: false,
|
||||
options: ["Alpha", "Beta"],
|
||||
allowOther: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe("Submission intake", () => {
|
||||
test("normalizes answers into stored and display values", () => {
|
||||
const normalized = normalizeSubmissionAnswers(questions, [
|
||||
{ questionId: "q-name", value: "Erika" },
|
||||
{ questionId: "q-skill", value: "Custom skill" },
|
||||
{ questionId: "q-tags", value: ["Alpha", "Gamma"] },
|
||||
]);
|
||||
|
||||
expect(normalized).toEqual([
|
||||
{ questionId: "q-name", value: "Erika", displayValue: "Erika" },
|
||||
{ questionId: "q-skill", value: "Custom skill", displayValue: "Custom skill" },
|
||||
{ questionId: "q-tags", value: '["Alpha","Gamma"]', displayValue: "Alpha, Gamma" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects answers for questions outside the Form", () => {
|
||||
expect(() => normalizeSubmissionAnswers(questions, [
|
||||
{ questionId: "q-name", value: "Erika" },
|
||||
{ questionId: "missing", value: "Injected" },
|
||||
])).toThrow("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
|
||||
});
|
||||
|
||||
test("rejects duplicate answers for one Question", () => {
|
||||
expect(() => normalizeSubmissionAnswers(questions, [
|
||||
{ questionId: "q-name", value: "Erika" },
|
||||
{ questionId: "q-name", value: "Duplicate" },
|
||||
])).toThrow("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
|
||||
});
|
||||
|
||||
test("rejects unresolved Other answer placeholders", () => {
|
||||
expect(() => normalizeSubmissionAnswers(questions, [
|
||||
{ questionId: "q-name", value: "Erika" },
|
||||
{ questionId: "q-skill", value: "__OTHER__" },
|
||||
])).toThrow("โปรดระบุคำตอบสำหรับ “Skill”");
|
||||
});
|
||||
|
||||
test("maps answers by Question order for webhook templates", () => {
|
||||
const normalized = normalizeSubmissionAnswers(questions, [
|
||||
{ questionId: "q-name", value: "Erika" },
|
||||
{ questionId: "q-skill", value: "Build" },
|
||||
{ questionId: "q-tags", value: ["Beta"] },
|
||||
]);
|
||||
|
||||
expect(answersByTemplateIndex(questions, normalized)).toEqual({
|
||||
"1": "Erika",
|
||||
"2": "Build",
|
||||
"3": "Beta",
|
||||
});
|
||||
});
|
||||
|
||||
test("renders Update text from stored old answers", () => {
|
||||
const normalized = normalizeSubmissionAnswers(questions, [
|
||||
{ questionId: "q-name", value: "Erika" },
|
||||
{ questionId: "q-skill", value: "Fight" },
|
||||
{ questionId: "q-tags", value: ["Alpha", "Gamma"] },
|
||||
]);
|
||||
|
||||
expect(submissionUpdateText(questions, normalized, {
|
||||
"q-name": "Erika",
|
||||
"q-skill": "Build",
|
||||
"q-tags": '["Alpha"]',
|
||||
})).toBe([
|
||||
"**Skill**: `Build` ➡️ `Fight`",
|
||||
"**Tags**: `Alpha` ➡️ `Alpha, Gamma`",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
test("renders a stable no-change Update message", () => {
|
||||
const normalized = normalizeSubmissionAnswers(questions, [
|
||||
{ questionId: "q-name", value: "Erika" },
|
||||
{ questionId: "q-skill", value: "Build" },
|
||||
{ questionId: "q-tags", value: ["Alpha"] },
|
||||
]);
|
||||
|
||||
expect(submissionUpdateText(questions, normalized, {
|
||||
"q-name": "Erika",
|
||||
"q-skill": "Build",
|
||||
"q-tags": '["Alpha"]',
|
||||
})).toBe("No changes made.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { QuestionType } from "@/db/schema/form";
|
||||
import {
|
||||
decodeCheckboxAnswer,
|
||||
encodeAnswerValue,
|
||||
formatStoredAnswer,
|
||||
type FormAnswerValue,
|
||||
} from "@/lib/form-answer-values";
|
||||
import { stripHtml } from "@/lib/utils";
|
||||
|
||||
export type SubmissionQuestion = {
|
||||
id: string;
|
||||
type: QuestionType;
|
||||
label: string;
|
||||
required: boolean;
|
||||
options?: string[] | null;
|
||||
allowOther: boolean;
|
||||
};
|
||||
|
||||
export type SubmittedFormAnswer = {
|
||||
questionId: string;
|
||||
value: FormAnswerValue;
|
||||
};
|
||||
|
||||
export type NormalizedSubmissionAnswer = {
|
||||
questionId: string;
|
||||
value: string;
|
||||
displayValue: string;
|
||||
};
|
||||
|
||||
function questionTitle(question: Pick<SubmissionQuestion, "label">) {
|
||||
return stripHtml(question.label);
|
||||
}
|
||||
|
||||
function ensureSubmittedQuestionIdsBelongToForm(
|
||||
questions: SubmissionQuestion[],
|
||||
answersList: SubmittedFormAnswer[],
|
||||
) {
|
||||
const questionsById = new Map(questions.map((question) => [question.id, question]));
|
||||
const submittedQuestionIds = new Set<string>();
|
||||
|
||||
for (const answer of answersList) {
|
||||
if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) {
|
||||
throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
|
||||
}
|
||||
submittedQuestionIds.add(answer.questionId);
|
||||
}
|
||||
|
||||
return questionsById;
|
||||
}
|
||||
|
||||
function normalizeCheckboxAnswer(
|
||||
question: SubmissionQuestion,
|
||||
rawValue: FormAnswerValue | undefined,
|
||||
): NormalizedSubmissionAnswer {
|
||||
if (
|
||||
rawValue !== undefined &&
|
||||
typeof rawValue !== "string" &&
|
||||
(!Array.isArray(rawValue) || rawValue.some((value) => typeof value !== "string"))
|
||||
) {
|
||||
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))];
|
||||
if (selectedValues.some((value) => !value.trim() || value === "__OTHER__")) {
|
||||
throw new Error(`โปรดระบุคำตอบสำหรับ “${questionTitle(question)}”`);
|
||||
}
|
||||
if (question.required && selectedValues.length === 0) {
|
||||
throw new Error(`จำเป็นต้องตอบ “${questionTitle(question)}”`);
|
||||
}
|
||||
|
||||
const standardOptions = question.options ?? [];
|
||||
const customValues = selectedValues.filter((value) => !standardOptions.includes(value));
|
||||
if ((!question.allowOther && customValues.length > 0) || customValues.length > 1) {
|
||||
throw new Error(`ตัวเลือกของ “${questionTitle(question)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
return {
|
||||
questionId: question.id,
|
||||
value: encodeAnswerValue(selectedValues),
|
||||
displayValue: selectedValues.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScalarAnswer(
|
||||
question: SubmissionQuestion,
|
||||
rawValue: FormAnswerValue | undefined,
|
||||
): NormalizedSubmissionAnswer {
|
||||
if (rawValue !== undefined && typeof rawValue !== "string") {
|
||||
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
const value = rawValue ?? "";
|
||||
if (value === "__OTHER__") {
|
||||
throw new Error(`โปรดระบุคำตอบสำหรับ “${questionTitle(question)}”`);
|
||||
}
|
||||
if (question.required && !value.trim()) {
|
||||
throw new Error(`จำเป็นต้องตอบ “${questionTitle(question)}”`);
|
||||
}
|
||||
if (
|
||||
question.type === "radio" &&
|
||||
value &&
|
||||
!(question.options ?? []).includes(value) &&
|
||||
!question.allowOther
|
||||
) {
|
||||
throw new Error(`ตัวเลือกของ “${questionTitle(question)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
return {
|
||||
questionId: question.id,
|
||||
value,
|
||||
displayValue: value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSubmissionAnswers(
|
||||
questions: SubmissionQuestion[],
|
||||
answersList: SubmittedFormAnswer[],
|
||||
) {
|
||||
ensureSubmittedQuestionIdsBelongToForm(questions, answersList);
|
||||
|
||||
const submittedAnswers = new Map(
|
||||
answersList.map((answer) => [answer.questionId, answer.value]),
|
||||
);
|
||||
|
||||
return questions.map((question) => {
|
||||
const rawValue = submittedAnswers.get(question.id);
|
||||
return question.type === "checkbox"
|
||||
? normalizeCheckboxAnswer(question, rawValue)
|
||||
: normalizeScalarAnswer(question, rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
export function answersByTemplateIndex(
|
||||
questions: Pick<SubmissionQuestion, "id">[],
|
||||
normalizedAnswers: NormalizedSubmissionAnswer[],
|
||||
) {
|
||||
const questionOrderMap = questions.reduce<Record<string, string>>((acc, question, index) => {
|
||||
acc[question.id] = String(index + 1);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return normalizedAnswers.reduce<Record<string, string>>((acc, answer) => {
|
||||
const index = questionOrderMap[answer.questionId];
|
||||
if (index) acc[index] = answer.displayValue;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function submissionUpdateText(
|
||||
questions: SubmissionQuestion[],
|
||||
normalizedAnswers: NormalizedSubmissionAnswer[],
|
||||
oldAnswersByQuestionId: Record<string, string>,
|
||||
) {
|
||||
const questionsById = new Map(questions.map((question) => [question.id, question]));
|
||||
const changed: string[] = [];
|
||||
|
||||
for (const answer of normalizedAnswers) {
|
||||
const question = questionsById.get(answer.questionId);
|
||||
const oldValue = oldAnswersByQuestionId[answer.questionId];
|
||||
const oldDisplayValue = question
|
||||
? formatStoredAnswer(oldValue, question.type)
|
||||
: oldValue;
|
||||
|
||||
if (oldDisplayValue !== answer.displayValue) {
|
||||
const title = question ? questionTitle(question) : "Question";
|
||||
changed.push(
|
||||
`**${title}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return changed.length > 0 ? changed.join("\n") : "No changes made.";
|
||||
}
|
||||
Reference in New Issue
Block a user