diff --git a/dev.sh b/dev.sh index 44278d8..abc37ea 100755 --- a/dev.sh +++ b/dev.sh @@ -21,7 +21,7 @@ done # Build the docker containers if clean flag is set if [ "$clean" = true ]; then -# rm vingo/.env || true + rm vingo/.env || true rm vinvoor/.env || true docker compose -f docker-compose.yml build fi @@ -29,9 +29,9 @@ fi # Check for the required files -#if [ ! -f vingo/.env ]; then -# cp vingo/dev.env vingo/.env -#fi +if [ ! -f vingo/.env ]; then + cp vingo/dev.env vingo/.env +fi if [ ! -f vinvoor/.env ]; then cp vinvoor/dev.env vinvoor/.env fi diff --git a/docker-compose.yml b/docker-compose.yml index 4cb3511..767294a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - "host.docker.internal:host-gateway" zess-backend: + env_file: "vingo/.env" build: context: vingo dockerfile: Dockerfile.dev @@ -22,6 +23,7 @@ services: - ./vingo:/backend extra_hosts: - "host.docker.internal:host-gateway" + stop_grace_period: 1s depends_on: - zess-db diff --git a/vingo/Cargo.lock b/vingo/Cargo.lock index bf6ed25..637732e 100644 --- a/vingo/Cargo.lock +++ b/vingo/Cargo.lock @@ -1361,6 +1361,7 @@ name = "migration" version = "0.1.0" dependencies = [ "async-std", + "chrono", "sea-orm-migration", ] diff --git a/vingo/dev.env b/vingo/dev.env new file mode 100644 index 0000000..89f05e6 --- /dev/null +++ b/vingo/dev.env @@ -0,0 +1 @@ +DEBUG_LOGIN="TRUE" diff --git a/vingo/migration/Cargo.toml b/vingo/migration/Cargo.toml index 23a5d66..7108649 100644 --- a/vingo/migration/Cargo.toml +++ b/vingo/migration/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] async-std = { version = "1", features = ["attributes", "tokio1"] } +chrono = { version = "0.4.38", default-features = false, features = ["serde"] } [dependencies.sea-orm-migration] version = "1.0.0" @@ -18,3 +19,4 @@ features = [ "runtime-tokio-rustls", "with-chrono", ] + diff --git a/vingo/migration/src/m20240909_214352_create_seasons.rs b/vingo/migration/src/m20240909_214352_create_seasons.rs index bc8f153..c1dbaea 100644 --- a/vingo/migration/src/m20240909_214352_create_seasons.rs +++ b/vingo/migration/src/m20240909_214352_create_seasons.rs @@ -1,3 +1,4 @@ +use chrono::NaiveDate; use sea_orm_migration::{prelude::*, schema::*}; #[derive(DeriveMigrationName)] @@ -25,7 +26,22 @@ impl MigrationTrait for Migration { .col(date(Season::End)) .to_owned(), ) - .await + .await?; + + // insert the first season (which is all data) + let insert = Query::insert() + .into_table(Season::Table) + .columns([Season::Name, Season::Start, Season::End]) + .values_panic([ + "All".into(), + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap().into(), + NaiveDate::from_ymd_opt(3000, 1, 1).unwrap().into(), + ]) + .to_owned(); + + manager.exec_stmt(insert).await?; + + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { @@ -33,4 +49,4 @@ impl MigrationTrait for Migration { .drop_table(Table::drop().table(Season::Table).to_owned()) .await } -} \ No newline at end of file +} diff --git a/vingo/src/main.rs b/vingo/src/main.rs index 2493af7..3f3575f 100644 --- a/vingo/src/main.rs +++ b/vingo/src/main.rs @@ -101,18 +101,16 @@ fn authenticated_routes() -> Router { ) .route("/scans", get(scans::get_for_current_user)) .route("/leaderboard", get(leaderboard::get)) - .route("/settings/seasons/:season_id", post(settings::set_season)) + .route("/seasons", get(seasons::get_until_now)) + .route("/settings", get(settings::get).patch(settings::update)) .route_layer(from_fn(middleware::is_logged_in)) } fn admin_routes() -> Router { Router::new() - .route( - "/days", - get(days::get).post(days::add_multiple), - ) + .route("/days", get(days::get).post(days::add_multiple)) .route("/days/:day_id", delete(days::delete)) - .route("/seasons", get(seasons::get).post(seasons::add)) + .route("/seasons", get(seasons::get_all).post(seasons::add)) .route("/seasons/:season_id", delete(seasons::delete)) .route_layer(from_fn(middleware::is_admin)) } diff --git a/vingo/src/middleware.rs b/vingo/src/middleware.rs index 234a4e2..fa233d1 100644 --- a/vingo/src/middleware.rs +++ b/vingo/src/middleware.rs @@ -1,8 +1,4 @@ -use axum::{ - extract::Request, - middleware::Next, - response::{IntoResponse, Response}, -}; +use axum::{extract::Request, middleware::Next, response::IntoResponse}; use reqwest::StatusCode; use tower_sessions::Session; diff --git a/vingo/src/routes/auth.rs b/vingo/src/routes/auth.rs index c5b9a62..20244ee 100644 --- a/vingo/src/routes/auth.rs +++ b/vingo/src/routes/auth.rs @@ -5,7 +5,7 @@ use chrono::Local; use rand::distributions::{Alphanumeric, DistString}; use reqwest::StatusCode; use sea_orm::sea_query::OnConflict; -use sea_orm::{EntityTrait, Set, TryIntoModel}; +use sea_orm::{EntityTrait, Set}; use serde::{Deserialize, Serialize}; use tower_sessions::Session; use user::Model; @@ -133,10 +133,13 @@ pub async fn callback( .or_log((StatusCode::INTERNAL_SERVER_ERROR, "user insert error"))?; session.clear().await; - session.insert(SessionKeys::User.as_str(), db_user).await.or_log(( - StatusCode::INTERNAL_SERVER_ERROR, - "failed to insert user in session", - ))?; + session + .insert(SessionKeys::User.as_str(), db_user) + .await + .or_log(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to insert user in session", + ))?; Ok(Redirect::to(FRONTEND_URL)) } diff --git a/vingo/src/routes/days.rs b/vingo/src/routes/days.rs index ccb5547..e71a827 100644 --- a/vingo/src/routes/days.rs +++ b/vingo/src/routes/days.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Path, State}, Json, }; -use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeDelta, Weekday}; +use chrono::{Datelike, NaiveDate, TimeDelta, Weekday}; use reqwest::StatusCode; use sea_orm::{ActiveModelTrait, EntityTrait, Set, TransactionTrait}; use serde::{Deserialize, Serialize}; @@ -38,7 +38,6 @@ pub async fn add_multiple( let mut current_date = day_range.start_date; while current_date <= day_range.end_date { - if current_date.weekday() == Weekday::Sat || current_date.weekday() == Weekday::Sun { current_date += TimeDelta::days(1); continue; diff --git a/vingo/src/routes/scans.rs b/vingo/src/routes/scans.rs index 84a3b34..5336296 100644 --- a/vingo/src/routes/scans.rs +++ b/vingo/src/routes/scans.rs @@ -14,7 +14,7 @@ use tower_sessions::Session; use super::util::{ errors::{ResponseResult, ResultAndLogError}, - session::get_user, + session::{get_season, get_user}, }; const SCAN_KEY: &str = "bad_key"; @@ -24,10 +24,14 @@ pub async fn get_for_current_user( state: State, ) -> ResponseResult>> { let user = get_user(&session).await?; + let season = get_season(&session, &state).await?; let scans = Scan::find() .join(InnerJoin, scan::Relation::Card.def()) .join(InnerJoin, card::Relation::User.def()) .filter(user::Column::Id.eq(user.id)) + // scan time > start && scan_time < end + .filter(scan::Column::ScanTime.gte(season.start)) + .filter(scan::Column::ScanTime.lte(season.end)) .all(&state.db) .await .or_log((StatusCode::INTERNAL_SERVER_ERROR, "failed to get scans"))?; diff --git a/vingo/src/routes/seasons.rs b/vingo/src/routes/seasons.rs index 6c29830..379d985 100644 --- a/vingo/src/routes/seasons.rs +++ b/vingo/src/routes/seasons.rs @@ -2,9 +2,13 @@ use axum::{ extract::{Path, State}, Json, }; -use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeDelta, Weekday}; +use chrono::NaiveDate; + use reqwest::StatusCode; -use sea_orm::{ActiveModelTrait, EntityTrait, Set, TransactionTrait}; +use sea_orm::{sea_query::Expr, DatabaseConnection}; +use sea_orm::{ + ActiveModelTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, QueryTrait, Set, +}; use serde::{Deserialize, Serialize}; use crate::{ @@ -14,11 +18,39 @@ use crate::{ use super::util::errors::{ResponseResult, ResultAndLogError}; -pub async fn get(state: State) -> ResponseResult>> { - Ok(Json(Season::find().all(&state.db).await.or_log(( - StatusCode::INTERNAL_SERVER_ERROR, - "failed to get seasons", - ))?)) +#[derive(Debug, FromQueryResult, Serialize, Deserialize)] +pub struct SeasonGet { + id: i32, + name: String, + start: NaiveDate, + end: NaiveDate, + is_current: bool, +} + +pub async fn get_until_now(state: State) -> ResponseResult>> { + Ok(Json(db_seasons(&state.db, false).await?)) +} + +pub async fn get_all(state: State) -> ResponseResult>> { + Ok(Json(db_seasons(&state.db, true).await?)) +} + +pub async fn db_seasons(db: &DatabaseConnection, future: bool) -> ResponseResult> { + Ok(Season::find() + .column_as( + Expr::col(season::Column::Start) + .lte(Expr::current_date()) + .and(Expr::col(season::Column::End).gte(Expr::current_date())) + .and(Expr::col(season::Column::Id).ne(1)), + "is_current", + ) + .apply_if((!future).then_some(()), |query, _| { + query.filter(Expr::col(season::Column::Start).lt(Expr::current_date())) + }) + .into_model::() + .all(db) + .await + .or_log((StatusCode::INTERNAL_SERVER_ERROR, "failed to get seasons"))?) } #[derive(Debug, Serialize, Deserialize)] @@ -27,13 +59,19 @@ pub struct SeasonAddBody { start: NaiveDate, end: NaiveDate, } -pub async fn add(state: State, Json(new_season): Json) -> ResponseResult<()> { +pub async fn add( + state: State, + Json(new_season): Json, +) -> ResponseResult<()> { season::ActiveModel { name: Set(new_season.name), start: Set(new_season.start), end: Set(new_season.end), ..Default::default() - }.insert(&state.db).await.or_log((StatusCode::INTERNAL_SERVER_ERROR, "failed to insert season"))?; + } + .insert(&state.db) + .await + .or_log((StatusCode::INTERNAL_SERVER_ERROR, "failed to insert season"))?; Ok(()) } diff --git a/vingo/src/routes/settings.rs b/vingo/src/routes/settings.rs index cdfb67b..32b1710 100644 --- a/vingo/src/routes/settings.rs +++ b/vingo/src/routes/settings.rs @@ -1,17 +1,51 @@ -use axum::extract::{Path, State}; +use axum::{extract::State, Json}; use reqwest::StatusCode; +use sea_orm::EntityTrait; +use serde::{Deserialize, Serialize}; use tower_sessions::Session; -use crate::{ - entities::{prelude::*, *}, - AppState, -}; +use crate::{entities::prelude::*, AppState}; -use super::util::{errors::{ResponseResult, ResultAndLogError}, session::{self, SessionKeys}}; +use super::util::{ + errors::{ResponseResult, ResultAndLogError}, + session::{get_season, SessionKeys}, +}; +#[derive(Debug, Serialize, Deserialize)] +pub struct SettingsGetBody { + season: i32, +} +pub async fn get( + session: Session, + state: State, +) -> ResponseResult> { + let season = get_season(&session, &state).await?; + Ok(Json(SettingsGetBody { season: season.id })) +} -pub async fn set_season(state: State, session: Session, Path(season_id): Path) -> ResponseResult<()> { - session.insert(SessionKeys::Season.as_str(), season_id).await.or_log((StatusCode::INTERNAL_SERVER_ERROR, "failed to insert season into session"))?; +#[derive(Debug, Serialize, Deserialize)] +pub struct SettingsUpdateBody { + season: Option, +} +pub async fn update( + session: Session, + state: State, + Json(settings): Json, +) -> ResponseResult<()> { + if let Some(season_id) = settings.season { + let season = Season::find_by_id(season_id) + .one(&state.db) + .await + .or_log((StatusCode::INTERNAL_SERVER_ERROR, "failed to get season"))? + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "no season 0"))?; + session + .insert(SessionKeys::Season.as_str(), season) + .await + .or_log(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to insert season into session", + ))?; + } Ok(()) -} \ No newline at end of file +} diff --git a/vingo/src/routes/util/session.rs b/vingo/src/routes/util/session.rs index 10b8d0d..f558bce 100644 --- a/vingo/src/routes/util/session.rs +++ b/vingo/src/routes/util/session.rs @@ -1,13 +1,22 @@ +use std::{env, sync::LazyLock}; + +use axum::extract::State; +use chrono::Local; use reqwest::StatusCode; +use sea_orm::EntityTrait; use tower_sessions::Session; -use user::Model; use super::errors::{ResponseResult, ResultAndLogError}; -use crate::entities::*; +use crate::{ + entities::{prelude::*, *}, + AppState, +}; +static DEBUG_LOGIN: LazyLock = + LazyLock::new(|| env::var("DEBUG_LOGIN").unwrap_or("".into()) == "TRUE"); pub enum SessionKeys { User, - Season, + Season, } impl SessionKeys { @@ -19,8 +28,17 @@ impl SessionKeys { } } +pub async fn get_user(session: &Session) -> ResponseResult { + // act as always logged in + if *DEBUG_LOGIN { + return Ok(user::Model { + id: 1, + name: "vincentest".into(), + admin: true, + created_at: Local::now().fixed_offset(), + }); + } -pub async fn get_user(session: &Session) -> ResponseResult { session .get(SessionKeys::User.as_str()) .await @@ -28,13 +46,23 @@ pub async fn get_user(session: &Session) -> ResponseResult { .ok_or((StatusCode::UNAUTHORIZED, "Not logged in")) } -pub type SeasonId = i32; - -pub async fn get_season(session: &Session) -> ResponseResult { - Ok(session +pub async fn get_season( + session: &Session, + state: &State, +) -> ResponseResult { + let season: Option = session .get(SessionKeys::Season.as_str()) .await - .or_log((StatusCode::INTERNAL_SERVER_ERROR, "Failed to get session"))? - .or(Some(0)) // set season to 0 (all) if none is set - .expect("can't be none")) + .or_log((StatusCode::INTERNAL_SERVER_ERROR, "Failed to get session"))?; + + let season_or_default = match season { + Some(season_model) => season_model, + None => Season::find_by_id(1) + .one(&state.db) + .await + .or_log((StatusCode::INTERNAL_SERVER_ERROR, "failed to get season"))? + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "no season 1"))?, + }; + + Ok(season_or_default) } diff --git a/vinvoor/src/hooks/admin/useAdminDays.ts b/vinvoor/src/hooks/admin/useAdminDays.ts new file mode 100644 index 0000000..2f0afa7 --- /dev/null +++ b/vinvoor/src/hooks/admin/useAdminDays.ts @@ -0,0 +1,27 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { convertDayJSON, Day, DayJSON } from "../../types/days"; +import { deleteAPI, getApi, postApi } from "../../util/fetch"; +import { Dayjs } from "dayjs"; + +const ENDPOINT = "admin/days"; + +export const useAdminDays = () => + useQuery({ + queryKey: ["adminDays"], + queryFn: () => getApi(ENDPOINT, convertDayJSON), + retry: 1, + }); + +export const useAdminAddDay = () => + useMutation({ + mutationFn: (args: { startDate: Dayjs; endDate: Dayjs }) => + postApi(ENDPOINT, { + start_date: args.startDate.format("YYYY-MM-DD"), + end_date: args.endDate.format("YYYY-MM-DD"), + }), + }); + +export const useAdminDeleteDay = () => + useMutation({ + mutationFn: (id: number) => deleteAPI(`${ENDPOINT}/${id}`), + }); diff --git a/vinvoor/src/hooks/admin/useAdminSeason.ts b/vinvoor/src/hooks/admin/useAdminSeason.ts new file mode 100644 index 0000000..bc6fbab --- /dev/null +++ b/vinvoor/src/hooks/admin/useAdminSeason.ts @@ -0,0 +1,29 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { deleteAPI, getApi, postApi } from "../../util/fetch"; +import { Dayjs } from "dayjs"; +import { convertSeasonJSON, Season, SeasonJSON } from "../../types/seasons"; + +const ENDPOINT = "admin/seasons"; + +export const useAdminSeasons = () => { + return useQuery({ + queryKey: ["adminSeasons"], + queryFn: () => getApi(ENDPOINT, convertSeasonJSON), + retry: 1, + }); +}; + +export const useAdminAddSeason = () => + useMutation({ + mutationFn: (args: { name: string; startDate: Dayjs; endDate: Dayjs }) => + postApi(ENDPOINT, { + name: args.name, + start: args.startDate.format("YYYY-MM-DD"), + end: args.endDate.format("YYYY-MM-DD"), + }), + }); + +export const useAdminDeleteSeason = () => + useMutation({ + mutationFn: (id: number) => deleteAPI(`${ENDPOINT}/${id}`), + }); diff --git a/vinvoor/src/hooks/useDays.ts b/vinvoor/src/hooks/useDays.ts deleted file mode 100644 index 9df3290..0000000 --- a/vinvoor/src/hooks/useDays.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { convertDayJSON, Day, DayJSON } from "../types/days"; -import { deleteAPI, getApi } from "../util/fetch"; - -const ENDPOINT = "admin/days"; - -export const useDays = () => - useQuery({ - queryKey: ["days"], - queryFn: () => getApi(ENDPOINT, convertDayJSON), - retry: 1, - }); - -export const useDeleteDay = () => { - return useMutation({ - mutationFn: (id: number) => deleteAPI(`${ENDPOINT}/${id}`), - }); -}; diff --git a/vinvoor/src/hooks/useSeasons.ts b/vinvoor/src/hooks/useSeasons.ts new file mode 100644 index 0000000..f5221a7 --- /dev/null +++ b/vinvoor/src/hooks/useSeasons.ts @@ -0,0 +1,30 @@ +import { MutateOptions, useQuery } from "@tanstack/react-query"; +import { getApi } from "../util/fetch"; +import { convertSeasonJSON, Season, SeasonJSON } from "../types/seasons"; +import { usePatchSettings } from "./useSettings"; + +const ENDPOINT = "seasons"; + +export const useSeasons = () => { + return useQuery({ + queryKey: ["seasons"], + queryFn: () => getApi(ENDPOINT, convertSeasonJSON), + retry: 1, + }); +}; + +export const useSetSeason = () => { + const { mutate, ...other } = usePatchSettings(); + + const setSeason = ( + id: number, + options: MutateOptions< + unknown, + Error, + Record, + unknown + >, + ) => mutate({ season: id }, options); + + return { setSeason, ...other }; +}; diff --git a/vinvoor/src/hooks/useSettings.ts b/vinvoor/src/hooks/useSettings.ts index d621d49..3432b31 100644 --- a/vinvoor/src/hooks/useSettings.ts +++ b/vinvoor/src/hooks/useSettings.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getApi, patchApi } from "../util/fetch"; import { converSettingsJSON, Settings, SettingsJSON } from "../types/settings"; @@ -12,16 +12,14 @@ export const useSettings = () => }); export const usePatchSettings = () => { + const queryClient = useQueryClient(); + return useMutation({ - mutationFn: (args: { - scanInOut: boolean; - leaderboard: boolean; - public: boolean; - }) => - patchApi(ENDPOINT, { - scanInOut: args.scanInOut, - leaderboard: args.leaderboard, - public: args.public, + mutationFn: (args: Record) => + patchApi(ENDPOINT, args), + onSuccess: () => + queryClient.invalidateQueries({ + predicate: query => query.queryKey[0] !== "settings", }), }); }; diff --git a/vinvoor/src/navbar/NavBar.tsx b/vinvoor/src/navbar/NavBar.tsx index 0f80a0f..26febee 100644 --- a/vinvoor/src/navbar/NavBar.tsx +++ b/vinvoor/src/navbar/NavBar.tsx @@ -12,6 +12,7 @@ import { NavBarPages } from "./NavBarPages"; import { NavBarSandwich } from "./NavBarSandwich"; import { NavBarUserMenu } from "./NavBarUserMenu"; import { useUser } from "../hooks/useUser"; +import { NavBarSeasons } from "./NavBarSeasons"; export interface PageIcon { page: string; @@ -25,7 +26,7 @@ const navBarPages: PageIcon[] = [ ]; const userMenuPages: PageIcon[] = [ - // { page: "Settings", icon: }, + { page: "Settings", icon: }, ]; export const NavBar = () => { @@ -75,6 +76,12 @@ export const NavBar = () => { + {/* Display a season selector */} + + + + + {/* Display a dark mode switch and the user menu */} diff --git a/vinvoor/src/navbar/NavBarSeasons.tsx b/vinvoor/src/navbar/NavBarSeasons.tsx new file mode 100644 index 0000000..85c776b --- /dev/null +++ b/vinvoor/src/navbar/NavBarSeasons.tsx @@ -0,0 +1,92 @@ +import { useState, MouseEvent } from "react"; +import { useSeasons, useSetSeason } from "../hooks/useSeasons"; +import { useSettings } from "../hooks/useSettings"; +import { Button, IconButton, Menu, MenuItem, Typography } from "@mui/material"; +import { ArrowDropDown, Refresh } from "@mui/icons-material"; + +export const NavBarSeasons = () => { + const { data: seasons } = useSeasons(); + const { data: settings, refetch } = useSettings(); + const { setSeason } = useSetSeason(); + + const currentSeason = seasons?.find(season => season.isCurrent)?.id ?? -1; + + const [anchorElUser, setAnchorElUser] = useState( + undefined, + ); + + const handleOpenUserMenu = (event: MouseEvent) => { + setAnchorElUser(event.currentTarget); + }; + + const handleCloseUserMenu = () => { + setAnchorElUser(undefined); + }; + + const handleClickSeason = (id: number) => { + setSeason(id, { onSuccess: () => void refetch() }); + handleCloseUserMenu(); + }; + + const handleResetSeason = () => + setSeason(currentSeason, { + onSuccess: () => void refetch(), + }); + + return ( + <> + {seasons && settings && ( + <> + + + + + + {seasons.map(season => ( + handleClickSeason(season.id)} + > + {season.name} + + ))} + + + )} + + ); +}; diff --git a/vinvoor/src/overview/Overview.tsx b/vinvoor/src/overview/Overview.tsx index cf29416..7630146 100644 --- a/vinvoor/src/overview/Overview.tsx +++ b/vinvoor/src/overview/Overview.tsx @@ -1,6 +1,6 @@ import { Box, Paper, Stack, Switch, Typography } from "@mui/material"; import Grid from "@mui/material/Grid"; -import { useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Tooltip } from "react-tooltip"; import { BrowserView } from "../components/BrowserView"; import { LoadingSkeleton } from "../components/LoadingSkeleton"; @@ -10,12 +10,20 @@ import { Heatmap } from "./heatmap/Heatmap"; import { HeatmapVariant } from "./heatmap/types"; import { Streak } from "./streak/Streak"; import { useScans } from "../hooks/useScan"; +import { useSeasons } from "../hooks/useSeasons"; +import { useSettings } from "../hooks/useSettings"; export const Overview = () => { const scansQuery = useScans(); + const seasonsQuery = useSeasons(); + const settingsQuery = useSettings(); const [checked, setChecked] = useState(false); const daysRef = useRef(null); const [paperHeight, setPaperHeight] = useState(0); + const [heatmapDates, setHeatmapDates] = useState<[Date, Date]>([ + new Date(), + new Date(), + ]); const handleChange = (event: React.ChangeEvent) => { setChecked(event.target.checked); @@ -26,8 +34,24 @@ export const Overview = () => { setPaperHeight(daysRef.current.getBoundingClientRect().height); }); + useEffect(() => { + const currentSeason = seasonsQuery.data?.find( + season => season.id === settingsQuery.data?.season, + ); + + if (currentSeason) { + if (currentSeason.id === 1 && seasonsQuery.data) { + const seasons = [...seasonsQuery.data]; + seasons.sort((a, b) => a.start.getTime() - b.start.getTime()); + setHeatmapDates([seasons[1].start, new Date()]); + } else { + setHeatmapDates([currentSeason.start, currentSeason.end]); + } + } + }, [seasonsQuery.data, settingsQuery.data]); + return ( - + {scansQuery.data?.length ? ( @@ -61,8 +85,8 @@ export const Overview = () => { { const { data: settingsTruth, refetch } = useSettings(); - if (!settingsTruth) return null; // Can never happen + const { data: seasons } = useSeasons(); + if (!settingsTruth || !seasons) return null; // Can never happen const patchSettings = usePatchSettings(); const [settings, setSettings] = useState({ ...settingsTruth }); const { enqueueSnackbar } = useSnackbar(); const confirm = useConfirm(); - const handleChange = (event: ChangeEvent) => { + const handleSeasonChange = (event: SelectChangeEvent) => setSettings({ ...settings, - [event.target.name]: event.target.checked, + season: parseInt(event.target.value), }); - }; const handleSubmit = () => { - patchSettings.mutate( - { - scanInOut: settings.scanInOut, - leaderboard: settings.leaderboard, - public: settings.public, - }, - { - onSuccess: () => { - enqueueSnackbar(saveSuccess, { variant: "success" }); - void refetch(); - }, - onError: () => enqueueSnackbar(saveFailure, { variant: "error" }), + patchSettings.mutate(settings, { + onSuccess: () => { + enqueueSnackbar(saveSuccess, { variant: "success" }); + void refetch(); }, - ); + onError: () => enqueueSnackbar(saveFailure, { variant: "error" }), + }); }; const handleDelete = () => { @@ -81,6 +74,10 @@ export const Settings = () => { }); }; + useEffect(() => { + setSettings({ ...settingsTruth, season: settingsTruth.season }); + }, [settingsTruth.season]); + return ( { > - - {adjustableSettings.map(setting => ( - - } - label={ - - {setting.name} - - - - - } - key={setting.id} + + + - ))} - + + Select season: + + diff --git a/vinvoor/src/settings/admin/Admin.tsx b/vinvoor/src/settings/admin/Admin.tsx index 33b7155..246665b 100644 --- a/vinvoor/src/settings/admin/Admin.tsx +++ b/vinvoor/src/settings/admin/Admin.tsx @@ -1,6 +1,7 @@ import { Alert, Grid, Typography } from "@mui/material"; import { FC } from "react"; import { Days } from "./days/Days"; +import { Seasons } from "./seasons/Seasons"; export const Admin: FC = () => { return ( @@ -22,9 +23,12 @@ export const Admin: FC = () => { - + + + + ); }; diff --git a/vinvoor/src/settings/admin/days/Days.tsx b/vinvoor/src/settings/admin/days/Days.tsx index 5aa058f..b495276 100644 --- a/vinvoor/src/settings/admin/days/Days.tsx +++ b/vinvoor/src/settings/admin/days/Days.tsx @@ -2,10 +2,10 @@ import { Grid } from "@mui/material"; import { LoadingSkeleton } from "../../../components/LoadingSkeleton"; import { DaysAdd } from "./DaysAdd"; import { DaysTable } from "./DaysTable"; -import { useDays } from "../../../hooks/useDays"; +import { useAdminDays } from "../../../hooks/admin/useAdminDays"; export const Days = () => { - const daysQuery = useDays(); + const daysQuery = useAdminDays(); return ( @@ -15,12 +15,12 @@ export const Days = () => { columnSpacing={4} rowSpacing={6} > - - - - + + + + ); diff --git a/vinvoor/src/settings/admin/days/DaysAdd.tsx b/vinvoor/src/settings/admin/days/DaysAdd.tsx index b6b2727..26000e2 100644 --- a/vinvoor/src/settings/admin/days/DaysAdd.tsx +++ b/vinvoor/src/settings/admin/days/DaysAdd.tsx @@ -5,11 +5,14 @@ import dayjs, { Dayjs } from "dayjs"; import { useSnackbar } from "notistack"; import { Dispatch, SetStateAction, useState } from "react"; import { TypographyG } from "../../../components/TypographyG"; -import { postApi } from "../../../util/fetch"; -import { useDays } from "../../../hooks/useDays"; +import { + useAdminAddDay, + useAdminDays, +} from "../../../hooks/admin/useAdminDays"; export const DaysAdd = () => { - const { refetch } = useDays(); + const { refetch } = useAdminDays(); + const addDay = useAdminAddDay(); const [startDate, setStartDate] = useState(dayjs()); const [endDate, setEndDate] = useState(dayjs()); @@ -28,22 +31,22 @@ export const DaysAdd = () => { return; } - postApi("admin/days", { - start_date: startDate.format("YYYY-MM-DD"), - end_date: endDate.format("YYYY-MM-DD"), - }) - .then(() => { - enqueueSnackbar("successfully saved days", { - variant: "success", - }); - void refetch(); - }) - .catch(error => - // This is the admin page so just show the error - enqueueSnackbar(`Failed to save days: ${error}`, { - variant: "error", - }), - ); + addDay.mutate( + { startDate, endDate }, + { + onSuccess: () => { + enqueueSnackbar("successfully saved days", { + variant: "success", + }); + void refetch(); + }, + onError: error => + // This is the admin page so just show the error + enqueueSnackbar(`Failed to save days: ${error.message}`, { + variant: "error", + }), + }, + ); }; return ( diff --git a/vinvoor/src/settings/admin/days/DaysTable.tsx b/vinvoor/src/settings/admin/days/DaysTable.tsx index ff94a1a..42e3667 100644 --- a/vinvoor/src/settings/admin/days/DaysTable.tsx +++ b/vinvoor/src/settings/admin/days/DaysTable.tsx @@ -8,13 +8,18 @@ import { randomInt } from "../../../util/util"; import { DaysTableBody } from "./DaysTableBody"; import { DaysTableHead } from "./DaysTableHead"; import { DaysTableToolbar } from "./DaysTableToolbar"; -import { useDays, useDeleteDay } from "../../../hooks/useDays"; +import { + useAdminDays, + useAdminDeleteDay, +} from "../../../hooks/admin/useAdminDays"; +import { useAdminSeasons } from "../../../hooks/admin/useAdminSeason"; export const DaysTable = () => { - const { data: days, refetch } = useDays(); + const { data: days, refetch } = useAdminDays(); + const { data: seasons } = useAdminSeasons(); if (!days) return null; // Can never happen - const deleteDay = useDeleteDay(); + const deleteDay = useAdminDeleteDay(); const [rows, setRows] = useState(days); const [selected, setSelected] = useState([]); const [deleting, setDeleting] = useState(false); @@ -22,6 +27,8 @@ export const DaysTable = () => { const [dateFilter, setDateFilter] = useState< [Optional, Optional] >([undefined, undefined]); + const [seasonsFilter, setSeasonsFilter] = + useState>(undefined); const [weekdaysFilter, setWeekdaysFilter] = useState(false); const [weekendsFilter, setWeekendsFilter] = useState(false); @@ -36,6 +43,14 @@ export const DaysTable = () => { day.date.getTime() <= dateFilter[1]!.getTime(), ); } + if (seasonsFilter) { + const season = seasons?.find(season => season.id === seasonsFilter); + if (season) { + filteredDays = filteredDays.filter( + day => day.date >= season.start && day.date <= season.end, + ); + } + } if (weekdaysFilter) { filteredDays = filteredDays.filter( day => day.date.getDay() !== 0 && day.date.getDay() !== 6, @@ -120,7 +135,7 @@ export const DaysTable = () => { useEffect( () => setRows(filterDays()), - [days, dateFilter, weekdaysFilter, weekendsFilter], + [days, dateFilter, seasonsFilter, weekdaysFilter, weekendsFilter], ); return ( @@ -136,6 +151,8 @@ export const DaysTable = () => { {...{ dateFilter, setDateFilter, + seasonsFilter, + setSeasonsFilter, weekdaysFilter, setWeekdaysFilter, weekendsFilter, diff --git a/vinvoor/src/settings/admin/days/DaysTableBody.tsx b/vinvoor/src/settings/admin/days/DaysTableBody.tsx index eff25c9..b83de5a 100644 --- a/vinvoor/src/settings/admin/days/DaysTableBody.tsx +++ b/vinvoor/src/settings/admin/days/DaysTableBody.tsx @@ -10,7 +10,10 @@ import { import { useSnackbar } from "notistack"; import { FC, ReactNode } from "react"; import { Day, daysHeadCells } from "../../../types/days"; -import { useDays, useDeleteDay } from "../../../hooks/useDays"; +import { + useAdminDays, + useAdminDeleteDay, +} from "../../../hooks/admin/useAdminDays"; interface DaysTableBodyProps { rows: readonly Day[]; @@ -25,15 +28,15 @@ export const DaysTableBody: FC = ({ isSelected, deleting, }) => { - const { refetch } = useDays(); - const deleteCard = useDeleteDay(); + const { refetch } = useAdminDays(); + const deleteDay = useAdminDeleteDay(); const { enqueueSnackbar } = useSnackbar(); const handleClick = (id: number) => { if (isSelected(id)) handleSelect(id); // This will remove it from the selected list - deleteCard.mutate(id, { + deleteDay.mutate(id, { onSuccess: () => { enqueueSnackbar("Deleted streakday", { variant: "success" }); void refetch(); diff --git a/vinvoor/src/settings/admin/days/DaysTableToolbar.tsx b/vinvoor/src/settings/admin/days/DaysTableToolbar.tsx index d89534a..fa0f17d 100644 --- a/vinvoor/src/settings/admin/days/DaysTableToolbar.tsx +++ b/vinvoor/src/settings/admin/days/DaysTableToolbar.tsx @@ -1,14 +1,24 @@ -import { Checkbox, Stack, Typography } from "@mui/material"; +import { + Checkbox, + MenuItem, + Select, + SelectChangeEvent, + Stack, + Typography, +} from "@mui/material"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import dayjs, { Dayjs } from "dayjs"; import { ChangeEvent, Dispatch, FC, SetStateAction, useState } from "react"; import { Optional } from "../../../types/general"; -import { useDays } from "../../../hooks/useDays"; +import { useAdminDays } from "../../../hooks/admin/useAdminDays"; +import { useAdminSeasons } from "../../../hooks/admin/useAdminSeason"; interface DaysTableToolbarProps { dateFilter: [Optional, Optional]; setDateFilter: Dispatch, Optional]>>; + seasonsFilter: Optional; + setSeasonsFilter: Dispatch>>; weekdaysFilter: boolean; setWeekdaysFilter: Dispatch>; weekendsFilter: boolean; @@ -18,13 +28,16 @@ interface DaysTableToolbarProps { export const DaysTableToolbar: FC = ({ dateFilter, setDateFilter, + seasonsFilter: seasonFilter, + setSeasonsFilter: setSeasonFilter, weekdaysFilter, setWeekdaysFilter, weekendsFilter, setWeekendsFilter, }) => { - const { data: days } = useDays(); - if (!days) return null; // Can never happen + const { data: days } = useAdminDays(); + const { data: seasons } = useAdminSeasons(); + if (!days || !seasons) return null; // Can never happen const [startDate, setStartDate] = useState( days.length ? dayjs(days[0].date) : dayjs(), @@ -33,6 +46,9 @@ export const DaysTableToolbar: FC = ({ days.length ? dayjs(days[days.length - 1].date) : dayjs(), ); + const [selectedSeason, setSelectedSeason] = + useState>(seasonFilter); + const handleDateChange = ( date: Dayjs | null, setter: Dispatch>, @@ -53,13 +69,21 @@ export const DaysTableToolbar: FC = ({ else setDateFilter([undefined, undefined]); }; + const handleSeasonChange = (event: SelectChangeEvent) => + setSelectedSeason(parseInt(event.target.value)); + + const handleClickSeason = (event: ChangeEvent) => { + if (event.target.checked) setSeasonFilter(selectedSeason); + else setSeasonFilter(undefined); + }; + const handleClickBoolean = ( event: ChangeEvent, setter: Dispatch>, ) => setter(event.target.checked); return ( - + = ({ /> + + + Filter season + + { + const seasonsQuery = useAdminSeasons(); + + return ( + + + + + + + + + + + ); +}; diff --git a/vinvoor/src/settings/admin/seasons/SeasonsAdd.tsx b/vinvoor/src/settings/admin/seasons/SeasonsAdd.tsx new file mode 100644 index 0000000..f308d4a --- /dev/null +++ b/vinvoor/src/settings/admin/seasons/SeasonsAdd.tsx @@ -0,0 +1,98 @@ +import dayjs, { Dayjs } from "dayjs"; +import { + useAdminAddSeason, + useAdminSeasons, +} from "../../../hooks/admin/useAdminSeason"; +import { Dispatch, SetStateAction, useState } from "react"; +import { useSnackbar } from "notistack"; +import { Box, Button, Paper, Stack, TextField } from "@mui/material"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { TypographyG } from "../../../components/TypographyG"; + +export const SeasonsAdd = () => { + const { refetch } = useAdminSeasons(); + const addSeason = useAdminAddSeason(); + const [startDate, setStartDate] = useState(dayjs()); + const [endDate, setEndDate] = useState(dayjs()); + const [name, setName] = useState(""); + + const { enqueueSnackbar } = useSnackbar(); + + const handleDateChange = ( + date: Dayjs | null, + setter: Dispatch>, + ) => setter(date); + + const handleNameChange = (event: React.ChangeEvent) => + setName(event.target.value); + + const handleOnClick = () => { + if (!startDate || !endDate) { + enqueueSnackbar("Please select a start and end date", { + variant: "error", + }); + return; + } + + addSeason.mutate( + { name, startDate, endDate }, + { + onSuccess: () => { + enqueueSnackbar("successfully saved season", { + variant: "success", + }); + void refetch(); + }, + onError: error => + // This is the admin page so just show the error + enqueueSnackbar(`Failed to save seasib: ${error.message}`, { + variant: "error", + }), + }, + ); + }; + + return ( + + + Add Season + + + handleDateChange(newValue, setStartDate)} + /> + handleDateChange(newValue, setEndDate)} + /> + + + + + + + + + ); +}; diff --git a/vinvoor/src/settings/admin/seasons/SeasonsTable.tsx b/vinvoor/src/settings/admin/seasons/SeasonsTable.tsx new file mode 100644 index 0000000..1963ad3 --- /dev/null +++ b/vinvoor/src/settings/admin/seasons/SeasonsTable.tsx @@ -0,0 +1,118 @@ +import { Paper, Stack, Table, TableContainer } from "@mui/material"; +import { TypographyG } from "../../../components/TypographyG"; +import { ChangeEvent, useState } from "react"; +import { randomInt } from "../../../util/util"; +import { useSnackbar } from "notistack"; +import { + useAdminDeleteSeason, + useAdminSeasons, +} from "../../../hooks/admin/useAdminSeason"; +import { SeasonsTableHead } from "./SeasonsTableHead"; +import { SeasonsTableBody } from "./SeasonsTableBody"; + +export const SeasonsTable = () => { + const { data: seasons, refetch } = useAdminSeasons(); + if (!seasons) return null; // Can never happen + + const deleteSeason = useAdminDeleteSeason(); + const [selected, setSelected] = useState([]); + const [deleting, setDeleting] = useState(false); + + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + + const handleDelete = () => { + setDeleting(true); + const key = randomInt(); + enqueueSnackbar("Deleting...", { + variant: "info", + key: key, + persist: true, + }); + + const promises = selected.map(id => + deleteSeason.mutate(id, { + onError: (error: Error) => + enqueueSnackbar(`Failed to delete season ${id}: ${error.message}`, { + variant: "error", + }), + }), + ); + + void Promise.all(promises) + .then(() => { + closeSnackbar(key); + enqueueSnackbar( + `Deleted ${selected.length} season${selected.length > 1 ? "s" : ""}`, + { + variant: "success", + }, + ); + + setSelected([]); + setDeleting(false); + }) + .finally(() => void refetch()); + }; + + const handleSelect = (id: number) => { + const selectedIndex = selected.indexOf(id); + let newSelected: readonly number[] = []; + + switch (selectedIndex) { + case -1: + newSelected = newSelected.concat(selected, id); + break; + case 0: + newSelected = newSelected.concat(selected.slice(1)); + break; + case selected.length - 1: + newSelected = newSelected.concat(selected.slice(0, -1)); + break; + default: + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1), + ); + } + + setSelected(newSelected); + }; + + const handleSelectAll = (event: ChangeEvent) => { + if (event.target.checked) setSelected(seasons.map(season => season.id)); + else setSelected([]); + }; + + const isSelected = (id: number) => selected.includes(id); + + return ( + + + Edit Seasons + + + + + +
+
+
+
+
+ ); +}; diff --git a/vinvoor/src/settings/admin/seasons/SeasonsTableBody.tsx b/vinvoor/src/settings/admin/seasons/SeasonsTableBody.tsx new file mode 100644 index 0000000..0409a96 --- /dev/null +++ b/vinvoor/src/settings/admin/seasons/SeasonsTableBody.tsx @@ -0,0 +1,83 @@ +import { + Checkbox, + IconButton, + TableBody, + TableCell, + TableRow, + Typography, +} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { FC, ReactNode } from "react"; +import { useSnackbar } from "notistack"; +import { + useAdminDeleteSeason, + useAdminSeasons, +} from "../../../hooks/admin/useAdminSeason"; +import { seasonsHeadCells } from "../../../types/seasons"; + +interface SeasonsTableBodyProps { + handleSelect: (id: number) => void; + isSelected: (id: number) => boolean; + deleting: boolean; +} + +export const SeasonsTableBody: FC = ({ + handleSelect, + isSelected, + deleting, +}) => { + const { data: seasons, refetch } = useAdminSeasons(); + if (!seasons) return null; // Can never happen + + const deleteSeason = useAdminDeleteSeason(); + const { enqueueSnackbar } = useSnackbar(); + + const handleClick = (id: number) => { + console.log("Hi"); + if (isSelected(id)) handleSelect(id); // This will remove it from the selected list + + deleteSeason.mutate(id, { + onSuccess: () => { + enqueueSnackbar("Deleted season", { variant: "success" }); + void refetch(); + }, + onError: (error: Error) => + enqueueSnackbar(`Failed to delete season ${id}: ${error.message}`, { + variant: "error", + }), + }); + }; + + return ( + + {seasons.map(season => ( + + handleSelect(season.id)}> + + + {seasonsHeadCells.map(headCell => ( + + + {headCell.convert + ? headCell.convert(season[headCell.id]) + : (season[headCell.id] as ReactNode)} + + + ))} + + handleClick(season.id)} + > + + + + + ))} + + ); +}; diff --git a/vinvoor/src/settings/admin/seasons/SeasonsTableHead.tsx b/vinvoor/src/settings/admin/seasons/SeasonsTableHead.tsx new file mode 100644 index 0000000..91c40b5 --- /dev/null +++ b/vinvoor/src/settings/admin/seasons/SeasonsTableHead.tsx @@ -0,0 +1,61 @@ +import { + Box, + Button, + Checkbox, + TableCell, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { ChangeEvent, FC } from "react"; +import { seasonsHeadCells } from "../../../types/seasons"; + +interface SeasonsTableHeadProps { + rowCount: number; + numSelected: number; + onSelectAll: (event: ChangeEvent) => void; + handleDelete: () => void; + deleting: boolean; +} + +export const SeasonsTableHead: FC = ({ + rowCount, + numSelected, + onSelectAll, + handleDelete, + deleting, +}) => { + return ( + + + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAll} + /> + + {seasonsHeadCells.map(headCell => ( + + {headCell.label} + + ))} + + + + + + + + ); +}; diff --git a/vinvoor/src/types/days.ts b/vinvoor/src/types/days.ts index dd28134..6824549 100644 --- a/vinvoor/src/types/days.ts +++ b/vinvoor/src/types/days.ts @@ -22,6 +22,8 @@ export const convertDayJSON = (daysJSON: DayJSON[]): Day[] => })) .sort((a, b) => a.date.getTime() - b.date.getTime()); +// Table + export const daysHeadCells: readonly TableHeadCell[] = [ { id: "date", diff --git a/vinvoor/src/types/seasons.ts b/vinvoor/src/types/seasons.ts index fba1d43..a1baf91 100644 --- a/vinvoor/src/types/seasons.ts +++ b/vinvoor/src/types/seasons.ts @@ -1,4 +1,4 @@ -import { Base, BaseJSON } from "./general"; +import { Base, BaseJSON, TableHeadCell } from "./general"; // External @@ -6,6 +6,7 @@ export interface SeasonJSON extends BaseJSON { name: string; start: string; end: string; + is_current: boolean; } // Internal @@ -14,6 +15,7 @@ export interface Season extends Base { name: string; start: Date; end: Date; + isCurrent: boolean; } // Converters @@ -23,4 +25,30 @@ export const convertSeasonJSON = (seasonsJSON: SeasonJSON[]): Season[] => ...seasonJSON, start: new Date(seasonJSON.start), end: new Date(seasonJSON.end), + isCurrent: seasonJSON.is_current, })); + +// Table + +export const seasonsHeadCells: readonly TableHeadCell[] = [ + { + id: "name", + label: "Name", + align: "left", + padding: "normal", + }, + { + id: "start", + label: "Start Date", + align: "right", + padding: "normal", + convert: (value: Date) => value.toDateString(), + } as TableHeadCell, + { + id: "end", + label: "End Date", + align: "right", + padding: "normal", + convert: (value: Date) => value.toDateString(), + } as TableHeadCell, +]; diff --git a/vinvoor/src/types/settings.ts b/vinvoor/src/types/settings.ts index 64e6075..c121da1 100644 --- a/vinvoor/src/types/settings.ts +++ b/vinvoor/src/types/settings.ts @@ -15,30 +15,3 @@ export interface Settings { export const converSettingsJSON = (settingsJSON: SettingsJSON): Settings => ({ ...settingsJSON, }); - -// // Table - -interface AdjustableSettings { - id: keyof Settings; - name: string; - description: string; -} - -export const adjustableSettings: AdjustableSettings[] = [ - // { - // id: "scanInOut", - // name: "Scan in and out", - // description: - // "A second scan on the same day will be interpreted as a scan out", - // }, - // { - // id: "leaderboard", - // name: "Leaderboard", - // description: "Show yourself on the leaderboard", - // }, - // { - // id: "public", - // name: "Public", - // description: "Let others see you!", - // }, -];