diff --git a/checkers-app/src/App.tsx b/checkers-app/src/App.tsx index 5e8fe68f..a5e2e184 100644 --- a/checkers-app/src/App.tsx +++ b/checkers-app/src/App.tsx @@ -6,6 +6,7 @@ import { DashboardPage, ViewVotePage, MyVotesPage, + LeaderboardPage, } from "./pages"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { useUser } from "./providers/UserContext"; @@ -16,6 +17,7 @@ export const router = createBrowserRouter([ { path: "/", element: }, { path: "/votes", element: }, { path: "/achievements", element: }, + { path: "/leaderboard", element: }, { path: "/messages/:messageId/voteRequests/:voteRequestId", element: , diff --git a/checkers-app/src/components/common/BotNavBar.tsx b/checkers-app/src/components/common/BotNavBar.tsx index c5cf6625..e81460d6 100644 --- a/checkers-app/src/components/common/BotNavBar.tsx +++ b/checkers-app/src/components/common/BotNavBar.tsx @@ -45,7 +45,7 @@ export default function NavbarDefault() { variant="text" className="rounded-full" onClick={() => { - navigate("/achievements"); + navigate("/leaderboard"); }} ripple > diff --git a/checkers-app/src/components/leaderboard/index.tsx b/checkers-app/src/components/leaderboard/index.tsx new file mode 100644 index 00000000..ce366726 --- /dev/null +++ b/checkers-app/src/components/leaderboard/index.tsx @@ -0,0 +1,13 @@ +import { Typography } from "@material-tailwind/react"; +import { LeaderboardTable } from "./table"; + +export default function Leaderboard() { + return ( +
+ + Refreshes every month + + +
+ ); +} diff --git a/checkers-app/src/components/leaderboard/table.tsx b/checkers-app/src/components/leaderboard/table.tsx new file mode 100644 index 00000000..e6cf649c --- /dev/null +++ b/checkers-app/src/components/leaderboard/table.tsx @@ -0,0 +1,203 @@ +import { Card, Typography } from "@material-tailwind/react"; +import { + ClockIcon, + UserIcon, + TrophyIcon, + HashtagIcon, + CheckCircleIcon, + CalculatorIcon, +} from "@heroicons/react/24/solid"; +import { Tooltip } from "@material-tailwind/react"; +import { useEffect, useState } from "react"; +import { useUser } from "../../providers/UserContext"; +import Loading from "../common/Loading"; +import { LeaderboardEntry } from "../../types"; +import { getLeaderboard } from "../../services/api"; + +const iconsize = "h-4 w-4"; +const COLUMNS = [ + { + title: "rank", + icon: , + description: "Rank", + }, + { + title: "name", + icon: , + description: "Name", + }, + { + title: "numVotes", + icon: , + description: "Number of votes on messages that did not end in unsure", + }, + { + title: "accuracy", + icon: , + description: "Accuracy (%) of votes on messages that did not end in unsure", + }, + { + title: "averageTimeTaken", + icon: , + description: + "Average time (hrs) taken on votes on messages that did not end in unsure", + }, + { + title: "score", + icon: , + description: "Score, based on both accuracy and speed", + }, +]; + +export function LeaderboardTable() { + let lastIndex = 0; + const [isLoading, setIsLoading] = useState(false); + const { checkerDetails } = useUser(); + const [abridgedLeaderboard, setAbridgedLeaderboard] = useState< + LeaderboardEntry[] + >([]); + + useEffect(() => { + const fetchLeaderboard = async () => { + setIsLoading(true); + if (!checkerDetails.checkerId) { + return; + } + const leaderboard: LeaderboardEntry[] = await getLeaderboard( + checkerDetails.checkerId + ); + if (leaderboard) { + setAbridgedLeaderboard(leaderboard); + setIsLoading(false); + } + }; + if (checkerDetails.checkerId) { + fetchLeaderboard(); + } + }, [checkerDetails.checkerId]); + + if (isLoading) { + return ; + } + + return ( + + + + + {COLUMNS.map((col) => ( + + ))} + + + + {abridgedLeaderboard.map( + ( + { + id, + position, + name, + numVoted, + accuracy, + averageTimeTaken, + score, + }, + index + ) => { + const discontinuity = position - lastIndex > 1; + const isChecker = id === checkerDetails.checkerId; + const isLast = index === abridgedLeaderboard.length - 1; + const rowClasses = isChecker ? "bg-orange-100" : ""; + const classes = isLast + ? "p-4" + : "p-4 border-b border-blue-gray-50"; + lastIndex = position; + + return ( + <> + {discontinuity && ( + + + + )} + + + + + + + + + + ); + } + )} + +
+ {col.icon} +
+ + ... + +
+ + {position} + + + + {name.length > 10 ? `${name.slice(0, 10)}..` : name} + + + + {numVoted} + + + + {(accuracy * 100).toFixed(0)} + + + + {(averageTimeTaken / 60).toFixed(2)} + + + + {score.toFixed(1)} + +
+
+ ); +} diff --git a/checkers-app/src/components/vote/CustomReply.tsx b/checkers-app/src/components/vote/CustomReply.tsx index a7d93d9e..4458ddc1 100644 --- a/checkers-app/src/components/vote/CustomReply.tsx +++ b/checkers-app/src/components/vote/CustomReply.tsx @@ -48,7 +48,6 @@ export default function CustomReply(Prop: PropType) { checkerDetails.checkerId, customReplyText ).then(() => { - console.log("Custom reply posted successfully"); navigate("/votes"); }); } @@ -57,9 +56,7 @@ export default function CustomReply(Prop: PropType) { const handleWhatsappTest = () => { if (checkerDetails.checkerId && customReplyText) { sendWhatsappTestMessage(checkerDetails.checkerId, customReplyText) - .then((data) => { - console.log(data); - console.log("Test is successfull"); + .then(() => { setShowAlerts(false); }) .catch((error) => { diff --git a/checkers-app/src/pages/LeaderboardPage.tsx b/checkers-app/src/pages/LeaderboardPage.tsx new file mode 100644 index 00000000..ca50b80b --- /dev/null +++ b/checkers-app/src/pages/LeaderboardPage.tsx @@ -0,0 +1,10 @@ +import Leaderboard from "../components/leaderboard"; +import Layout from "../components/common/Layout"; + +export default function LeaderboardPage() { + return ( + + + + ); +} diff --git a/checkers-app/src/pages/Onboarding.tsx b/checkers-app/src/pages/Onboarding.tsx index b76be396..45934fdf 100644 --- a/checkers-app/src/pages/Onboarding.tsx +++ b/checkers-app/src/pages/Onboarding.tsx @@ -180,7 +180,6 @@ const Onboarding = () => { } setIsOTPValidated(true); setShowAlerts(false); - console.log("OTP checked"); }) .catch((error) => { console.error("Error checking OTP", error); diff --git a/checkers-app/src/pages/index.tsx b/checkers-app/src/pages/index.tsx index 3ad30514..c1d57ff6 100644 --- a/checkers-app/src/pages/index.tsx +++ b/checkers-app/src/pages/index.tsx @@ -2,5 +2,12 @@ import AchievementPage from "./AchievementPage"; import DashboardPage from "./DashboardPage"; import ViewVotePage from "./VotingPage"; import MyVotesPage from "./MyVotesPage"; +import LeaderboardPage from "./LeaderboardPage"; -export { AchievementPage, DashboardPage, ViewVotePage, MyVotesPage }; +export { + AchievementPage, + DashboardPage, + ViewVotePage, + MyVotesPage, + LeaderboardPage, +}; diff --git a/checkers-app/src/services/api.ts b/checkers-app/src/services/api.ts index 7859295b..0f8d6da4 100644 --- a/checkers-app/src/services/api.ts +++ b/checkers-app/src/services/api.ts @@ -3,6 +3,7 @@ import { Vote, VoteSummaryApiResponse, PendingCountApiResponse, + LeaderboardEntry, } from "../types"; import { getAuth, connectAuthEmulator } from "firebase/auth"; import app from "../firebase"; @@ -189,6 +190,16 @@ export const getMessage = async (messageId: string) => { return (await axiosInstance.get(`/api/messages/${messageId}`)).data; }; +export const getLeaderboard = async ( + checkerId: string +): Promise => { + if (!checkerId) { + throw new Error("Checker ID missing."); + } + return (await axiosInstance.get(`/api/checkers/${checkerId}/leaderboard`)) + .data; +}; + export const sendWhatsappTestMessage = async ( checkerId: string, message: string diff --git a/checkers-app/src/types.d.ts b/checkers-app/src/types.d.ts index d02c7420..f2d2cf7c 100644 --- a/checkers-app/src/types.d.ts +++ b/checkers-app/src/types.d.ts @@ -7,6 +7,7 @@ import { PendingCountApiResponse, AssessedInfo, updateChecker, + LeaderboardEntry, } from "../../functions/src/definitions/api/interfaces"; interface CheckerDetails { @@ -34,4 +35,5 @@ export type { updateChecker, CheckerDetails, Window, + LeaderboardEntry, }; diff --git a/functions/src/definitions/api/api.ts b/functions/src/definitions/api/api.ts index a0082c60..a852e02d 100644 --- a/functions/src/definitions/api/api.ts +++ b/functions/src/definitions/api/api.ts @@ -14,6 +14,7 @@ import checkOTPHandler from "./handlers/checkOTP" import deleteCheckerHandler from "./handlers/deleteChecker" import postCustomReplyHandler from "./handlers/postCustomReply" import postWhatsappTestMessageHandler from "./handlers/postWhatsappTestMessage" +import getLeaderboardHandler from "./handlers/getLeaderboard" import { validateFirebaseIdToken } from "./middleware/validator" import getMessageHandler from "./handlers/getMessage" @@ -77,6 +78,12 @@ checkersRouter.post( postWhatsappTestMessageHandler ) +checkersRouter.get( + "/checkers/:checkerId/leaderboard", + validateFirebaseIdToken, + getLeaderboardHandler +) + messagesRouter.get( "/messages/:messageId/voteRequests/:voteRequestId", validateFirebaseIdToken, diff --git a/functions/src/definitions/api/authentication.ts b/functions/src/definitions/api/authentication.ts index e28cafa5..0b413818 100644 --- a/functions/src/definitions/api/authentication.ts +++ b/functions/src/definitions/api/authentication.ts @@ -138,6 +138,12 @@ app.post("/", async (req, res) => { preferredPlatform: "telegram", lastVotedTimestamp: null, getNameMessageId: null, + leaderboardStats: { + numVoted: 0, + numCorrectVotes: 0, + totalTimeTaken: 0, + score: 0, + }, } try { diff --git a/functions/src/definitions/api/handlers/getLeaderboard.test.ts b/functions/src/definitions/api/handlers/getLeaderboard.test.ts new file mode 100644 index 00000000..dedd49dd --- /dev/null +++ b/functions/src/definitions/api/handlers/getLeaderboard.test.ts @@ -0,0 +1,151 @@ +import { getDisplayedRows } from "./getLeaderboard" + +describe("getDisplayedRows", () => { + const sampleLeaderboard = [ + { + id: "1", + position: 1, + name: "Alice", + numVoted: 100, + accuracy: 99, + averageTimeTaken: 30, + score: 500, + }, + { + id: "2", + position: 2, + name: "Bob", + numVoted: 100, + accuracy: 97, + averageTimeTaken: 28, + score: 490, + }, + { + id: "3", + position: 3, + name: "Charlie", + numVoted: 100, + accuracy: 95, + averageTimeTaken: 25, + score: 480, + }, + { + id: "4", + position: 4, + name: "David", + numVoted: 100, + accuracy: 93, + averageTimeTaken: 30, + score: 470, + }, + { + id: "5", + position: 5, + name: "Eve", + numVoted: 100, + accuracy: 90, + averageTimeTaken: 35, + score: 460, + }, + { + id: "6", + position: 6, + name: "Faythe", + numVoted: 100, + accuracy: 89, + averageTimeTaken: 20, + score: 450, + }, + { + id: "7", + position: 7, + name: "Grace", + numVoted: 100, + accuracy: 88, + averageTimeTaken: 22, + score: 440, + }, + { + id: "8", + position: 8, + name: "Heidi", + numVoted: 100, + accuracy: 87, + averageTimeTaken: 23, + score: 430, + }, + { + id: "9", + position: 9, + name: "Ivan", + numVoted: 100, + accuracy: 85, + averageTimeTaken: 25, + score: 420, + }, + { + id: "10", + position: 10, + name: "Judy", + numVoted: 100, + accuracy: 84, + averageTimeTaken: 26, + score: 410, + }, + { + id: "11", + position: 11, + name: "Michael", + numVoted: 100, + accuracy: 82, + averageTimeTaken: 24, + score: 400, + }, + ] + + test("Should return top 5 and checker plus/minus 2 when checker is in the middle", () => { + const result = getDisplayedRows("6", sampleLeaderboard) + expect(result).toEqual([ + ...sampleLeaderboard.slice(0, 5), + ...sampleLeaderboard.slice(5, 8), // Faythe is at position 6, range 4-8 + ]) + }) + + test("Should handle checker in the 4th position", () => { + const result = getDisplayedRows("4", sampleLeaderboard) + expect(result).toEqual([ + ...sampleLeaderboard.slice(0, 5), + ...sampleLeaderboard.slice(5, 6), // Faythe is at position 6, range 4-8 + ]) + }) + + test("Should handle checker being in the top 5", () => { + const result = getDisplayedRows("3", sampleLeaderboard) + expect(result).toEqual(sampleLeaderboard.slice(0, 5)) + }) + + test("Should handle checker being in the top 1", () => { + const result = getDisplayedRows("1", sampleLeaderboard) + expect(result).toEqual(sampleLeaderboard.slice(0, 5)) + }) + + test("Should handle checker at the end of the array", () => { + const result = getDisplayedRows("11", sampleLeaderboard) + expect(result).toEqual([ + ...sampleLeaderboard.slice(0, 5), + ...sampleLeaderboard.slice(8, 11), + ]) + }) + + test("Should throw an error if checker is not found", () => { + expect(() => getDisplayedRows("12", sampleLeaderboard)).toThrow( + "Checker not found in leaderboard" + ) + }) + + test("Should handle small leaderboards correctly", () => { + const smallBoard = sampleLeaderboard.slice(0, 3) // Only 3 members + const result = getDisplayedRows("2", smallBoard) + expect(result).toEqual(smallBoard) + }) +}) diff --git a/functions/src/definitions/api/handlers/getLeaderboard.ts b/functions/src/definitions/api/handlers/getLeaderboard.ts new file mode 100644 index 00000000..eecd2fbb --- /dev/null +++ b/functions/src/definitions/api/handlers/getLeaderboard.ts @@ -0,0 +1,62 @@ +import { Request, Response } from "express" +import { getFullLeaderboard } from "../../common/statistics" +import { LeaderboardEntry } from "../interfaces" +import { logger } from "firebase-functions/v2" +import * as admin from "firebase-admin" +if (!admin.apps.length) { + admin.initializeApp() +} + +const db = admin.firestore() + +const getLeaderboardHandler = async (req: Request, res: Response) => { + let displayedRows + try { + const checkerId = req.params.checkerId + if (!checkerId) { + return res.status(400).send("Checker ID missing.") + } + const leaderboardData = await getFullLeaderboard() + try { + displayedRows = getDisplayedRows(checkerId, leaderboardData) + } catch (error) { + logger.error( + `Error fetching leaderboard, likely due to checker not found in leaderboard data: ${error}` + ) + return res + .status(500) + .send( + "Error fetching leaderboard, likely due to checker not found in leaderboard data" + ) + } + res.status(200).json(displayedRows) + } catch (error) { + logger.error(`Error fetching leaderboard: ${error}`) + return res.status(500).send("Error fetching leaderboard") + } +} + +function getDisplayedRows(checkerId: string, leaderboard: LeaderboardEntry[]) { + //find position of current checker in leaderboard data + const checkerPosition = leaderboard.findIndex( + (checker) => checker.id === checkerId + ) + if (checkerPosition === -1) { + throw new Error("Checker not found in leaderboard") + } + //return top 5 positions and those +/- 2 positions from current checker. altogether at most 10 entries should be returned + // Always include the top 5 entries + const topFive = leaderboard.slice(0, 5) + // Calculate the range around the checker's position to exclude top 5 if there's an overlap + let lowerBound = Math.max(5, checkerPosition - 2) // Start after top 5 or 2 positions before the checker + let upperBound = Math.min(leaderboard.length, checkerPosition + 3) // Include checker's position + 2 more + // Extract the vicinity without overlapping the top 5 + const vicinity = leaderboard.slice(lowerBound, upperBound) + // Merge the two segments ensuring no duplicates based on id + const uniqueIds = new Set(topFive.map((row) => row.id)) + return topFive.concat(vicinity.filter((row) => !uniqueIds.has(row.id))) +} + +export default getLeaderboardHandler + +export { getDisplayedRows } diff --git a/functions/src/definitions/api/handlers/postChecker.ts b/functions/src/definitions/api/handlers/postChecker.ts index 9b648a60..dd81ee8d 100644 --- a/functions/src/definitions/api/handlers/postChecker.ts +++ b/functions/src/definitions/api/handlers/postChecker.ts @@ -63,6 +63,12 @@ const postCheckerHandler = async (req: Request, res: Response) => { preferredPlatform: preferredPlatform || type === "ai" ? null : "telegram", lastVotedTimestamp: lastVotedTimestamp || null, getNameMessageId: null, + leaderboardStats: { + numVoted: 0, + numCorrectVotes: 0, + totalTimeTaken: 0, + score: 0, + }, } logger.info("Creating new checker", newChecker) diff --git a/functions/src/definitions/api/handlers/postVoteRequest.ts b/functions/src/definitions/api/handlers/postVoteRequest.ts index 9949d1e2..df141a3b 100644 --- a/functions/src/definitions/api/handlers/postVoteRequest.ts +++ b/functions/src/definitions/api/handlers/postVoteRequest.ts @@ -85,6 +85,9 @@ const postVoteRequestHandler = async (req: Request, res: Response) => { createdTimestamp: Timestamp.fromDate(new Date()), acceptedTimestamp: Timestamp.fromDate(new Date()), votedTimestamp: null, + isCorrect: null, + score: null, + duration: null, } //create new voteRequest in message diff --git a/functions/src/definitions/api/interfaces.ts b/functions/src/definitions/api/interfaces.ts index f2221ee0..b92141c9 100644 --- a/functions/src/definitions/api/interfaces.ts +++ b/functions/src/definitions/api/interfaces.ts @@ -1,4 +1,4 @@ -import { CustomReply } from "../../types" +import { CustomReply, LeaderboardEntry } from "../../types" interface createVoteRequest { factCheckerId?: string @@ -162,4 +162,5 @@ export type { upsertCustomReply, postWhatsappTestMessage, MessageSummary, + LeaderboardEntry, } diff --git a/functions/src/definitions/api/middleware/validator.ts b/functions/src/definitions/api/middleware/validator.ts index 82b98e3b..a868dac3 100644 --- a/functions/src/definitions/api/middleware/validator.ts +++ b/functions/src/definitions/api/middleware/validator.ts @@ -43,7 +43,6 @@ async function validateFirebaseIdToken( const isAdmin = decodedIdToken.isAdmin const tier = decodedIdToken.tier const checkerID = decodedIdToken.uid - console.log("checkerID: ", checkerID) if (!checkerID) { functions.logger.warn("Error while verifying Firebase ID token") return res.status(403).send("Unauthorized") diff --git a/functions/src/definitions/batchJobs/batchJobs.ts b/functions/src/definitions/batchJobs/batchJobs.ts index 772bf70d..448ab2d1 100644 --- a/functions/src/definitions/batchJobs/batchJobs.ts +++ b/functions/src/definitions/batchJobs/batchJobs.ts @@ -13,16 +13,17 @@ import { logger } from "firebase-functions/v2" import { sendTelegramTextMessage } from "../common/sendTelegramMessage" import { AppEnv } from "../../appEnv" import { TIME } from "../../utils/time" +import { getFullLeaderboard } from "../common/statistics" const runtimeEnvironment = defineString(AppEnv.ENVIRONMENT) if (!admin.apps.length) { admin.initializeApp() } +const db = admin.firestore() async function deactivateAndRemind() { try { - const db = admin.firestore() const cutoffHours = 72 const activeCheckMatesSnap = await db .collection("checkers") @@ -96,7 +97,6 @@ If you'd like take a break, just type /deactivate. We'll stop sending you messag async function checkConversationSessionExpiring() { try { - const db = admin.firestore() const hoursAgo = 23 const windowStart = Timestamp.fromDate( new Date(Date.now() - hoursAgo * TIME.ONE_HOUR) @@ -121,7 +121,6 @@ async function checkConversationSessionExpiring() { async function interimPromptHandler() { try { - const db = admin.firestore() const dayAgo = Timestamp.fromDate(new Date(Date.now() - TIME.ONE_DAY)) const halfHourAgo = runtimeEnvironment.value() === "PROD" @@ -158,6 +157,45 @@ async function interimPromptHandler() { } } +async function resetLeaderboardHandler() { + await saveLeaderboard() + try { + // reset leaderboard stats for all checkers + const checkersQuerySnap = await db.collection("checkers").get() + const promisesArr = checkersQuerySnap.docs.map(async (doc) => { + return doc.ref.update({ + leaderboardStats: { + numVoted: 0, + numCorrectVotes: 0, + totalTimeTaken: 0, + score: 0, + }, + }) + }) + await Promise.all(promisesArr) + } catch (error) { + logger.error("Error in resetLeaderboard:", error) + } +} + +async function saveLeaderboard() { + try { + const leaderboardData = await getFullLeaderboard() + const storageBucket = admin.storage().bucket() + //get date string + const date = new Date(); + const dateString = date.toISOString().split('T')[0]; + const leaderboardFile = storageBucket.file(`leaderboard_${dateString}.json`) + + await leaderboardFile.save(JSON.stringify(leaderboardData), { + contentType: "application/json", + }) + logger.log("Leaderboard saved successfully") + } catch (error) { + logger.error("Failed to save leaderboard:", error) + } +} + const checkSessionExpiring = onSchedule( { schedule: "1 * * * *", @@ -192,9 +230,19 @@ const sendInterimPrompt = onSchedule( interimPromptHandler ) +const resetLeaderboard = onSchedule( + { + schedule: "0 0 1 * *", + timeZone: "Asia/Singapore", + region: "asia-southeast1", + }, + resetLeaderboardHandler +) + export { checkSessionExpiring, scheduledDeactivation, sendInterimPrompt, + resetLeaderboard, interimPromptHandler, } diff --git a/functions/src/definitions/common/statistics.test.ts b/functions/src/definitions/common/statistics.test.ts new file mode 100644 index 00000000..b852b502 --- /dev/null +++ b/functions/src/definitions/common/statistics.test.ts @@ -0,0 +1,39 @@ +import * as admin from "firebase-admin" +import { Timestamp } from "firebase-admin/firestore" +import { computeGamificationScore } from "./statistics" + +// Function to create a mock DocumentSnapshot +function createMockDocumentSnapshot(data: any) { + return { + exists: true, + isEqual: jest.fn(), + id: "mockDocId", + ref: {} as admin.firestore.DocumentReference, + metadata: {}, // Metadata can be mocked as an empty object or further detailed + readTime: Timestamp.now(), + get: jest.fn((field) => data[field]), + data: jest.fn(() => data), + } +} + +// Example usage in a test +describe("computeGamificationScore Tests", () => { + it("should calculate correct score for a valid response time", () => { + // Setup + const nowSeconds = Math.floor(Date.now() / 1000) + const createdTimestamp = new Timestamp(nowSeconds - 3600, 0) // 1 hour ago + const votedTimestamp = new Timestamp(nowSeconds, 0) + + const mockData = { + createdTimestamp: createdTimestamp, + votedTimestamp: votedTimestamp, + } + + const mockDocSnap = createMockDocumentSnapshot(mockData) + + // Act + const score = computeGamificationScore(mockDocSnap, true) + // Assert + expect(score).toBeCloseTo(0.97916667) // Expect some positive score for correct and timely response + }) +}) diff --git a/functions/src/definitions/common/statistics.ts b/functions/src/definitions/common/statistics.ts index 4ce6afff..243ccd22 100644 --- a/functions/src/definitions/common/statistics.ts +++ b/functions/src/definitions/common/statistics.ts @@ -1,5 +1,6 @@ import * as admin from "firebase-admin" import { logger } from "firebase-functions/v2" +import { LeaderboardEntry } from "../../types" function checkAccuracy( parentMessageSnap: admin.firestore.DocumentSnapshot, @@ -49,4 +50,87 @@ function checkAccuracy( } } -export { checkAccuracy } +function computeGamificationScore( + voteRequestSnap: admin.firestore.DocumentSnapshot, + isCorrect: boolean | null +) { + const createdTimestamp = voteRequestSnap.get( + "createdTimestamp" + ) as admin.firestore.Timestamp + const votedTimestamp = voteRequestSnap.get( + "votedTimestamp" + ) as admin.firestore.Timestamp + + // Scoring parameters + const timeBucketSize = 300 // 5 minutes + const maxTimeAllowedForPoints = 24 * 60 * 60 // 24 hours + + // Calculate the time taken to vote in seconds + const timeTaken = votedTimestamp.seconds - createdTimestamp.seconds + const numBuckets = Math.ceil(maxTimeAllowedForPoints / timeBucketSize) + const timeBucket = Math.min( + Math.floor(timeTaken / timeBucketSize), + numBuckets + ) + + // Scoring function + const points = isCorrect === true ? 1 - timeBucket / numBuckets / 2 : 0 + return points +} + +async function getFullLeaderboard(): Promise { + const db = admin.firestore() + const leaderboardQuery = db + .collection("checkers") + .where("isActive", "==", true) + .orderBy("leaderboardStats.score", "desc") + const leaderboardSnap = await leaderboardQuery.get() + const leaderboardData = leaderboardSnap.docs.map((doc, index) => { + const data = doc.data() + const numVoted = data.leaderboardStats.numVoted ?? 0 + const numCorrectVotes = data.leaderboardStats.numCorrectVotes ?? 0 + const totalTimeTaken = data.leaderboardStats.totalTimeTaken ?? 0 + const accuracy = numVoted === 0 ? 0 : numCorrectVotes / numVoted + const averageTimeTaken = numVoted === 0 ? 0 : totalTimeTaken / numVoted + return { + id: doc.id, + position: index + 1, + name: data.name, + numVoted: numVoted, + accuracy: accuracy, + averageTimeTaken: averageTimeTaken, + score: data.leaderboardStats.score, + } + }) + return leaderboardData +} + +function computeTimeTakenMinutes( + voteRequestData: admin.firestore.DocumentSnapshot +) { + const createdTimestamp = voteRequestData.get( + "createdTimestamp" + ) as admin.firestore.Timestamp + const votedTimestamp = voteRequestData.get( + "votedTimestamp" + ) as admin.firestore.Timestamp + return (votedTimestamp.seconds - createdTimestamp.seconds) / 60 +} + +function tabulateVoteStats( + parentMessageSnap: admin.firestore.DocumentSnapshot, + voteRequestSnap: admin.firestore.DocumentSnapshot +) { + const isCorrect = checkAccuracy(parentMessageSnap, voteRequestSnap) + const score = computeGamificationScore(voteRequestSnap, isCorrect) + const duration = computeTimeTakenMinutes(voteRequestSnap) + return { isCorrect, score, duration } +} + +export { + checkAccuracy, + computeGamificationScore, + getFullLeaderboard, + computeTimeTakenMinutes, + tabulateVoteStats, +} diff --git a/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts b/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts index 479cc797..d0169e11 100644 --- a/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts +++ b/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts @@ -131,6 +131,12 @@ async function onSignUp(from: string, platform = "whatsapp") { preferredPlatform: "whatsapp", getNameMessageId: res.data.messages[0].id, lastVotedTimestamp: null, + leaderboardStats: { + numVoted: 0, + numCorrectVotes: 0, + totalTimeTaken: 0, + score: 0, + }, } await db.collection("checkers").add(checkerObj) } diff --git a/functions/src/definitions/eventHandlers/onInstanceCreate.ts b/functions/src/definitions/eventHandlers/onInstanceCreate.ts index d74b119b..659df53b 100644 --- a/functions/src/definitions/eventHandlers/onInstanceCreate.ts +++ b/functions/src/definitions/eventHandlers/onInstanceCreate.ts @@ -182,23 +182,30 @@ async function sendTemplateMessageAndCreateVoteRequest( messageRef: admin.firestore.DocumentReference ) { const factChecker = factCheckerDocSnap.data() - if (factChecker?.preferredPlatform === "whatsapp") { + const preferredPlatform = factChecker?.preferredPlatform + const newVoteRequest: VoteRequest = { + factCheckerDocRef: factCheckerDocSnap.ref, + platformId: + preferredPlatform === "whatsapp" + ? factChecker.whatsappId + : factChecker.telegramId, + hasAgreed: false, + triggerL2Vote: null, + triggerL2Others: null, + platform: preferredPlatform, + sentMessageId: null, + category: null, + truthScore: null, + reasoning: null, + createdTimestamp: Timestamp.fromDate(new Date()), + acceptedTimestamp: null, + votedTimestamp: null, + isCorrect: null, + score: null, + duration: null, + } + if (preferredPlatform === "whatsapp") { // First, add the voteRequest object to the "voteRequests" sub-collection - const newVoteRequest: VoteRequest = { - factCheckerDocRef: factCheckerDocSnap.ref, - platformId: factChecker.whatsappId, - hasAgreed: false, - triggerL2Vote: null, - triggerL2Others: null, - platform: "whatsapp", - sentMessageId: null, - category: null, - truthScore: null, - reasoning: null, - createdTimestamp: Timestamp.fromDate(new Date()), - acceptedTimestamp: null, - votedTimestamp: null, - } return messageRef .collection("voteRequests") .add(newVoteRequest) @@ -214,25 +221,12 @@ async function sendTemplateMessageAndCreateVoteRequest( "factChecker" ) }) - } else if (factChecker?.preferredPlatform === "telegram") { + } else if (preferredPlatform === "telegram") { //not yet implemented // First, add the voteRequest object to the "voteRequests" sub-collection return messageRef .collection("voteRequests") - .add({ - factCheckerDocRef: factCheckerDocSnap.ref, - platformId: factChecker.telegramId, - hasAgreed: false, - triggerL2Vote: null, - triggerL2Others: null, - platform: "telegram", - sentMessageId: null, - category: null, - vote: null, - createdTimestamp: Timestamp.fromDate(new Date()), - acceptedTimestamp: null, - votedTimestamp: null, - }) + .add(newVoteRequest) .then((voteRequestRef) => { const voteRequestPath = voteRequestRef.path const voteRequestUrl = `${checkerAppHost}/${voteRequestPath}` diff --git a/functions/src/definitions/eventHandlers/onMessageUpdate.ts b/functions/src/definitions/eventHandlers/onMessageUpdate.ts index 742872fc..ca1191b2 100644 --- a/functions/src/definitions/eventHandlers/onMessageUpdate.ts +++ b/functions/src/definitions/eventHandlers/onMessageUpdate.ts @@ -3,6 +3,7 @@ import { respondToInstance } from "../common/responseUtils" import { Timestamp } from "firebase-admin/firestore" import { rationaliseMessage, anonymiseMessage } from "../common/genAI" import { onDocumentUpdated } from "firebase-functions/v2/firestore" +import { tabulateVoteStats } from "../common/statistics" const onMessageUpdateV2 = onDocumentUpdated( { @@ -71,6 +72,26 @@ const onMessageUpdateV2 = onDocumentUpdated( text: anonymisedText, }) } + if (shouldRecalculateAccuracy(preChangeSnap, postChangeSnap)) { + console.log() + //get all voteRequests + const voteRequestsQuerySnap = await postChangeSnap.ref + .collection("voteRequests") + .where("category", "!=", null) + .get() + const promiseArr = voteRequestsQuerySnap.docs.map((voteRequestSnap) => { + const { isCorrect, score, duration } = tabulateVoteStats( + postChangeSnap, + voteRequestSnap + ) + return voteRequestSnap.ref.update({ + isCorrect: isCorrect, + score: score, + duration: duration, + }) + }) + await Promise.all(promiseArr) + } return Promise.resolve() } ) @@ -87,4 +108,26 @@ async function replyPendingInstances( }) } +function shouldRecalculateAccuracy( + preChangeSnap: functions.firestore.DocumentSnapshot, + postChangeSnap: functions.firestore.DocumentSnapshot +) { + if (postChangeSnap.get("isAssessed") !== true) { + return false + } + if (preChangeSnap.get("isAssessed") !== postChangeSnap.get("isAssessed")) { + return true + } + if ( + preChangeSnap.get("primaryCategory") !== + postChangeSnap.get("primaryCategory") + ) { + return true + } + if (preChangeSnap.get("truthScore") !== postChangeSnap.get("truthScore")) { + return true + } + return false +} + export { onMessageUpdateV2 } diff --git a/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts b/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts index d9e1e84c..c27a4895 100644 --- a/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts +++ b/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts @@ -10,6 +10,7 @@ import { incrementCounter, getVoteCounts } from "../common/counters" import { FieldValue } from "@google-cloud/firestore" import { defineInt } from "firebase-functions/params" import { onDocumentUpdated } from "firebase-functions/v2/firestore" +import { tabulateVoteStats } from "../common/statistics" // Define some parameters const numVoteShards = defineInt("NUM_SHARDS_VOTE_COUNT") @@ -38,6 +39,7 @@ const onVoteRequestUpdateV2 = onDocumentUpdated( functions.logger.error(`Vote request ${docSnap.ref.path} has no parent`) return } + const messageSnap = await messageRef.get() if ( preChangeData.triggerL2Vote !== true && postChangeData.triggerL2Vote === true @@ -149,6 +151,17 @@ const onVoteRequestUpdateV2 = onDocumentUpdated( isAssessed: isAssessed, primaryCategory: primaryCategory, }) + if (messageSnap.get("isAssessed") === true) { + const { isCorrect, score, duration } = tabulateVoteStats( + messageSnap, + docSnap + ) + await docSnap.ref.update({ + isCorrect: isCorrect, + score: score, + duration: duration, + }) + } if (postChangeData.category !== null) { //vote has ended if ( @@ -174,6 +187,54 @@ const onVoteRequestUpdateV2 = onDocumentUpdated( } } } + //update leaderboard stats + if (preChangeData.isCorrect !== postChangeData.isCorrect) { + const checkerUpdateObj = {} as Record + const previousCorrect = preChangeData.isCorrect + const currentCorrect = postChangeData.isCorrect + const previousScore = preChangeData.score + const currentScore = postChangeData.score + const previousDuration = preChangeData.duration + const currentDuration = postChangeData.duration + let durationDelta = 0 + if (previousDuration != null && previousCorrect != null) { + durationDelta -= previousDuration + } + if (currentDuration != null && currentCorrect != null) { + durationDelta += currentDuration + } + if (durationDelta !== 0) { + checkerUpdateObj["leaderboardStats.totalTimeTaken"] = + FieldValue.increment(durationDelta) + } + + if (previousCorrect === true) { + //means now its not correct + checkerUpdateObj["leaderboardStats.numCorrectVotes"] = + FieldValue.increment(-1) + if (previousScore != null) { + checkerUpdateObj["leaderboardStats.score"] = FieldValue.increment( + -previousScore + ) + } + } + if (currentCorrect === null) { + //means now it's unsure and should not be added to denominator + checkerUpdateObj["leaderboardStats.numVoted"] = FieldValue.increment(-1) + } + + if (currentCorrect === true) { + await docSnap.ref.update({ score: currentScore }) + checkerUpdateObj["leaderboardStats.numCorrectVotes"] = + FieldValue.increment(1) + checkerUpdateObj["leaderboardStats.score"] = + FieldValue.increment(currentScore) + } + if (previousCorrect == null) { + checkerUpdateObj["leaderboardStats.numVoted"] = FieldValue.increment(1) + } + await postChangeData.factCheckerDocRef.update(checkerUpdateObj) + } return Promise.resolve() } ) diff --git a/functions/src/definitions/webhookHandlers/specialCommands.ts b/functions/src/definitions/webhookHandlers/specialCommands.ts index 02cfef9a..b46d0a49 100644 --- a/functions/src/definitions/webhookHandlers/specialCommands.ts +++ b/functions/src/definitions/webhookHandlers/specialCommands.ts @@ -102,6 +102,12 @@ const mockDb = async function () { preferredPlatform: "whatsapp", lastVotedTimestamp: null, getNameMessageId: null, + leaderboardStats: { + numVoted: 0, + numCorrectVotes: 0, + totalTimeTaken: 0, + score: 0, + }, } if (querySnap.empty) { await checkersCollectionRef.add(checkerObj) diff --git a/functions/src/types.ts b/functions/src/types.ts index 4068d5dd..ce4a0be4 100644 --- a/functions/src/types.ts +++ b/functions/src/types.ts @@ -57,6 +57,24 @@ export type Checker = { preferredPlatform: string | null lastVotedTimestamp: Timestamp | null getNameMessageId: string | null + leaderboardStats: LeaderBoardStats +} + +type LeaderBoardStats = { + numVoted: number // number of votes cast where the parent message category is not unsure + numCorrectVotes: number // number of correct votes cast where the parent message category is not unsure + totalTimeTaken: number // total time taken to vote where the parent message category is not unsure + score: number // total score +} + +export type LeaderboardEntry = { + id: string + position: number + name: string + numVoted: number + accuracy: number + averageTimeTaken: number + score: number } export type VoteRequest = { @@ -83,6 +101,9 @@ export type VoteRequest = { createdTimestamp: Timestamp | null acceptedTimestamp: Timestamp | null votedTimestamp: Timestamp | null + isCorrect: boolean | null + score: number | null + duration: number | null //duration in minutes } export type CustomReply = {