diff --git a/checkers-app/package-lock.json b/checkers-app/package-lock.json index 7281d9a9..1a8a7dbf 100644 --- a/checkers-app/package-lock.json +++ b/checkers-app/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@firebase/storage": "^0.12.0", "@heroicons/react": "^2.0.18", - "@material-tailwind/react": "^2.1.4", + "@material-tailwind/react": "^2.1.9", "@tanstack/react-query": "^5.20.1", "@tanstack/react-query-devtools": "^5.20.1", "apexcharts": "^3.44.0", @@ -1322,14 +1322,14 @@ "integrity": "sha512-RtEH4vdcbXZuZWRZbIRmQVBNsE7VDQpet2qFvq6vwKLBIQRQR5Kh58M4ok3A3US8Sr3rubYnaGqZSurCwI8uMA==" }, "node_modules/@firebase/storage": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.12.2.tgz", - "integrity": "sha512-MzanOBcxDx9oOwDaDPMuiYxd6CxcN1xZm+os5uNE3C1itbRKLhM9rzpODDKWzcbnHHFtXk3Q3lsK/d3Xa1WYYw==", + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.12.4.tgz", + "integrity": "sha512-HcmUcp2kSSr5cHkIqFrgUW+i20925EEjkXepQxgBcI2Vx0cyqshr8iETtGow2+cMBFeY8H2swsKKabOKAjIwlQ==", "dependencies": { - "@firebase/component": "0.6.5", - "@firebase/util": "1.9.4", + "@firebase/component": "0.6.6", + "@firebase/util": "1.9.5", "tslib": "^2.1.0", - "undici": "5.28.3" + "undici": "5.28.4" }, "peerDependencies": { "@firebase/app": "0.x" @@ -1374,18 +1374,18 @@ } }, "node_modules/@firebase/storage/node_modules/@firebase/component": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.5.tgz", - "integrity": "sha512-2tVDk1ixi12sbDmmfITK8lxSjmcb73BMF6Qwc3U44hN/J1Fi1QY/Hnnb6klFlbB9/G16a3J3d4nXykye2EADTw==", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.6.tgz", + "integrity": "sha512-pp7sWqHmAAlA3os6ERgoM3k5Cxff510M9RLXZ9Mc8KFKMBc2ct3RkZTWUF7ixJNvMiK/iNgRLPDrLR2gtRJ9iQ==", "dependencies": { - "@firebase/util": "1.9.4", + "@firebase/util": "1.9.5", "tslib": "^2.1.0" } }, "node_modules/@firebase/storage/node_modules/@firebase/util": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.4.tgz", - "integrity": "sha512-WLonYmS1FGHT97TsUmRN3qnTh5TeeoJp1Gg5fithzuAgdZOUtsYECfy7/noQ3llaguios8r5BuXSEiK82+UrxQ==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.5.tgz", + "integrity": "sha512-PP4pAFISDxsf70l3pEy34Mf3GkkUcVQ3MdKp6aSVb7tcpfUQxnsdV7twDd8EkfB6zZylH6wpUAoangQDmCUMqw==", "dependencies": { "tslib": "^2.1.0" } @@ -1570,9 +1570,9 @@ } }, "node_modules/@material-tailwind/react": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@material-tailwind/react/-/react-2.1.4.tgz", - "integrity": "sha512-dRHnC+Ka0IJ8KSfToBmlJRy9gAPv6F1oVZb+ABOPcIR8LxBhAXXhMAjtQf9ilG0nq2VK9AISsYFtyWBsr8Y88A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@material-tailwind/react/-/react-2.1.9.tgz", + "integrity": "sha512-3uPlJE9yK4JF9DEQO4I1QbjR8o05+4fysLqoZ0v38TDOLE2tvDRhTBVhn6Mp9vSsq5CoJOKgemG7kbkOFAji4A==", "dependencies": { "@floating-ui/react": "0.19.0", "classnames": "2.3.2", @@ -5202,9 +5202,9 @@ } }, "node_modules/undici": { - "version": "5.28.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", - "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -5279,9 +5279,9 @@ } }, "node_modules/vite": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", - "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", "dev": true, "dependencies": { "esbuild": "^0.18.10", @@ -6382,29 +6382,29 @@ "integrity": "sha512-RtEH4vdcbXZuZWRZbIRmQVBNsE7VDQpet2qFvq6vwKLBIQRQR5Kh58M4ok3A3US8Sr3rubYnaGqZSurCwI8uMA==" }, "@firebase/storage": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.12.2.tgz", - "integrity": "sha512-MzanOBcxDx9oOwDaDPMuiYxd6CxcN1xZm+os5uNE3C1itbRKLhM9rzpODDKWzcbnHHFtXk3Q3lsK/d3Xa1WYYw==", + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.12.4.tgz", + "integrity": "sha512-HcmUcp2kSSr5cHkIqFrgUW+i20925EEjkXepQxgBcI2Vx0cyqshr8iETtGow2+cMBFeY8H2swsKKabOKAjIwlQ==", "requires": { - "@firebase/component": "0.6.5", - "@firebase/util": "1.9.4", + "@firebase/component": "0.6.6", + "@firebase/util": "1.9.5", "tslib": "^2.1.0", - "undici": "5.28.3" + "undici": "5.28.4" }, "dependencies": { "@firebase/component": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.5.tgz", - "integrity": "sha512-2tVDk1ixi12sbDmmfITK8lxSjmcb73BMF6Qwc3U44hN/J1Fi1QY/Hnnb6klFlbB9/G16a3J3d4nXykye2EADTw==", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.6.tgz", + "integrity": "sha512-pp7sWqHmAAlA3os6ERgoM3k5Cxff510M9RLXZ9Mc8KFKMBc2ct3RkZTWUF7ixJNvMiK/iNgRLPDrLR2gtRJ9iQ==", "requires": { - "@firebase/util": "1.9.4", + "@firebase/util": "1.9.5", "tslib": "^2.1.0" } }, "@firebase/util": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.4.tgz", - "integrity": "sha512-WLonYmS1FGHT97TsUmRN3qnTh5TeeoJp1Gg5fithzuAgdZOUtsYECfy7/noQ3llaguios8r5BuXSEiK82+UrxQ==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.5.tgz", + "integrity": "sha512-PP4pAFISDxsf70l3pEy34Mf3GkkUcVQ3MdKp6aSVb7tcpfUQxnsdV7twDd8EkfB6zZylH6wpUAoangQDmCUMqw==", "requires": { "tslib": "^2.1.0" } @@ -6584,9 +6584,9 @@ } }, "@material-tailwind/react": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@material-tailwind/react/-/react-2.1.4.tgz", - "integrity": "sha512-dRHnC+Ka0IJ8KSfToBmlJRy9gAPv6F1oVZb+ABOPcIR8LxBhAXXhMAjtQf9ilG0nq2VK9AISsYFtyWBsr8Y88A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@material-tailwind/react/-/react-2.1.9.tgz", + "integrity": "sha512-3uPlJE9yK4JF9DEQO4I1QbjR8o05+4fysLqoZ0v38TDOLE2tvDRhTBVhn6Mp9vSsq5CoJOKgemG7kbkOFAji4A==", "requires": { "@floating-ui/react": "0.19.0", "classnames": "2.3.2", @@ -9195,9 +9195,9 @@ "dev": true }, "undici": { - "version": "5.28.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", - "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "requires": { "@fastify/busboy": "^2.0.0" } @@ -9249,9 +9249,9 @@ } }, "vite": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", - "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", "dev": true, "requires": { "esbuild": "^0.18.10", diff --git a/checkers-app/package.json b/checkers-app/package.json index 506500b6..428ace01 100644 --- a/checkers-app/package.json +++ b/checkers-app/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "build:uat": "vite build --mode uat", + "build:uat": "tsc && vite build --mode uat", "build:watch": "vite build --mode dev --watch", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" @@ -14,7 +14,7 @@ "dependencies": { "@firebase/storage": "^0.12.0", "@heroicons/react": "^2.0.18", - "@material-tailwind/react": "^2.1.4", + "@material-tailwind/react": "^2.1.9", "@tanstack/react-query": "^5.20.1", "@tanstack/react-query-devtools": "^5.20.1", "apexcharts": "^3.44.0", 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/common/ToolTip.tsx b/checkers-app/src/components/common/ToolTip.tsx new file mode 100644 index 00000000..83ace8a2 --- /dev/null +++ b/checkers-app/src/components/common/ToolTip.tsx @@ -0,0 +1,42 @@ +import { Tooltip, Typography } from "@material-tailwind/react"; + +interface TooltipProps { + header: string; + text: string; +} + +export function TooltipWithHelperIcon({ header, text }: TooltipProps) { + return ( + + + {header} + + + {text} + + + } + > + + + + + ); +} 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/components/vote/Tier2.tsx b/checkers-app/src/components/vote/InfoOptions.tsx similarity index 100% rename from checkers-app/src/components/vote/Tier2.tsx rename to checkers-app/src/components/vote/InfoOptions.tsx diff --git a/checkers-app/src/components/vote/VoteCategories.tsx b/checkers-app/src/components/vote/VoteCategories.tsx index f9252b31..b4558af6 100644 --- a/checkers-app/src/components/vote/VoteCategories.tsx +++ b/checkers-app/src/components/vote/VoteCategories.tsx @@ -6,14 +6,15 @@ import { QuestionMarkCircleIcon } from "@heroicons/react/20/solid"; import { HandThumbUpIcon } from "@heroicons/react/20/solid"; import { NewspaperIcon } from "@heroicons/react/20/solid"; import { FaceSmileIcon } from "@heroicons/react/20/solid"; - +import { PaperAirplaneIcon } from "@heroicons/react/20/solid"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@material-tailwind/react"; import { patchVote } from "../../services/api"; import { useUser } from "../../providers/UserContext"; +import { TooltipWithHelperIcon } from "../common/ToolTip"; -import InfoOptions from "./Tier2"; +import InfoOptions from "./InfoOptions"; interface PropType { messageId: string | null; @@ -23,41 +24,62 @@ interface PropType { } const CATEGORIES = [ - { name: "scam", icon: , display: "Scam" }, + { + name: "scam", + icon: , + display: "Scam", + description: "Intended to obtain money/personal information via deception", + }, { name: "illicit", icon: , display: "Illicit", + description: + "Other potential illicit activity, e.g. moneylending/prostitution", }, { name: "info", icon: , display: "News/Info/Opinion", + description: + "Messages intended to inform/convince/mislead a broad base of people", }, { name: "satire", icon: , display: "Satire", + description: "Content clearly satirical in nature", }, { name: "spam", icon: , display: "Spam", + description: "Unsolicited spam, such as marketing messages", + }, + { + name: "legitimate", + icon: , + display: "Legitimate", + description: + "Legitimate source but can't be assessed, e.g. transactional messages", }, { name: "irrelevant", icon: , display: "Trivial", + description: "Trivial/banal messages with nothing to assess", }, { name: "unsure", icon: , display: "Unsure", + description: "Insufficient information to decide", }, { - name: "legitimate", - icon: , - display: "Legitimate", + name: "pass", + icon: , + display: "Pass", + description: "Skip this message if you're really unable to assess it", }, ]; @@ -89,7 +111,12 @@ export default function VoteCategories(Prop: PropType) { const handleSubmitVote = (category: string, truthScore: number | null) => { if (messageId && voteRequestId) { //call api to update vote - patchVote(messageId, voteRequestId, category, category === "info" ? truthScore : null) + patchVote( + messageId, + voteRequestId, + category, + category === "info" ? truthScore : null + ) .then(() => { incrementSessionVotedCount(); navigate("/votes"); @@ -106,13 +133,18 @@ export default function VoteCategories(Prop: PropType) { <> {/* Conditionally render InfoOptions right after the "info" button if it has been selected */} {category === "info" && cat.name === "info" && ( diff --git a/checkers-app/src/components/vote/index.tsx b/checkers-app/src/components/vote/index.tsx index 81e4f014..622615a2 100644 --- a/checkers-app/src/components/vote/index.tsx +++ b/checkers-app/src/components/vote/index.tsx @@ -60,7 +60,9 @@ export default function VotePage() { caption={vote.caption} /> Sender: {vote.sender} - {vote.category === null || !vote.isAssessed ? ( + {vote.category === null || + vote.category === "pass" || + !vote.isAssessed ? ( <> + + + ); +} 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/getChecker.ts b/functions/src/definitions/api/handlers/getChecker.ts index a05a8072..052acf5d 100644 --- a/functions/src/definitions/api/handlers/getChecker.ts +++ b/functions/src/definitions/api/handlers/getChecker.ts @@ -51,7 +51,7 @@ const getCheckerHandler = async (req: Request, res: Response) => { //filter client side for category != null, since firestore doesn't support inequality on 2 fields const last30DaysData = last30DaysSnap.docs.filter( - (doc) => doc.get("category") !== null + (doc) => doc.get("category") !== null && doc.get("category") !== "pass" ) const totalVoted = last30DaysData.length 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/patchChecker.ts b/functions/src/definitions/api/handlers/patchChecker.ts index cdf117ae..ecc71db5 100644 --- a/functions/src/definitions/api/handlers/patchChecker.ts +++ b/functions/src/definitions/api/handlers/patchChecker.ts @@ -42,6 +42,14 @@ const patchCheckerHandler = async (req: Request, res: Response) => { return res.status(400).send("telegramId cannot be updated") } + if (keys.includes("isAdmin")) { + return res.status(400).send("isAdmin cannot be updated") + } + + if (keys.includes("tier")) { + return res.status(400).send("tier cannot be updated") + } + //update checker await checkerRef.update(body) diff --git a/functions/src/definitions/api/handlers/patchVoteRequest.ts b/functions/src/definitions/api/handlers/patchVoteRequest.ts index 4e2d89ec..e5414b73 100644 --- a/functions/src/definitions/api/handlers/patchVoteRequest.ts +++ b/functions/src/definitions/api/handlers/patchVoteRequest.ts @@ -49,7 +49,7 @@ const patchVoteRequestHandler = async (req: Request, res: Response) => { "legitimate", "irrelevant", "unsure", - "error", + "pass", ].includes(category) ) { return res.status(400).send(`${category} is not a valid category`) 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 5c83df7e..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") @@ -48,7 +49,6 @@ async function deactivateAndRemind() { .get() if (!voteRequestsQuerySnap.empty && lastVotedDate < cutoffDate) { logger.log(`Checker ${doc.id}, ${doc.get("name")} set to inactive`) - await doc.ref.update({ isActive: false }) if (preferredPlatform === "whatsapp") { if (!whatsappId) { logger.error( @@ -58,6 +58,7 @@ async function deactivateAndRemind() { ) return Promise.resolve() } + await doc.ref.update({ isActive: false }) return sendWhatsappTemplateMessage( "factChecker", whatsappId, @@ -75,11 +76,11 @@ async function deactivateAndRemind() { ) return Promise.resolve() } - const reactivationMessage = `Hello ${doc.get("name")}, - - Just a reminder - you've not completed a check within the last ${cutoffHours} hours! No worries, we know everyone's busy! But because the replies to our users are contingent on a large enough proportion of CheckMates voting, not doing so adds to the denominator and slows down the response to our users. Thus, we've temporarily removed you from the active CheckMates pool so you can take a break without worries! - - Anytime you wish to continue checking, just vist the portal below to reactivate yourself and you'll be immediately added back into the pool! (You can do so right now too!)` + const reactivationMessage = `Hello ${doc.get( + "name" + )}! Thanks for all your contributions so far๐Ÿ™. We noticed that you have an outstanding message that hasn't been checked, and thought to remind you on it! +You can go to the CheckMates' Portal and find your outstanding votes there. You can opt to pass the vote there, but we hope you'll at least give it a try ๐Ÿ’ช. +If you'd like take a break, just type /deactivate. We'll stop sending you messages to vote on. Once you're ready to get back, you can type /activate to start receiving messages again.` return sendTelegramTextMessage( "factChecker", telegramId, @@ -96,7 +97,6 @@ async function deactivateAndRemind() { 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/counters.ts b/functions/src/definitions/common/counters.ts index 820b1a9b..f81bf9c7 100644 --- a/functions/src/definitions/common/counters.ts +++ b/functions/src/definitions/common/counters.ts @@ -35,7 +35,7 @@ const getVoteCounts = async function (messageRef: DocumentReference) { .get() const [ responsesCount, - errorCount, + passCount, irrelevantCount, scamCount, illicitCount, @@ -48,7 +48,7 @@ const getVoteCounts = async function (messageRef: DocumentReference) { voteRequestCountSnapshot, ] = await Promise.all([ getCount(messageRef, "responses"), - getCount(messageRef, "error"), + getCount(messageRef, "pass"), getCount(messageRef, "irrelevant"), getCount(messageRef, "scam"), getCount(messageRef, "illicit"), @@ -61,12 +61,12 @@ const getVoteCounts = async function (messageRef: DocumentReference) { totalVoteRequestQuery, ]) const totalVoteRequestsCount = voteRequestCountSnapshot.data().count ?? 0 - const factCheckerCount = totalVoteRequestsCount - errorCount //don't count "error" votes in number of fact checkers, as this will slow the replies unnecessarily. - const validResponsesCount = responsesCount - errorCount //can remove in future and replace with nonErrorCount + const factCheckerCount = totalVoteRequestsCount - passCount //don't count "error" votes in number of fact checkers, as this will slow the replies unnecessarily. + const validResponsesCount = responsesCount - passCount //can remove in future and replace with nonErrorCount const susCount = scamCount + illicitCount return { responsesCount, - errorCount, + passCount, irrelevantCount, scamCount, illicitCount, diff --git a/functions/src/definitions/common/responseUtils.ts b/functions/src/definitions/common/responseUtils.ts index 669451d2..e38b2a45 100644 --- a/functions/src/definitions/common/responseUtils.ts +++ b/functions/src/definitions/common/responseUtils.ts @@ -311,7 +311,6 @@ async function sendVotingStats(instancePath: string) { satireCount, validResponsesCount, susCount, - factCheckerCount, } = await getVoteCounts(messageRef) const truthScore = messageSnap.get("truthScore") const thresholds = await getThresholds() diff --git a/functions/src/definitions/common/sendFactCheckerMessages.ts b/functions/src/definitions/common/sendFactCheckerMessages.ts index fb4c357e..6e6f5e2d 100644 --- a/functions/src/definitions/common/sendFactCheckerMessages.ts +++ b/functions/src/definitions/common/sendFactCheckerMessages.ts @@ -108,8 +108,12 @@ const sendL2OthersCategorisationMessage = async function ( { id: `${type}_${messageRef.id}_${voteRequestSnap.id}_unsure`, title: "I'm Unsure", - description: - "Do try your best to categorize! But if really unsure, select this", + description: "Insufficient information to determine", + }, + { + id: `${type}_${messageRef.id}_${voteRequestSnap.id}_pass`, + title: "I'm Passing", + description: "Skip this message if you're really unable to assess it", }, ] const sections = [ 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 ca4d1402..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, @@ -30,6 +31,10 @@ function checkAccuracy( logger.warn("Vote request has no category") return null } + if (voteRequestCategory === "pass") { + logger.info("Checker has passed") + return null + } if (voteRequestCategory === "info") { //check the truth scores and return true if they are within 1 of each other if (!["misleading", "untrue", "accurate"].includes(parentMessageCategory)) { @@ -45,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 312624fa..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 = { @@ -77,12 +95,15 @@ export type VoteRequest = { | "legitimate" | "irrelevant" | "unsure" - | "error" + | "pass" | null reasoning: string | null createdTimestamp: Timestamp | null acceptedTimestamp: Timestamp | null votedTimestamp: Timestamp | null + isCorrect: boolean | null + score: number | null + duration: number | null //duration in minutes } export type CustomReply = { diff --git a/integration-tests/checkmate.postman_collection.json b/integration-tests/checkmate.postman_collection.json index fb06c506..2aaa092b 100644 --- a/integration-tests/checkmate.postman_collection.json +++ b/integration-tests/checkmate.postman_collection.json @@ -2510,7 +2510,12 @@ " {\r", " \"id\": `others_${messageId}_${voteRequestId}_unsure`,\r", " \"title\": \"I'm Unsure\",\r", - " \"description\": \"Do try your best to categorize! But if really unsure, select this\"\r", + " \"description\": \"Insufficient information to determine\"\r", + " },\r", + " {\r", + " \"id\": `others_${messageId}_${voteRequestId}_pass`,\r", + " \"title\": \"I'm Passing\",\r", + " \"description\": \"Skip this message if you're really unable to assess it\"\r", " }\r", " ]\r", " }\r", @@ -2527,7 +2532,8 @@ " pm.expect(jsonData).to.eql(expected);\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -2536,7 +2542,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -3574,7 +3581,12 @@ " {\r", " \"id\": `others_${messageId}_${voteRequestId}_unsure`,\r", " \"title\": \"I'm Unsure\",\r", - " \"description\": \"Do try your best to categorize! But if really unsure, select this\"\r", + " \"description\": \"Insufficient information to determine\"\r", + " },\r", + " {\r", + " \"id\": `others_${messageId}_${voteRequestId}_pass`,\r", + " \"title\": \"I'm Passing\",\r", + " \"description\": \"Skip this message if you're really unable to assess it\"\r", " }\r", " ]\r", " }\r", @@ -3591,7 +3603,8 @@ " pm.expect(jsonData).to.eql(expected);\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -3600,7 +3613,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -4285,7 +4299,12 @@ " {\r", " \"id\": `others_${messageId}_${voteRequestId}_unsure`,\r", " \"title\": \"I'm Unsure\",\r", - " \"description\": \"Do try your best to categorize! But if really unsure, select this\"\r", + " \"description\": \"Insufficient information to determine\"\r", + " },\r", + " {\r", + " \"id\": `others_${messageId}_${voteRequestId}_pass`,\r", + " \"title\": \"I'm Passing\",\r", + " \"description\": \"Skip this message if you're really unable to assess it\"\r", " }\r", " ]\r", " }\r", @@ -4302,7 +4321,8 @@ " pm.expect(jsonData).to.eql(expected);\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -4311,7 +4331,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -8075,7 +8096,12 @@ " {\r", " \"id\": `others_${messageId}_${voteRequestId}_unsure`,\r", " \"title\": \"I'm Unsure\",\r", - " \"description\": \"Do try your best to categorize! But if really unsure, select this\"\r", + " \"description\": \"Insufficient information to determine\"\r", + " },\r", + " {\r", + " \"id\": `others_${messageId}_${voteRequestId}_pass`,\r", + " \"title\": \"I'm Passing\",\r", + " \"description\": \"Skip this message if you're really unable to assess it\"\r", " }\r", " ]\r", " }\r", @@ -8092,7 +8118,8 @@ " pm.expect(jsonData).to.eql(expected);\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -8101,7 +8128,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -10008,7 +10036,12 @@ " {\r", " \"id\": `others_${messageId}_${voteRequestId}_unsure`,\r", " \"title\": \"I'm Unsure\",\r", - " \"description\": \"Do try your best to categorize! But if really unsure, select this\"\r", + " \"description\": \"Insufficient information to determine\"\r", + " },\r", + " {\r", + " \"id\": `others_${messageId}_${voteRequestId}_pass`,\r", + " \"title\": \"I'm Passing\",\r", + " \"description\": \"Skip this message if you're really unable to assess it\"\r", " }\r", " ]\r", " }\r", @@ -10025,7 +10058,8 @@ " pm.expect(jsonData).to.eql(expected);\r", "});" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } }, { @@ -10034,7 +10068,8 @@ "exec": [ "" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ],