From dd5ea1b1fcbb21c919a16bc70c6507b097c12f6b Mon Sep 17 00:00:00 2001 From: Arda Serdar Pektezol <1669855+pektezol@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:12:56 +0300 Subject: feat/frontend: homepage with timeline and recent scores --- frontend/src/pages/Homepage.tsx | 240 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 233 insertions(+), 7 deletions(-) (limited to 'frontend/src/pages/Homepage.tsx') diff --git a/frontend/src/pages/Homepage.tsx b/frontend/src/pages/Homepage.tsx index 3f30d9a..88290dd 100644 --- a/frontend/src/pages/Homepage.tsx +++ b/frontend/src/pages/Homepage.tsx @@ -1,20 +1,246 @@ import React from "react"; import { Helmet } from "react-helmet"; +import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from "recharts"; +import { API } from "../api/Api"; +import { PortalCountData, ScoreLog } from "../api/Stats"; +import "../css/Homepage.css"; +import { Link } from "react-router-dom"; const Homepage: React.FC = () => { + const [portalCountDataSingleplayer, setPortalCountDataSingleplayer] = React.useState([]); + const [portalCountDataMultiplayer, setPortalCountDataMultiplayer] = React.useState([]); + const [recentScores, setRecentScores] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [isLoadingScores, setIsLoadingScores] = React.useState(true); + const [selectedMode, setSelectedMode] = React.useState<"singleplayer" | "multiplayer">("singleplayer"); + + const processTimelineData = (data: PortalCountData[]): PortalCountData[] => { + if (data.length === 0) { + return []; + }; + const sortedData = [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const startDate = new Date(sortedData[0].date); + const endDate = new Date(sortedData[sortedData.length - 1].date); + + const result: PortalCountData[] = []; + let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1); + + let dataIndex = 0; + let currentCount = sortedData[0].count; + + while (currentDate <= endDate) { + while (dataIndex < sortedData.length && new Date(sortedData[dataIndex].date) <= currentDate) { + currentCount = sortedData[dataIndex].count; + dataIndex++; + } + result.push({ + date: currentDate.toISOString(), + count: currentCount + }); + const nextDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() + 7); + if (nextDate.getMonth() !== currentDate.getMonth()) { + currentDate = new Date(nextDate.getFullYear(), nextDate.getMonth(), 1); + } else { + currentDate = nextDate; + } + } + + return result; + }; + + const processedDataSingleplayer = React.useMemo( + () => processTimelineData(portalCountDataSingleplayer), + [portalCountDataSingleplayer] + ); + + const processedDataMultiplayer = React.useMemo( + () => processTimelineData(portalCountDataMultiplayer), + [portalCountDataMultiplayer] + ); + + const getYearlyTicks = (data: PortalCountData[]): string[] => { + if (data.length === 0) { + return []; + } + const seenYears = new Set(); + const ticks: string[] = []; + for (const point of data) { + const year = new Date(point.date).getFullYear(); + if (!seenYears.has(year)) { + seenYears.add(year); + ticks.push(point.date); + } + } + return ticks; + }; + + const yearlyTicksSingleplayer = React.useMemo( + () => getYearlyTicks(processedDataSingleplayer), + [processedDataSingleplayer] + ); + + const yearlyTicksMultiplayer = React.useMemo( + () => getYearlyTicks(processedDataMultiplayer), + [processedDataMultiplayer] + ); + + const fetchPortalCountData = async () => { + setIsLoading(true); + const data = await API.get_portal_count_history(); + setPortalCountDataSingleplayer(data?.timeline_singleplayer || []); + setPortalCountDataMultiplayer(data?.timeline_multiplayer || []); + setIsLoading(false); + }; + + const fetchRecentScores = async () => { + setIsLoadingScores(true); + const scores = await API.get_recent_scores(); + setRecentScores(scores); + setIsLoadingScores(false); + }; + + React.useEffect(() => { + fetchPortalCountData(); + fetchRecentScores(); + }, []); + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + return ( +
+

{new Date(payload[0].payload.date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + })}

+

{`Portal Count: ${payload[0].value}`}

+
+ ); + } + return null; + }; return ( -
+
LPHUB | Homepage -
-

-

Welcome to Least Portals Hub!

-

At the moment, LPHUB is in beta state. This means that the site has only the core functionalities enabled for providing both collaborative information and competitive leaderboards.

-

The website should feel intuitive to navigate around. For any type of feedback, reach us at LPHUB Discord server.

-

By using LPHUB, you agree that you have read the 'Leaderboard Rules' and the 'About LPHUB' pages.

+ +
+
+

Welcome to Least Portals Hub!

+

+ Your ultimate destination for Portal 2 Least Portals speedrunning. +

+
+ +
+
+
+
+

Least Portals World Record Timeline

+
+ + {isLoading ? ( +
+
+
+ ) : (selectedMode === "singleplayer" ? processedDataSingleplayer : processedDataMultiplayer).length > 0 ? ( + <> +
+ + + + + + + + + + { + const d = new Date(date); + return d.getFullYear().toString(); + }} + /> + + } /> + + + +
+
+ + +
+ + ) : ( +
+

No data available yet.

+
+ )} +
+ +
+
+

Recent Scores

+
+ + {isLoadingScores ? ( +
+
+
+ ) : recentScores.length > 0 ? ( +
+ {recentScores.map((score, index) => ( +
+
+ {score.user.user_name} +
+
+ {score.map.name} +
+
{score.score_count} { } portals
+
+ ))} +
+ ) : ( +
+

No Recent Scores.

+
+ )} +
+
+
+
); }; -- cgit v1.2.3