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 (
+
+ );
+};
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"