diff --git a/vinvoor/package.json b/vinvoor/package.json index 2f17aa0..28cc4c0 100644 --- a/vinvoor/package.json +++ b/vinvoor/package.json @@ -24,7 +24,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.23.1", - "react-router-hash-link": "^2.4.3" + "react-router-hash-link": "^2.4.3", + "react-tooltip": "^5.27.0" }, "devDependencies": { "@types/react": "^18.2.66", diff --git a/vinvoor/src/App.tsx b/vinvoor/src/App.tsx index 8371a6c..76278d2 100644 --- a/vinvoor/src/App.tsx +++ b/vinvoor/src/App.tsx @@ -1,8 +1,9 @@ import { Container } from "@mui/material"; import { useContext } from "react"; -import { Navigate, Outlet } from "react-router-dom"; +import { Navigate, Outlet, useOutlet } from "react-router-dom"; import { LoadingSkeleton } from "./components/LoadingSkeleton"; import { NavBar } from "./navbar/NavBar"; +import { Overview } from "./overview/Overview"; import { UserContext } from "./user/UserProvider"; import { WelcomePage } from "./WelcomePage"; @@ -11,13 +12,19 @@ export const App = () => { userState: { user, loading }, } = useContext(UserContext); + const outlet = useOutlet(); + return ( <> {user !== undefined ? ( - + outlet !== null ? ( + + ) : ( + + ) ) : ( <> @@ -29,5 +36,3 @@ export const App = () => { ); }; - -// TODO: Add link to the github repo diff --git a/vinvoor/src/cards/Cards.tsx b/vinvoor/src/cards/Cards.tsx index fa2c699..5570818 100644 --- a/vinvoor/src/cards/Cards.tsx +++ b/vinvoor/src/cards/Cards.tsx @@ -7,7 +7,7 @@ import { CardsTable } from "./CardsTable"; export const Cards = () => { const [cards, setCards] = useState([]); - const { loading, error: _ } = useFetch("cards", setCards); + const { loading } = useFetch("cards", setCards); return ( diff --git a/vinvoor/src/cards/CardsTableBody.tsx b/vinvoor/src/cards/CardsTableBody.tsx index 0035265..a6a64b0 100644 --- a/vinvoor/src/cards/CardsTableBody.tsx +++ b/vinvoor/src/cards/CardsTableBody.tsx @@ -63,6 +63,3 @@ export const CardsTableBody: FC = ({ ); }; - -// TODO: Go over all mouse events -// TODO: Move all components props diff --git a/vinvoor/src/leaderboard/Leaderboard.tsx b/vinvoor/src/leaderboard/Leaderboard.tsx index eed034c..ca9ebb6 100644 --- a/vinvoor/src/leaderboard/Leaderboard.tsx +++ b/vinvoor/src/leaderboard/Leaderboard.tsx @@ -10,7 +10,7 @@ export const Leaderboard = () => { const [leaderboardItems, setLeaderboardItems] = useState< readonly LeaderboardItem[] >([]); - const { loading, error: _ } = useFetch( + const { loading } = useFetch( "leaderboard", setLeaderboardItems ); @@ -24,7 +24,6 @@ export const Leaderboard = () => { /> - {/* */} diff --git a/vinvoor/src/leaderboard/LeaderboardTableBody.tsx b/vinvoor/src/leaderboard/LeaderboardTableBody.tsx index 81a286d..9fd6302 100644 --- a/vinvoor/src/leaderboard/LeaderboardTableBody.tsx +++ b/vinvoor/src/leaderboard/LeaderboardTableBody.tsx @@ -1,39 +1,14 @@ -import { - styled, - TableBody, - TableCell, - tableCellClasses, - TableRow, - Typography, -} from "@mui/material"; +import { TableBody, TableCell, TableRow, Typography } from "@mui/material"; +import { alpha } from "@mui/material/styles"; import { PodiumBronze, PodiumGold, PodiumSilver } from "mdi-material-ui"; -import { FC } from "react"; +import { FC, useContext } from "react"; import { leaderboardHeadCells, LeaderboardItem } from "../types/leaderboard"; +import { UserContext } from "../user/UserProvider"; interface LeaderboardTableBodyProps { leaderboardItems: readonly LeaderboardItem[]; } -const StyledTableCell = styled(TableCell)(({ theme }) => ({ - [`&.${tableCellClasses.head}`]: { - backgroundColor: theme.palette.common.black, - color: theme.palette.common.white, - }, - [`&.${tableCellClasses.body}`]: { - fontSize: 14, - }, -})); - -const StyledTableRow = styled(TableRow)(({ theme }) => ({ - "&:nth-of-type(odd)": { - backgroundColor: theme.palette.action.hover, - }, - // hide last border - "&:last-child td, &:last-child th": { - border: 0, - }, -})); - const getPosition = (position: number) => { switch (position) { case 1: @@ -50,13 +25,33 @@ const getPosition = (position: number) => { export const LeaderboardTableBody: FC = ({ leaderboardItems: rows, }) => { + const { + userState: { user }, + } = useContext(UserContext); + return ( - {rows.map((row) => { + {rows.map((row, index) => { return ( - + + theme.palette.action.hover, + }), + ...(row.username === user!.username && { + backgroundColor: (theme) => + alpha( + theme.palette.primary.main, + theme.palette.action.activatedOpacity + ), + }), + }} + > {leaderboardHeadCells.map((headCell) => ( - = ({ ) : ( {row[headCell.id]} )} - + ))} - + ); })} diff --git a/vinvoor/src/main.tsx b/vinvoor/src/main.tsx index 8f070ec..b79cca9 100644 --- a/vinvoor/src/main.tsx +++ b/vinvoor/src/main.tsx @@ -6,6 +6,7 @@ import { CssBaseline } from "@mui/material"; import React from "react"; import ReactDOM from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import "react-tooltip/dist/react-tooltip.css"; import { App } from "./App.tsx"; import { Cards } from "./cards/Cards.tsx"; import { ErrorPage } from "./errors/ErrorPage.tsx"; diff --git a/vinvoor/src/navbar/NavBarLogo.tsx b/vinvoor/src/navbar/NavBarLogo.tsx index 58c6e2a..f2986a0 100644 --- a/vinvoor/src/navbar/NavBarLogo.tsx +++ b/vinvoor/src/navbar/NavBarLogo.tsx @@ -1,4 +1,4 @@ -import { Button, SxProps, Theme, Typography } from "@mui/material"; +import { Box, Button, SxProps, Theme, Typography } from "@mui/material"; import { FC } from "react"; import { UnstyledLink } from "../components/UnstyledLink"; @@ -8,25 +8,28 @@ interface NavBarLogoProps { export const NavBarLogo: FC = ({ sx }) => { return ( - - - + + ZeSS + + + + + ); }; diff --git a/vinvoor/src/overview/Overview.tsx b/vinvoor/src/overview/Overview.tsx new file mode 100644 index 0000000..1da7469 --- /dev/null +++ b/vinvoor/src/overview/Overview.tsx @@ -0,0 +1,45 @@ +import { Box, Paper, Switch, Typography } from "@mui/material"; +import { useState } from "react"; +import { Tooltip } from "react-tooltip"; +import { LoadingSkeleton } from "../components/LoadingSkeleton"; +import { useFetch } from "../hooks/useFetch"; +import { Scan } from "../types/scans"; +import { Heatmap, HeatmapVariant } from "./heatmap/Heatmap"; + +export const Overview = () => { + const [scans, setScans] = useState([]); + const { loading } = useFetch("scans", setScans); + const [checked, setChecked] = useState(true); + + const dates = scans.map((scan) => new Date(scan.scanTime)); + + const handleChange = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + }; + + return ( + + + + Months + + Days + + + + + + ); +}; diff --git a/vinvoor/src/overview/heatmap/Heatmap.tsx b/vinvoor/src/overview/heatmap/Heatmap.tsx new file mode 100644 index 0000000..42dd7a7 --- /dev/null +++ b/vinvoor/src/overview/heatmap/Heatmap.tsx @@ -0,0 +1,253 @@ +import { FC } from "react"; +import "./heatmap.css"; +import { + dateTimeFormat, + DAYS_IN_WEEK, + getColumnCount, + getEmpty, + getHeight, + getMonthLabelCoordinates, + getSquareCoordinates, + getTransformForAllWeeks, + getTransformForColumn, + getTransformForMonthLabels, + getWidth, + MILLISECONDS_IN_ONE_DAY, + MONTH_LABELS, + shiftDate, + SQUARE_SIZE, +} from "./utils"; + +export interface HeatmapItem { + date: Date; + count: number; +} + +export enum HeatmapVariant { + DAYS, + MONTHS, +} + +interface HeatmapProps { + days: readonly Date[]; + startDate: Date; + endDate: Date; + variant: HeatmapVariant; +} + +const getAllValues = ( + days: readonly Date[], + startDate: Date, + endDate: Date, + variant: HeatmapVariant +): HeatmapItem[] => { + const values: readonly HeatmapItem[] = days.map((date) => ({ + date, + count: 1, + })); + if (variant === HeatmapVariant.DAYS) { + return Array.from( + { + length: + (endDate.getTime() - startDate.getTime()) / + MILLISECONDS_IN_ONE_DAY + + 1, + }, + (_, i) => { + const date = shiftDate(startDate, i); + return ( + values.find((v) => v.date.getTime() === date.getTime()) || { + date, + count: 0, + } + ); + } + ); + } else { + return Array.from( + { length: getColumnCount(startDate, endDate, HeatmapVariant.DAYS) }, + (_, i) => { + const start = shiftDate(startDate, i * DAYS_IN_WEEK); + const count = Array.from({ + length: DAYS_IN_WEEK, + }).reduce((sum, _, j) => { + const date = shiftDate(start, j); + const value = values.find( + (v) => v.date.getTime() === date.getTime() + ); + return sum + (value ? value.count : 0); + }, 0); + + return { date: start, count }; + } + ); + } +}; + +const getWeeksInMonth = (values: HeatmapItem[]): { [key: number]: number } => { + const startYear = values[0].date.getFullYear(); + return values.reduce( + (acc, value) => { + const index = + (value.date.getFullYear() - startYear) * 12 + + value.date.getMonth(); + acc[index] = (acc[index] || 0) + 1; + return acc; + }, + { 0: getEmpty(values[0].date, HeatmapVariant.MONTHS) } as { + [key: number]: number; + } + ); +}; + +const getClassNameForValue = (value: HeatmapItem, variant: HeatmapVariant) => { + if (variant === HeatmapVariant.DAYS) { + if (value.count > 0) { + return `color-active`; + } + + return `color-inactive`; + } else { + if (value.count <= 5) { + return `color-${value.count}`; + } + return "color-5"; + } +}; + +const getTooltipDataAttrsForDate = ( + value: HeatmapItem, + variant: HeatmapVariant +) => { + return { + "data-tooltip-id": "heatmap", + "data-tooltip-content": + variant === HeatmapVariant.DAYS + ? getTooltipDataAttrsForDays(value) + : getTooltipDataAttrsForMonths(value), + }; +}; + +const getTooltipDataAttrsForDays = (value: HeatmapItem) => { + return `${ + value.count > 0 ? "Present" : "Absent" + } on ${dateTimeFormat.format(value.date)}`; +}; + +const getTooltipDataAttrsForMonths = (value: HeatmapItem) => { + return `${value.count} scan${ + value.count !== 1 ? "s" : "" + } on the week of ${dateTimeFormat.format(value.date)}`; +}; + +export const Heatmap: FC = ({ + days, + startDate, + endDate, + variant, +}) => { + days.forEach((date) => date.setHours(0, 0, 0, 0)); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(0, 0, 0, 0); + + const values = getAllValues(days, startDate, endDate, variant); + + const viewBox = `0 0 ${getWidth(startDate, endDate, variant)} ${getHeight( + variant + )}`; + + const weeksInMonth = + variant === HeatmapVariant.MONTHS ? getWeeksInMonth(values) : {}; // Amount of weeks in each month + const columns = getColumnCount(startDate, endDate, variant); // Amount of columns of squares + const emptyStart = getEmpty(startDate, variant); // Amount of empty squares at the start + const emptyEnd = getEmpty(endDate, variant); // Amount of empty squares at the end + + let valueIndex = 0; + const renderSquare = (row: number, column: number) => { + if (column === 0 && row < emptyStart) { + return null; + } + + if (variant === HeatmapVariant.DAYS) { + if (column === columns - 1 && row > emptyEnd) { + return null; + } + } + + const value = values[valueIndex++]; + console.log(value); + + const [x, y] = getSquareCoordinates(row); + + return ( + + ); + }; + + const renderColumn = (column: number) => { + return ( + + {[ + ...Array( + variant === HeatmapVariant.DAYS + ? DAYS_IN_WEEK + : weeksInMonth[column] + ).keys(), + ].map((row) => renderSquare(row, column))} + + ); + }; + + const renderColumns = () => { + return [...Array(columns).keys()].map((column) => renderColumn(column)); + }; + + const renderMonthLabels = () => { + if (variant === HeatmapVariant.DAYS) { + return [...Array(columns).keys()].map((column) => { + const endOfWeek = shiftDate(startDate, column * DAYS_IN_WEEK); + const [x, y] = getMonthLabelCoordinates(column); + + return endOfWeek.getDate() >= 1 && + endOfWeek.getDate() <= DAYS_IN_WEEK ? ( + + {MONTH_LABELS[endOfWeek.getMonth()]} + + ) : null; + }); + } else { + return [...Array(columns).keys()].map((column) => { + if (column % 2 === 1) { + return null; + } + + const [x, y] = getMonthLabelCoordinates(column); + + return ( + + {MONTH_LABELS[column]} + + ); + }); + } + }; + + return ( + + + {renderMonthLabels()} + + {renderColumns()} + + ); +}; diff --git a/vinvoor/src/overview/heatmap/heatmap.css b/vinvoor/src/overview/heatmap/heatmap.css new file mode 100644 index 0000000..d9361cf --- /dev/null +++ b/vinvoor/src/overview/heatmap/heatmap.css @@ -0,0 +1,62 @@ +.heatmap text { + font-size: 10px; + fill: #aaa; +} + +.heatmap rect:hover { + stroke: #555; + stroke-width: 1px; +} + +/* + Gradients for months variant +*/ + +.heatmap .color-0 { + fill: #eeeeee; + stroke-width: 1px; + stroke: #fcce9f; +} +.heatmap .color-1 { + fill: #fcce9f; + stroke-width: 1px; + stroke: #fcbb79; +} + +.heatmap .color-2 { + fill: #fcbb79; + stroke-width: 1px; + stroke: #fa922a; +} +.heatmap .color-3 { + fill: #fa922a; + stroke-width: 1px; + stroke: #ff7f00; +} + +.heatmap .color-4 { + fill: #ff7f00; + stroke-width: 1px; + stroke: #ba5f02; +} +.heatmap .color-5 { + fill: #ba5f02; + stroke-width: 1px; + stroke: #ba5f02; +} + +/* + Active or not active for days variant +*/ + +.heatmap .color-inactive { + fill: #eeeeee; + stroke-width: 1px; + stroke: #fcce9f; +} + +.heatmap .color-active { + fill: #ff7f00; + stroke-width: 1px; + stroke: #ba5f02; +} diff --git a/vinvoor/src/overview/heatmap/utils.ts b/vinvoor/src/overview/heatmap/utils.ts new file mode 100644 index 0000000..641b87c --- /dev/null +++ b/vinvoor/src/overview/heatmap/utils.ts @@ -0,0 +1,125 @@ +// Exports + +import { HeatmapVariant } from "./Heatmap"; + +// Constants + +export const MILLISECONDS_IN_ONE_DAY = 24 * 60 * 60 * 1000; +export const DAYS_IN_WEEK = 7; +export const WEEKS_IN_MONTH = 5; +export const SQUARE_SIZE = 10; + +export const MONTH_LABELS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; +export const dateTimeFormat = new Intl.DateTimeFormat("en-GB", { + year: "2-digit", + month: "short", + day: "numeric", +}); + +// Labels + +export const getMonthLabelSize = () => { + return SQUARE_SIZE + MONTH_LABEL_GUTTER_SIZE; +}; + +export const getMonthLabelCoordinates = (column: number) => { + return [ + column * getSquareSize(), + getMonthLabelSize() - MONTH_LABEL_GUTTER_SIZE, + ]; +}; + +// Transforms + +export const getTransformForColumn = (column: number) => { + return `translate(${column * getSquareSize() + GUTTERSIZE}, 0)`; +}; + +export const getTransformForAllWeeks = () => { + return `translate(0, ${getMonthLabelSize()})`; +}; + +export const getTransformForMonthLabels = () => { + return `translate(0, 0)`; +}; + +export const getWidth = ( + startDate: Date, + endDate: Date, + variant: HeatmapVariant +) => { + return ( + getColumnCount(startDate, endDate, variant) * getSquareSize() + + GUTTERSIZE + ); +}; + +export const getHeight = (variant: HeatmapVariant) => { + if (variant === HeatmapVariant.DAYS) { + return DAYS_IN_WEEK * getSquareSize() + getMonthLabelSize(); + } else { + return WEEKS_IN_MONTH * getSquareSize() + getMonthLabelSize(); + } +}; + +// Coordinate + +export const getSquareCoordinates = (dayIndex: number) => { + return [0, dayIndex * getSquareSize()]; +}; + +// Utils + +export const getEmpty = (date: Date, variant: HeatmapVariant) => { + if (variant === HeatmapVariant.DAYS) { + return (date.getDay() + DAYS_IN_WEEK - 1) % DAYS_IN_WEEK; + } else { + return Math.floor((date.getDate() - 1) / DAYS_IN_WEEK); + } +}; + +export const getColumnCount = ( + startDate: Date, + endDate: Date, + variant: HeatmapVariant +) => { + if (variant === HeatmapVariant.DAYS) { + return Math.ceil( + (endDate.getTime() - startDate.getTime()) / + (DAYS_IN_WEEK * MILLISECONDS_IN_ONE_DAY) + ); + } else { + return ( + (endDate.getFullYear() - startDate.getFullYear()) * 12 + + (endDate.getMonth() - startDate.getMonth() + 1) + ); + } +}; + +export const shiftDate = (date: Date, numDays: number) => { + const newDate = new Date(date); + newDate.setDate(newDate.getDate() + numDays); + return newDate; +}; + +// Local functions + +const GUTTERSIZE = 5; +const MONTH_LABEL_GUTTER_SIZE = 4; + +const getSquareSize = () => { + return SQUARE_SIZE + GUTTERSIZE; +}; diff --git a/vinvoor/src/types/scans.ts b/vinvoor/src/types/scans.ts new file mode 100644 index 0000000..e9cc504 --- /dev/null +++ b/vinvoor/src/types/scans.ts @@ -0,0 +1,4 @@ +export interface Scan { + scanTime: string; + card: string; +} diff --git a/vinvoor/yarn.lock b/vinvoor/yarn.lock index 78fe7ca..5d56e0c 100644 --- a/vinvoor/yarn.lock +++ b/vinvoor/yarn.lock @@ -384,7 +384,7 @@ dependencies: "@floating-ui/utils" "^0.2.0" -"@floating-ui/dom@^1.0.0": +"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.6.1": version "1.6.5" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.5.tgz#323f065c003f1d3ecf0ff16d2c2c4d38979f4cb9" integrity sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw== @@ -1016,6 +1016,11 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +classnames@^2.3.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clsx@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" @@ -1789,6 +1794,14 @@ react-router@6.23.1: dependencies: "@remix-run/router" "1.16.1" +react-tooltip@^5.27.0: + version "5.27.0" + resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.27.0.tgz#d502d1055b259c26b10ebfc6d925621f2afd3119" + integrity sha512-JXROcdfCEbCqkAkh8LyTSP3guQ0dG53iY2E2o4fw3D8clKzziMpE6QG6CclDaHELEKTzpMSeAOsdtg0ahoQosw== + dependencies: + "@floating-ui/dom" "^1.6.1" + classnames "^2.3.0" + react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"