From 03b3568eb7ac0f6d38bd18dbe4d255f2672b5be2 Mon Sep 17 00:00:00 2001 From: GreenAsJade Date: Mon, 23 Dec 2024 10:23:47 +1030 Subject: [PATCH] Show CM performance (and incidentally "My Own Reports) to everyone in the Reports Center --- .../CMVotingGroupGraph/CMVotingGraphs.tsx | 480 +++++++++++++ src/components/CMVotingGroupGraph/index.ts | 18 + src/components/NavBar/NavBar.tsx | 11 +- src/views/ReportsCenter/ReportsCenter.tsx | 31 +- .../ReportsCenterCMDashboard.tsx | 637 ++++-------------- 5 files changed, 650 insertions(+), 527 deletions(-) create mode 100644 src/components/CMVotingGroupGraph/CMVotingGraphs.tsx create mode 100644 src/components/CMVotingGroupGraph/index.ts diff --git a/src/components/CMVotingGroupGraph/CMVotingGraphs.tsx b/src/components/CMVotingGroupGraph/CMVotingGraphs.tsx new file mode 100644 index 0000000000..a2cc4d4efa --- /dev/null +++ b/src/components/CMVotingGroupGraph/CMVotingGraphs.tsx @@ -0,0 +1,480 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { format, subDays, startOfWeek as startOfWeekDateFns } from "date-fns"; + +import { dropCurrentPeriod } from "@/lib/misc"; +import * as data from "@/lib/data"; + +import { ResponsiveLine } from "@nivo/line"; +import React from "react"; + +function startOfWeek(the_date: Date): Date { + return startOfWeekDateFns(the_date, { weekStartsOn: 1 }); // 1 = Monday +} + +export interface ReportCount { + date: string; + escalated: number; + consensus: number; + non_consensus: number; +} + +export interface ReportAlignmentCount { + date: string; + escalated: number; + unanimous: number; + non_unanimous: number; +} + +// Hardcoding the vertical axis of all "report count" graphs as the total number helps convey +// the relative number of types of reports. + +// TBD: it might be nice if this "max" number was dynamically provided by the server, but +// we are already possibly hitting it hard for these rollups + +const EXPECTED_MAX_WEEKLY_CM_REPORTS = 200; +const Y_STEP_SIZE = 40; // must divide nicely into EXPECTED_MAX_WEEKLY_CM_REPORTS + +interface CMVoteCountGraphProps { + vote_data: ReportCount[]; + period: number; +} + +export const CMVoteCountGraph = ({ vote_data, period }: CMVoteCountGraphProps): JSX.Element => { + if (!vote_data) { + vote_data = []; + } + + const aggregateDataByWeek = React.useMemo(() => { + const aggregated: { + [key: string]: { + escalated: number; + consensus: number; + non_consensus: number; + total: number; + }; + } = {}; + + vote_data.forEach(({ date, escalated, consensus, non_consensus }) => { + const weekStart = startOfWeek(new Date(date)).toISOString().slice(0, 10); // Get week start and convert to ISO string for key + + if (!aggregated[weekStart]) { + aggregated[weekStart] = { escalated: 0, consensus: 0, non_consensus: 0, total: 0 }; + } + aggregated[weekStart].escalated += escalated; + aggregated[weekStart].consensus += consensus; + aggregated[weekStart].non_consensus += non_consensus; + aggregated[weekStart].total += escalated + consensus + non_consensus; + }); + + return Object.entries(aggregated).map(([date, counts]) => ({ + date, + ...counts, + })); + }, [vote_data]); + + const totals_data = React.useMemo(() => { + return [ + { + id: "consensus", + data: dropCurrentPeriod( + aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.consensus, + })), + ), + }, + { + id: "escalated", + data: dropCurrentPeriod( + aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.escalated, + })), + ), + }, + { + id: "non-consensus", + data: dropCurrentPeriod( + aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.non_consensus, + })), + ), + }, + ]; + }, [aggregateDataByWeek]); + + const percent_data = React.useMemo( + () => [ + { + id: "consensus", + data: aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.consensus / week.total, + })), + }, + { + id: "escalated", + data: aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.escalated / week.total, + })), + }, + { + id: "non-consensus", + data: aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.non_consensus / week.total, + })), + }, + ], + [aggregateDataByWeek], + ); + + const chart_theme = + data.get("theme") === "light" // (Accessible theme TBD - this assumes accessible is dark for now) + ? { + /* nivo defaults work well with our light theme */ + } + : { + text: { fill: "#FFFFFF" }, + tooltip: { container: { color: "#111111" } }, + grid: { line: { stroke: "#444444" } }, + }; + + const line_colors = { + consensus: "rgba(0, 128, 0, 1)", // green + escalated: "rgba(255, 165, 0, 1)", // orange + "non-consensus": "rgba(255, 0, 0, 1)", // red + }; + + const percent_line_colours = { + consensus: "rgba(0, 128, 0, 0.4)", + escalated: "rgba(255, 165, 0, 0.4)", + "non-consensus": "rgba(255, 0, 0, 0.4)", + }; + + if (!totals_data[0].data.length) { + return
No activity yet
; + } + + return ( +
+
+ line_colors[id as keyof typeof line_colors]} + animate + curve="monotoneX" + enablePoints={false} + enableSlices="x" + axisBottom={{ + format: "%d %b %g", + tickValues: "every week", + }} + xFormat="time:%Y-%m-%d" + xScale={{ + type: "time", + min: format( + startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), + "yyyy-MM-dd", + ), + format: "%Y-%m-%d", + useUTC: false, + precision: "day", + }} + axisLeft={{ + tickValues: Array.from( + { length: EXPECTED_MAX_WEEKLY_CM_REPORTS / Y_STEP_SIZE + 1 }, + (_, i) => i * Y_STEP_SIZE, + ), + }} + yScale={{ + stacked: false, + type: "linear", + min: 0, + max: EXPECTED_MAX_WEEKLY_CM_REPORTS, + }} + margin={{ + bottom: 40, + left: 60, + right: 40, + top: 5, + }} + theme={chart_theme} + /> +
+
+ + percent_line_colours[id as keyof typeof percent_line_colours] + } + animate + curve="monotoneX" + enablePoints={false} + enableSlices="x" + axisBottom={{ + format: "%d %b %g", + tickValues: "every week", + }} + xFormat="time:%Y-%m-%d" + xScale={{ + type: "time", + min: format( + startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), + "yyyy-MM-dd", + ), + format: "%Y-%m-%d", + useUTC: false, + precision: "day", + }} + axisLeft={{ + format: (d) => `${Math.round(d * 100)}%`, // Format ticks as percentages + tickValues: 6, + }} + yFormat=" >-.0p" + yScale={{ + stacked: true, + type: "linear", + }} + margin={{ + bottom: 40, + left: 60, + right: 40, + top: 5, + }} + theme={chart_theme} + /> +
+
+ ); +}; + +interface CMVotingGroupGraphProps { + vote_data: ReportAlignmentCount[]; + period: number; +} + +export const CMVotingGroupGraph = ({ vote_data, period }: CMVotingGroupGraphProps): JSX.Element => { + if (!vote_data) { + vote_data = []; + } + + const aggregateDataByWeek = React.useMemo(() => { + const aggregated: { + [key: string]: { + escalated: number; + unanimous: number; + non_unanimous: number; + total: number; + }; + } = {}; + + vote_data.forEach(({ date, escalated, unanimous, non_unanimous }) => { + const weekStart = startOfWeek(new Date(date)).toISOString().slice(0, 10); // Get week start and convert to ISO string for key + + if (!aggregated[weekStart]) { + aggregated[weekStart] = { escalated: 0, unanimous: 0, non_unanimous: 0, total: 0 }; + } + aggregated[weekStart].escalated += escalated; + aggregated[weekStart].unanimous += unanimous; + aggregated[weekStart].non_unanimous += non_unanimous; + aggregated[weekStart].total += unanimous + non_unanimous; + }); + + return Object.entries(aggregated).map(([date, counts]) => ({ + date, + ...counts, + })); + }, [vote_data]); + + const totals_data = React.useMemo(() => { + return [ + { + id: "unanimous", + data: dropCurrentPeriod( + aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.unanimous, + })), + ), + }, + { + id: "escalated", + data: dropCurrentPeriod( + aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.escalated, + })), + ), + }, + { + id: "non-unanimous", + data: dropCurrentPeriod( + aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.non_unanimous, + })), + ), + }, + ]; + }, [aggregateDataByWeek]); + + const percent_data = React.useMemo( + () => [ + { + id: "unanimous", + data: aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.unanimous / week.total, + })), + }, + { + id: "escalated", + data: aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.escalated / week.total, + })), + }, + { + id: "non-unanimous", + data: aggregateDataByWeek.map((week) => ({ + x: week.date, + y: week.non_unanimous / week.total, + })), + }, + ], + [aggregateDataByWeek], + ); + + const chart_theme = + data.get("theme") === "light" // (Accessible theme TBD - this assumes accessible is dark for now) + ? { + /* nivo defaults work well with our light theme */ + } + : { + text: { fill: "#FFFFFF" }, + tooltip: { container: { color: "#111111" } }, + grid: { line: { stroke: "#444444" } }, + }; + + const line_colors = { + unanimous: "rgba(0, 128, 0, 1)", // green + escalated: "rgba(255, 165, 0, 1)", // orange + "non-unanimous": "rgba(255, 0, 0, 1)", // red + }; + + if (!totals_data[0].data.length) { + return
No activity yet
; + } + + return ( +
+
+ line_colors[id as keyof typeof line_colors]} + animate + curve="monotoneX" + enablePoints={false} + enableSlices="x" + axisBottom={{ + format: "%d %b %g", + tickValues: "every week", + }} + xFormat="time:%Y-%m-%d" + xScale={{ + type: "time", + min: format( + startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), + "yyyy-MM-dd", + ), + format: "%Y-%m-%d", + useUTC: false, + precision: "day", + }} + axisLeft={{ + tickValues: Array.from( + { length: EXPECTED_MAX_WEEKLY_CM_REPORTS / Y_STEP_SIZE + 1 }, + (_, i) => i * Y_STEP_SIZE, + ), + }} + yScale={{ + stacked: false, + type: "linear", + min: 0, + max: EXPECTED_MAX_WEEKLY_CM_REPORTS, + }} + margin={{ + bottom: 40, + left: 60, + right: 40, + top: 5, + }} + theme={chart_theme} + /> +
+
+ line_colors[id as keyof typeof line_colors]} + animate + curve="monotoneX" + enablePoints={false} + enableSlices="x" + axisBottom={{ + format: "%d %b %g", + tickValues: "every week", + }} + xFormat="time:%Y-%m-%d" + xScale={{ + type: "time", + min: format( + startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), + "yyyy-MM-dd", + ), + format: "%Y-%m-%d", + useUTC: false, + precision: "day", + }} + axisLeft={{ + format: (d) => `${Math.round(d * 100)}%`, // Format ticks as percentages + tickValues: 6, + }} + yFormat=" >-.0p" + yScale={{ + stacked: false, + type: "linear", + max: 1, + }} + margin={{ + bottom: 40, + left: 60, + right: 40, + top: 5, + }} + theme={chart_theme} + /> +
+
+ ); +}; diff --git a/src/components/CMVotingGroupGraph/index.ts b/src/components/CMVotingGroupGraph/index.ts new file mode 100644 index 0000000000..87a7c9ec9f --- /dev/null +++ b/src/components/CMVotingGroupGraph/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export * from "./CMVotingGraphs"; diff --git a/src/components/NavBar/NavBar.tsx b/src/components/NavBar/NavBar.tsx index f072e23021..4fac688bf0 100644 --- a/src/components/NavBar/NavBar.tsx +++ b/src/components/NavBar/NavBar.tsx @@ -290,12 +290,11 @@ export function NavBar(): JSX.Element { {_("Rating Calculator")} - {(user.is_moderator || !!user.moderator_powers) && ( - - - {_("Reports Center")} - - )} + + + {_("Reports Center")} + + {user.is_moderator && ( diff --git a/src/views/ReportsCenter/ReportsCenter.tsx b/src/views/ReportsCenter/ReportsCenter.tsx index ef6b7cfe8c..077962f6ce 100644 --- a/src/views/ReportsCenter/ReportsCenter.tsx +++ b/src/views/ReportsCenter/ReportsCenter.tsx @@ -36,6 +36,7 @@ interface OtherView { special: string; title: string; show_cm: boolean; + show_all: boolean; } const categories: (ReportDescription | OtherView)[] = [ @@ -54,11 +55,11 @@ const categories: (ReportDescription | OtherView)[] = [ }, ]) .concat([ - { special: "hr", title: "", show_cm: true }, - { special: "history", title: "History", show_cm: true }, - { special: "cm", title: "Community Moderation", show_cm: true }, - { special: "my_reports", title: "My Own Reports", show_cm: true }, - { special: "settings", title: "Settings", show_cm: false }, + { special: "hr", title: "", show_cm: true, show_all: false }, + { special: "history", title: "History", show_cm: true, show_all: false }, + { special: "cm", title: "Community Moderation", show_cm: true, show_all: true }, + { special: "my_reports", title: "My Own Reports", show_cm: true, show_all: true }, + { special: "settings", title: "Settings", show_cm: false, show_all: false }, ]); const category_priorities: { [type: string]: number } = {}; @@ -131,10 +132,6 @@ export function ReportsCenter(): JSX.Element | null { navigateTo(`/reports-center/${category}`); }, []); - if (!user.is_moderator && !user.moderator_powers) { - return null; - } - const selectReport = (report_id: number) => { if (report_id) { navigateTo(`/reports-center/${category}/${report_id}`); @@ -145,13 +142,15 @@ export function ReportsCenter(): JSX.Element | null { const visible_categories = user.is_moderator ? categories - : // community moderators supported report types - categories.filter( - (category) => - ("special" in category && category.show_cm) || - (!("special" in category) && - community_mod_has_power(user.moderator_powers, category.type)), - ); + : user.moderator_powers + ? // community moderators supported report types + categories.filter( + (category) => + ("special" in category && category.show_cm) || + (!("special" in category) && + community_mod_has_power(user.moderator_powers, category.type)), + ) + : categories.filter((category) => "special" in category && category.show_all); const my_reports = report_manager .getEligibleReports() diff --git a/src/views/ReportsCenter/ReportsCenterCMDashboard.tsx b/src/views/ReportsCenter/ReportsCenterCMDashboard.tsx index d9fdb4afe0..c48c64e46e 100644 --- a/src/views/ReportsCenter/ReportsCenterCMDashboard.tsx +++ b/src/views/ReportsCenter/ReportsCenterCMDashboard.tsx @@ -16,34 +16,26 @@ */ import React, { useEffect } from "react"; -import { format, subDays, startOfWeek as startOfWeekDateFns } from "date-fns"; import { get } from "@/lib/requests"; -import * as data from "@/lib/data"; - -import { ResponsiveLine } from "@nivo/line"; import { useUser } from "@/lib/hooks"; import { PaginatedTable } from "@/components/PaginatedTable"; import { Player } from "@/components/Player"; import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; -import { llm_pgettext, pgettext } from "@/lib/translate"; +import { llm_pgettext, pgettext, _ } from "@/lib/translate"; import { UserVoteActivityGraph } from "@/views/User"; -import { dropCurrentPeriod } from "@/lib/misc"; -import { CMPieCharts } from "@/views/User"; -interface ReportCount { - date: string; - escalated: number; - consensus: number; - non_consensus: number; -} - -interface ReportAlignmentCount { - date: string; - escalated: number; - unanimous: number; - non_unanimous: number; +import { CMPieCharts } from "@/views/User"; +import { + CMVoteCountGraph, + CMVotingGroupGraph, + ReportAlignmentCount, + ReportCount, +} from "@/components/CMVotingGroupGraph"; + +interface SystemPerformanceData { + [reportType: string]: string; } interface CMVotingOutcomeData { @@ -58,460 +50,13 @@ interface IndividualCMVotingOutcomeData { user_id: number; vote_data: CMVotingOutcomeData; } - -function startOfWeek(the_date: Date): Date { - const date = new Date(the_date); - const day = date.getDay(); // Get current day of week (0 is Sunday) - const diff = date.getDate() - day; // Calculate difference to the start of the week - - return new Date(date.setDate(diff)); -} - -// Hardcoding the vertical axis of all "report count" graphs as the total number helps convey -// the relative number of types of reports. - -// TBD: it might be nice if this number was dynamically provided by the server, but -// we are already possibly hitting it hard for these rollups - -const EXPECTED_MAX_WEEKLY_CM_REPORTS = 200; -const Y_STEP_SIZE = 40; // must divide nicely into EXPECTED_MAX_WEEKLY_CM_REPORTS - -interface CMVoteCountGraphProps { - vote_data: ReportCount[]; - period: number; -} - -const CMVoteCountGraph = ({ vote_data, period }: CMVoteCountGraphProps): JSX.Element => { - if (!vote_data) { - vote_data = []; - } - - const aggregateDataByWeek = React.useMemo(() => { - const aggregated: { - [key: string]: { - escalated: number; - consensus: number; - non_consensus: number; - total: number; - }; - } = {}; - - vote_data.forEach(({ date, escalated, consensus, non_consensus }) => { - const weekStart = startOfWeek(new Date(date)).toISOString().slice(0, 10); // Get week start and convert to ISO string for key - - if (!aggregated[weekStart]) { - aggregated[weekStart] = { escalated: 0, consensus: 0, non_consensus: 0, total: 0 }; - } - aggregated[weekStart].escalated += escalated; - aggregated[weekStart].consensus += consensus; - aggregated[weekStart].non_consensus += non_consensus; - aggregated[weekStart].total += escalated + consensus + non_consensus; - }); - - return Object.entries(aggregated).map(([date, counts]) => ({ - date, - ...counts, - })); - }, [vote_data]); - - const totals_data = React.useMemo(() => { - return [ - { - id: "consensus", - data: dropCurrentPeriod( - aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.consensus, - })), - ), - }, - { - id: "escalated", - data: dropCurrentPeriod( - aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.escalated, - })), - ), - }, - { - id: "non-consensus", - data: dropCurrentPeriod( - aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.non_consensus, - })), - ), - }, - ]; - }, [aggregateDataByWeek]); - - const percent_data = React.useMemo( - () => [ - { - id: "consensus", - data: aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.consensus / week.total, - })), - }, - { - id: "escalated", - data: aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.escalated / week.total, - })), - }, - { - id: "non-consensus", - data: aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.non_consensus / week.total, - })), - }, - ], - [aggregateDataByWeek], - ); - - const chart_theme = - data.get("theme") === "light" // (Accessible theme TBD - this assumes accessible is dark for now) - ? { - /* nivo defaults work well with our light theme */ - } - : { - text: { fill: "#FFFFFF" }, - tooltip: { container: { color: "#111111" } }, - grid: { line: { stroke: "#444444" } }, - }; - - const line_colors = { - consensus: "rgba(0, 128, 0, 1)", // green - escalated: "rgba(255, 165, 0, 1)", // orange - "non-consensus": "rgba(255, 0, 0, 1)", // red - }; - - const percent_line_colours = { - consensus: "rgba(0, 128, 0, 0.4)", - escalated: "rgba(255, 165, 0, 0.4)", - "non-consensus": "rgba(255, 0, 0, 0.4)", - }; - - if (!totals_data[0].data.length) { - return
No activity yet
; - } - - return ( -
-
- line_colors[id as keyof typeof line_colors]} - animate - curve="monotoneX" - enablePoints={false} - enableSlices="x" - axisBottom={{ - format: "%d %b %g", - tickValues: "every week", - }} - xFormat="time:%Y-%m-%d" - xScale={{ - type: "time", - min: format( - startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), - "yyyy-MM-dd", - ), - format: "%Y-%m-%d", - useUTC: false, - precision: "day", - }} - axisLeft={{ - tickValues: Array.from( - { length: EXPECTED_MAX_WEEKLY_CM_REPORTS / Y_STEP_SIZE + 1 }, - (_, i) => i * Y_STEP_SIZE, - ), - }} - yScale={{ - stacked: false, - type: "linear", - min: 0, - max: EXPECTED_MAX_WEEKLY_CM_REPORTS, - }} - margin={{ - bottom: 40, - left: 60, - right: 40, - top: 5, - }} - theme={chart_theme} - /> -
-
- - percent_line_colours[id as keyof typeof percent_line_colours] - } - animate - curve="monotoneX" - enablePoints={false} - enableSlices="x" - axisBottom={{ - format: "%d %b %g", - tickValues: "every week", - }} - xFormat="time:%Y-%m-%d" - xScale={{ - type: "time", - min: format( - startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), - "yyyy-MM-dd", - ), - format: "%Y-%m-%d", - useUTC: false, - precision: "day", - }} - axisLeft={{ - format: (d) => `${Math.round(d * 100)}%`, // Format ticks as percentages - tickValues: 6, - }} - yFormat=" >-.0p" - yScale={{ - stacked: true, - type: "linear", - }} - margin={{ - bottom: 40, - left: 60, - right: 40, - top: 5, - }} - theme={chart_theme} - /> -
-
- ); -}; - -interface CMVotingGroupGraphProps { - vote_data: ReportAlignmentCount[]; - period: number; -} - -const CMVotingGroupGraph = ({ vote_data, period }: CMVotingGroupGraphProps): JSX.Element => { - if (!vote_data) { - vote_data = []; - } - - const aggregateDataByWeek = React.useMemo(() => { - const aggregated: { - [key: string]: { - escalated: number; - unanimous: number; - non_unanimous: number; - total: number; - }; - } = {}; - - vote_data.forEach(({ date, escalated, unanimous, non_unanimous }) => { - const weekStart = startOfWeek(new Date(date)).toISOString().slice(0, 10); // Get week start and convert to ISO string for key - - if (!aggregated[weekStart]) { - aggregated[weekStart] = { escalated: 0, unanimous: 0, non_unanimous: 0, total: 0 }; - } - aggregated[weekStart].escalated += escalated; - aggregated[weekStart].unanimous += unanimous; - aggregated[weekStart].non_unanimous += non_unanimous; - aggregated[weekStart].total += unanimous + non_unanimous; - }); - - return Object.entries(aggregated).map(([date, counts]) => ({ - date, - ...counts, - })); - }, [vote_data]); - - const totals_data = React.useMemo(() => { - return [ - { - id: "unanimous", - data: dropCurrentPeriod( - aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.unanimous, - })), - ), - }, - { - id: "escalated", - data: dropCurrentPeriod( - aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.escalated, - })), - ), - }, - { - id: "non-unanimous", - data: dropCurrentPeriod( - aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.non_unanimous, - })), - ), - }, - ]; - }, [aggregateDataByWeek]); - - const percent_data = React.useMemo( - () => [ - { - id: "unanimous", - data: aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.unanimous / week.total, - })), - }, - { - id: "escalated", - data: aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.escalated / week.total, - })), - }, - { - id: "non-unanimous", - data: aggregateDataByWeek.map((week) => ({ - x: week.date, - y: week.non_unanimous / week.total, - })), - }, - ], - [aggregateDataByWeek], - ); - - const chart_theme = - data.get("theme") === "light" // (Accessible theme TBD - this assumes accessible is dark for now) - ? { - /* nivo defaults work well with our light theme */ - } - : { - text: { fill: "#FFFFFF" }, - tooltip: { container: { color: "#111111" } }, - grid: { line: { stroke: "#444444" } }, - }; - - const line_colors = { - unanimous: "rgba(0, 128, 0, 1)", // green - escalated: "rgba(255, 165, 0, 1)", // orange - "non-unanimous": "rgba(255, 0, 0, 1)", // red - }; - - if (!totals_data[0].data.length) { - return
No activity yet
; - } - - return ( -
-
- line_colors[id as keyof typeof line_colors]} - animate - curve="monotoneX" - enablePoints={false} - enableSlices="x" - axisBottom={{ - format: "%d %b %g", - tickValues: "every week", - }} - xFormat="time:%Y-%m-%d" - xScale={{ - type: "time", - min: format( - startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), - "yyyy-MM-dd", - ), - format: "%Y-%m-%d", - useUTC: false, - precision: "day", - }} - axisLeft={{ - tickValues: Array.from( - { length: EXPECTED_MAX_WEEKLY_CM_REPORTS / Y_STEP_SIZE + 1 }, - (_, i) => i * Y_STEP_SIZE, - ), - }} - yScale={{ - stacked: false, - type: "linear", - min: 0, - max: EXPECTED_MAX_WEEKLY_CM_REPORTS, - }} - margin={{ - bottom: 40, - left: 60, - right: 40, - top: 5, - }} - theme={chart_theme} - /> -
-
- line_colors[id as keyof typeof line_colors]} - animate - curve="monotoneX" - enablePoints={false} - enableSlices="x" - axisBottom={{ - format: "%d %b %g", - tickValues: "every week", - }} - xFormat="time:%Y-%m-%d" - xScale={{ - type: "time", - min: format( - startOfWeekDateFns(subDays(new Date(), period), { weekStartsOn: 1 }), - "yyyy-MM-dd", - ), - format: "%Y-%m-%d", - useUTC: false, - precision: "day", - }} - axisLeft={{ - format: (d) => `${Math.round(d * 100)}%`, // Format ticks as percentages - tickValues: 6, - }} - yFormat=" >-.0p" - yScale={{ - stacked: false, - type: "linear", - max: 1, - }} - margin={{ - bottom: 40, - left: 60, - right: 40, - top: 5, - }} - theme={chart_theme} - /> -
-
- ); -}; - export function ReportsCenterCMDashboard(): JSX.Element { const user = useUser(); const [selectedTabIndex, setSelectedTabIndex] = React.useState(user.moderator_powers ? 0 : 1); const [vote_data, setVoteData] = React.useState(null); const [users_data, setUsersData] = React.useState(null); + const [system_data, setSystemData] = React.useState(null); - // `Tabs` isn't expecting the possibility that the initial tab is not zero. useEffect(() => { handleTabSelect(selectedTabIndex); }, []); @@ -524,6 +69,8 @@ export function ReportsCenterCMDashboard(): JSX.Element { fetchVoteData(); } else if (index === 3 && !users_data) { fetchUsersData(); + } else if (index === 4 && !system_data) { + fetchSystemData(); } }; @@ -549,6 +96,28 @@ export function ReportsCenterCMDashboard(): JSX.Element { }); }; + const fetchSystemData = () => { + get(`moderation/system_performance`) + .then((response) => { + const fetchedData: SystemPerformanceData = response; + const currentDate = new Date(); + + const performanceAsAge: SystemPerformanceData = Object.fromEntries( + Object.entries(fetchedData).map(([reportType, dateString]) => { + const date = new Date(dateString); + const age = Math.floor( + (currentDate.getTime() - date.getTime()) / (1000 * 60 * 60), + ); // age in hours + return [reportType, age.toString()]; + }), + ); + setSystemData(performanceAsAge); + }) + .catch((err) => { + console.error(err); + }); + }; + return ( - My Summary - Group Outcomes - Individual Votes - My Votes + + {pgettext("This is a title of a page showing summary graphs", "My Summary")} + + + {pgettext( + "This is a title of a page showing graphs of Community Moderation outcomes", + "Group Outcomes", + )} + + + {pgettext( + "This is a title of a page showing graphs of individual Community Moderators' votes", + "Individual Votes", + )} + + + {pgettext( + "This is a title of a page showing a Community Moderator how their votes turned out", + "My Votes", + )} + + + {pgettext( + "This is a title of a page showing graphs how the report system is performing", + "System Performance", + )} + - {/* A CM's Summary Pie Charts */} + {/* My Summary: A CM's Summary Pie Charts */}
{pgettext( @@ -583,26 +175,28 @@ export function ReportsCenterCMDashboard(): JSX.Element { - {/* The overall CM voting outcomes */} + {/* Group Outcomes: The overall CM voting outcomes */} - {vote_data - ? ["overall", "escaping", "stalling", "score_cheating"].map((report_type) => ( -
-

{report_type}

- {vote_data[report_type] ? ( - - ) : ( - "no data" - )} -
- )) - : "loading..."} + {vote_data ? ( + ["overall", "escaping", "stalling", "score_cheating"].map((report_type) => ( +
+

{report_type}

+ {vote_data[report_type] ? ( + + ) : ( + "no data" + )} +
+ )) + ) : ( +

{_("loading...")}

+ )}
- {/* Moderator view of each CM's vote categories */} + {/* Individual Votes: Moderator view of each CM's vote categories */} - {/* A CM's individual vote categories (My Votes) */} + {/* My Votes: A CM's individual vote categories */} + + {users_data ? ( + ["overall", "escaping", "stalling", "score_cheating"].map((report_type) => ( +
+

{report_type}

+ {users_data[report_type] ? ( + + ) : ( + "no data" + )} +
+ )) + ) : ( +

{_("loading...")}

+ )} +
+ + {/* System Performance: Info about how the report system is performing */} - {users_data - ? ["overall", "escaping", "stalling", "score_cheating"].map((report_type) => ( -
-

{report_type}

- {users_data[report_type] ? ( - - ) : ( - "no data" - )} -
- )) - : "loading..."} +

{_("System Performance - oldest open reports")}

+ {system_data ? ( +
    + {Object.entries(system_data).map(([reportType, age]) => { + // Cursor came up with this, there's probably a library or something we already have that can do this + const ageInHours = parseInt(age, 10); + let displayAge; + if (ageInHours < 24) { + displayAge = `${ageInHours} hour${ageInHours !== 1 ? "s" : ""}`; + } else { + const days = Math.floor(ageInHours / 24); + const hours = ageInHours % 24; + displayAge = `${days} day${days !== 1 ? "s" : ""}`; + if (hours > 0) { + displayAge += ` and ${hours} hour${hours !== 1 ? "s" : ""}`; + } + } + return ( +
  • + {reportType}: {displayAge} +
  • + ); + })} +
+ ) : ( +

{_("loading...")}

+ )}
);