diff --git a/.eslintrc.js b/.eslintrc.js index 71381e7f..54203233 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -38,5 +38,11 @@ module.exports = { "@typescript-eslint/no-unused-vars": "warn", "@typescript-eslint/triple-slash-reference": "off", "prettier/prettier": "warn", + "padding-line-between-statements": [ + "warn", + { blankLine: "always", prev: "block-like", next: "function" }, + { blankLine: "always", prev: "const", next: "function" }, + { blankLine: "always", prev: "function", next: "return" }, + ], }, }; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 13ec86e3..bd215cf6 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -14,8 +14,8 @@ "@mui/lab": "5.0.0-alpha.117", "@mui/material": "^5.11.6", "@shared/browserlogger": "*", - "@shared/socket": "*", "@shared/configuration": "*", + "@shared/socket": "*", "axios": "^1.6.0", "lodash": "^4.17.21", "nanoid": "^4.0.0", diff --git a/packages/frontend/src/common/components/buttons/ActionButton.tsx b/packages/frontend/src/common/components/buttons/ActionButton.tsx index 79e37607..5c3a8935 100644 --- a/packages/frontend/src/common/components/buttons/ActionButton.tsx +++ b/packages/frontend/src/common/components/buttons/ActionButton.tsx @@ -1,14 +1,13 @@ import { Button, ButtonProps, useTheme } from "@mui/material"; -import React from "react"; +import React, { MouseEventHandler } from "react"; interface ActionButtonProps extends ButtonProps { - onClick: () => void; + onClick: MouseEventHandler; label: string; - isDisabled?: boolean; icon?: React.ReactNode; } -export function ActionButton({ onClick, isDisabled, label, icon, ...props }: ActionButtonProps) { +export function ActionButton({ onClick, label, icon, ...props }: ActionButtonProps) { const theme = useTheme(); return ( @@ -16,15 +15,15 @@ export function ActionButton({ onClick, isDisabled, label, icon, ...props }: Act {...props} variant="contained" onClick={onClick} - disabled={isDisabled} sx={{ m: 1, borderRadius: theme.spacing(2), boxShadow: "0px 5px 10px 0px rgba(0, 0, 0, 0.5)", whiteSpace: "nowrap", width: theme.spacing(22), + ...props.sx, }} - startIcon={icon ?? undefined} + startIcon={icon} fullWidth > {label} diff --git a/packages/frontend/src/poker/components/buttons/PokerResultButton.tsx b/packages/frontend/src/poker/components/buttons/PokerResultButton.tsx index 040f0f27..13223a44 100644 --- a/packages/frontend/src/poker/components/buttons/PokerResultButton.tsx +++ b/packages/frontend/src/poker/components/buttons/PokerResultButton.tsx @@ -20,7 +20,7 @@ export function PokerResultButton() { } /> ); diff --git a/packages/frontend/src/poker/components/buttons/ResetVotesButton.tsx b/packages/frontend/src/poker/components/buttons/ResetVotesButton.tsx index 7aa69bb9..df4eed26 100644 --- a/packages/frontend/src/poker/components/buttons/ResetVotesButton.tsx +++ b/packages/frontend/src/poker/components/buttons/ResetVotesButton.tsx @@ -20,7 +20,7 @@ export function ResetVotesButton() { } /> diff --git a/packages/frontend/src/poker/components/dialogs/CreatePokerSessionDialog.tsx b/packages/frontend/src/poker/components/dialogs/CreatePokerSessionDialog.tsx index 9cb11e3d..62bb5239 100644 --- a/packages/frontend/src/poker/components/dialogs/CreatePokerSessionDialog.tsx +++ b/packages/frontend/src/poker/components/dialogs/CreatePokerSessionDialog.tsx @@ -73,6 +73,7 @@ export function CreatePokerSessionDialog() { redirectToRoom(roomId); handleClose(); } + return ( + ); } diff --git a/packages/frontend/src/retro/components/TimePicker.tsx b/packages/frontend/src/retro/components/TimePicker.tsx new file mode 100644 index 00000000..39a3e7a6 --- /dev/null +++ b/packages/frontend/src/retro/components/TimePicker.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { FlexBox } from "../../common/components/FlexBox"; +import { TextInput } from "../../common/components/TextInput"; +import IncrementTimerButton from "./buttons/IncrementTimerButton"; +interface TimePickerProps { + minutes: string; + seconds: string; + disabled: boolean; + isMinutesError: boolean; + isSecondsError: boolean; + onSecondsChange: (event: React.ChangeEvent) => void; + onMinutesChange: (event: React.ChangeEvent) => void; + onSubmit: () => void; + onTimerIncrement: (increment: number) => void; +} +export function TimePicker({ + minutes, + seconds, + disabled, + isMinutesError, + isSecondsError, + onSecondsChange, + onMinutesChange, + onSubmit, + onTimerIncrement, +}: TimePickerProps) { + return ( + <> + + + + + + + + + + + ); +} diff --git a/packages/frontend/src/retro/components/buttons/CreateColumnButton.tsx b/packages/frontend/src/retro/components/buttons/CreateColumnButton.tsx index a794d4f6..53211ab2 100644 --- a/packages/frontend/src/retro/components/buttons/CreateColumnButton.tsx +++ b/packages/frontend/src/retro/components/buttons/CreateColumnButton.tsx @@ -16,7 +16,7 @@ export function CreateColumnButton() { } /> diff --git a/packages/frontend/src/retro/components/buttons/ImportRetroMenuItem.tsx b/packages/frontend/src/retro/components/buttons/ImportRetroMenuItem.tsx index 26d7c605..bf6cd062 100644 --- a/packages/frontend/src/retro/components/buttons/ImportRetroMenuItem.tsx +++ b/packages/frontend/src/retro/components/buttons/ImportRetroMenuItem.tsx @@ -3,7 +3,7 @@ import { Publish } from "@mui/icons-material"; import { ListItemIcon, ListItemText, MenuItem } from "@mui/material"; import { useUserContext } from "../../../common/context/UserContext"; import { RetroSchemaV1 } from "../../types/retroSchema"; -import { RetroState } from "../../types/retroTypes"; +import { RetroState, TimerStatus } from "../../types/retroTypes"; import { useRetroContext } from "../../context/RetroContext"; import { isModerator } from "../../../common/utils/participantsUtils"; @@ -29,6 +29,8 @@ export function ImportRetroMenuItem() { participants: {}, waitingList: {}, isVotingEnabled: false, + timerStatus: TimerStatus.STOPPED, + timerDuration: 0, }; handleSetRetroState(retro); } diff --git a/packages/frontend/src/retro/components/buttons/IncrementTimerButton.tsx b/packages/frontend/src/retro/components/buttons/IncrementTimerButton.tsx new file mode 100644 index 00000000..7d07dd3d --- /dev/null +++ b/packages/frontend/src/retro/components/buttons/IncrementTimerButton.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ActionButton } from "../../../common/components/buttons/ActionButton"; + +interface IncrementTimerButtonProps { + onTimerIncrement: (amount: number) => void; + minutesToIncrement: number; +} +export default function IncrementTimerButton({ + onTimerIncrement, + minutesToIncrement, +}: IncrementTimerButtonProps) { + return ( + { + onTimerIncrement(minutesToIncrement); + }} + /> + ); +} diff --git a/packages/frontend/src/retro/components/buttons/ToggleTimerDialogButton.tsx b/packages/frontend/src/retro/components/buttons/ToggleTimerDialogButton.tsx new file mode 100644 index 00000000..46d046fb --- /dev/null +++ b/packages/frontend/src/retro/components/buttons/ToggleTimerDialogButton.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Alarm, SnoozeOutlined } from "@mui/icons-material"; +import { isModerator } from "../../../common/utils/participantsUtils"; +import { TimerDialog } from "../dialogs/TimerDialog"; +import { useRetroContext } from "../../context/RetroContext"; +import { useUserContext } from "../../../common/context/UserContext"; +import { useDialog } from "../../../common/hooks/useDialog"; +import { useTimer } from "../../hooks/useTimer"; + +import { TimerStatus } from "../../types/retroTypes"; +import { WiggleActionButton } from "./WiggleActionButton"; +import useTimedEffect from "../../hooks/useTimedEffect"; + +export function ToggleTimerDialogButton() { + const { isOpen, closeDialog, openDialog } = useDialog(); + const { retroState } = useRetroContext(); + const { timerStatus } = retroState; + const { user } = useUserContext(); + + const { isEffectActive, startEffect } = useTimedEffect({ effectLength: 3000 }); + const { minutes, seconds, remainingTimeLabel } = useTimer({ onTimerFinish: startEffect }); + + function handleOpenDialog() { + if (!isModerator(user)) return; + openDialog(); + } + + if (!isModerator(user) && timerStatus === TimerStatus.STOPPED && !isEffectActive) return null; + + return ( + <> + : } + color={ + timerStatus === TimerStatus.PAUSED + ? "info" + : timerStatus === TimerStatus.RUNNING || isEffectActive + ? "error" + : undefined + } + isWiggling={isEffectActive} + /> + + + ); +} diff --git a/packages/frontend/src/retro/components/buttons/WiggleActionButton.tsx b/packages/frontend/src/retro/components/buttons/WiggleActionButton.tsx new file mode 100644 index 00000000..5b60d7ed --- /dev/null +++ b/packages/frontend/src/retro/components/buttons/WiggleActionButton.tsx @@ -0,0 +1,43 @@ +import React, { MouseEventHandler } from "react"; +import { ButtonProps } from "@mui/material"; +import { keyframes } from "@emotion/react"; +import { ActionButton } from "../../../common/components/buttons/ActionButton"; + +interface WiggleActionButtonProps extends ButtonProps { + onClick: MouseEventHandler; + label: string; + icon?: React.ReactNode; + isWiggling: boolean; +} + +export function WiggleActionButton({ + onClick, + label, + icon, + isWiggling, + ...props +}: WiggleActionButtonProps) { + const wiggle = keyframes` + 0%, 20%, 40%, 60%, 80%, 100% { + transform: rotate(0deg); + } + 5%, 25%, 45%, 65%, 85% { + transform: rotate(5deg); + } + 10%, 30%, 50%, 70%, 90% { + transform: rotate(-5deg); + } + 15%, 35%, 55%, 75%, 95% { + transform: rotate(0deg); + } + `; + return ( + + ); +} diff --git a/packages/frontend/src/retro/components/columns/column-header/SortColumnMenuItem.tsx b/packages/frontend/src/retro/components/columns/column-header/SortColumnMenuItem.tsx index 61e096c9..89d159dc 100644 --- a/packages/frontend/src/retro/components/columns/column-header/SortColumnMenuItem.tsx +++ b/packages/frontend/src/retro/components/columns/column-header/SortColumnMenuItem.tsx @@ -11,6 +11,7 @@ interface SortColumnMenuItemProps { export const SortColumnMenuItem = React.forwardRef( ({ column }: SortColumnMenuItemProps, ref: any) => { const { handleSortCardsByVotesDescending } = useRetroContext(); + function sortByVotesDescending() { handleSortCardsByVotesDescending(column.index); } diff --git a/packages/frontend/src/retro/components/dialogs/QrCodeDialog.tsx b/packages/frontend/src/retro/components/dialogs/QrCodeDialog.tsx index 94dd9f18..b8b6f444 100644 --- a/packages/frontend/src/retro/components/dialogs/QrCodeDialog.tsx +++ b/packages/frontend/src/retro/components/dialogs/QrCodeDialog.tsx @@ -18,6 +18,7 @@ export function QrCodeDialog({ isOpen, close }: DialogProps) { async function onRendered() { await QRCode.toCanvas(qrCanvas.current, window.location.href); } + return ( + Set Timer + + + + + + {isTimerRunning && } + {isTimerPaused && } + + {isTimerStopped ? "Start" : "Stop"} + + + + ); +} diff --git a/packages/frontend/src/retro/context/RetroContext.tsx b/packages/frontend/src/retro/context/RetroContext.tsx index 543f0b25..9ef862a2 100644 --- a/packages/frontend/src/retro/context/RetroContext.tsx +++ b/packages/frontend/src/retro/context/RetroContext.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useReducer } from "react"; import { usePeerToPeer } from "../../common/hooks/usePeerToPeer"; -import { RetroState } from "../types/retroTypes"; +import { RetroState, TimerStatus } from "../types/retroTypes"; import { CardRemoveUpvoteAction, CardUpvoteAction, @@ -16,6 +16,7 @@ import { RetroAction, SetRetroStateAction, SortCardsByVotesDescendingAction, + StartTimerAction, ToggleCardDiscussedAction, ToggleColumnBlurAction, UnhighlightCardAction, @@ -47,6 +48,8 @@ const initialState: RetroState = { participants: {}, waitingList: {}, isVotingEnabled: false, + timerStatus: TimerStatus.STOPPED, + timerDuration: 0, }; export interface RetroContextValues { @@ -79,6 +82,11 @@ export interface RetroContextValues { handleAcceptJoinUser: (userId: string) => void; handleAddToWaitingList: (payload: AddToWaitingListAction["payload"]) => void; handleIsVotingEnabledChanged: (isEnabled: boolean) => void; + handleStartTimer: (duration: number) => void; + handlePauseTimer: () => void; + handleChangeTimer: (duration: number) => void; + handleStopTimer: () => void; + handleResumeTimer: () => void; } export const RetroContext = React.createContext(undefined!); @@ -221,6 +229,26 @@ export function RetroContextProvider(props: RetroContextProviderProps) { dispatchAndBroadcast({ type: "IS_VOTING_ENABLED_CHANGED", isEnabled }); } + function handleStartTimer(duration: StartTimerAction["duration"]) { + dispatchAndBroadcast({ type: "START_TIMER", duration }); + } + + function handlePauseTimer() { + dispatchAndBroadcast({ type: "PAUSE_TIMER" }); + } + + function handleStopTimer() { + dispatchAndBroadcast({ type: "STOP_TIMER" }); + } + + function handleResumeTimer() { + dispatchAndBroadcast({ type: "START_TIMER", duration: state.timerDuration }); + } + + function handleChangeTimer(duration: StartTimerAction["duration"]) { + dispatchAndBroadcast({ type: "CHANGE_TIMER", duration }); + } + const resetRetroState = useCallback(() => { dispatch({ type: "SET_RETRO_STATE", payload: initialState }); }, []); @@ -255,6 +283,11 @@ export function RetroContextProvider(props: RetroContextProviderProps) { handleAcceptJoinUser: acceptJoinUser, handleAddToWaitingList, handleIsVotingEnabledChanged, + handleStartTimer, + handlePauseTimer, + handleStopTimer, + handleChangeTimer, + handleResumeTimer, }; return {props.children}; diff --git a/packages/frontend/src/retro/hooks/useTimedEffect.ts b/packages/frontend/src/retro/hooks/useTimedEffect.ts new file mode 100644 index 00000000..9235d7bc --- /dev/null +++ b/packages/frontend/src/retro/hooks/useTimedEffect.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; + +interface useTimedEffectProps { + effectLength: number; +} +export default function useTimedEffect({ effectLength }: useTimedEffectProps) { + const [isEffectActive, setIsEffectActive] = useState(false); + + useEffect(() => { + if (isEffectActive) { + const timeout = setTimeout(() => { + setIsEffectActive(false); + }, effectLength); + return () => { + clearTimeout(timeout); + }; + } + }, [isEffectActive, effectLength]); + + function startEffect() { + setIsEffectActive(true); + } + + return { + isEffectActive, + startEffect, + }; +} diff --git a/packages/frontend/src/retro/hooks/useTimer.ts b/packages/frontend/src/retro/hooks/useTimer.ts new file mode 100644 index 00000000..cf16fcd1 --- /dev/null +++ b/packages/frontend/src/retro/hooks/useTimer.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from "react"; +import { useRetroContext } from "../context/RetroContext"; +import { TimerStatus } from "../types/retroTypes"; + +interface useTimerProps { + onTimerFinish: () => void; +} + +export function useTimer({ onTimerFinish }: useTimerProps) { + const { retroState, handleStopTimer } = useRetroContext(); + const { timerStatus, timerDuration } = retroState; + const [timeRunning, setTimeRunning] = useState(0); + const intervalRef = useRef(); + + const remainingTime = timerDuration - timeRunning; + const minutes = Math.floor(remainingTime / 1000 / 60); + const seconds = Math.round((remainingTime / 1000) % 60); + + const labelOptions: Intl.DateTimeFormatOptions = { + minute: "numeric", + second: "numeric", + hourCycle: "h23", + hour: minutes > 59 ? "numeric" : undefined, + }; + + function createLabel() { + const date = new Date(0); + date.setHours(0, 0, 0); + date.setTime(date.getTime() + remainingTime); + return new Intl.DateTimeFormat("de-DE", labelOptions).format(date); + } + + useEffect(() => { + setTimeRunning(0); + }, [timerDuration]); + + useEffect(() => { + if (timerStatus === TimerStatus.RUNNING && remainingTime <= 0) { + handleStopTimer(); + } + }, [handleStopTimer, remainingTime, timerStatus]); + + useEffect(() => { + if (timerStatus === TimerStatus.RUNNING && !intervalRef.current) { + intervalRef.current = setInterval(() => { + setTimeRunning((timeRunning) => Math.min(timeRunning + 1000, timerDuration)); + }, 1000); + } else if (timerStatus !== TimerStatus.RUNNING && intervalRef.current) { + if (timerStatus === TimerStatus.STOPPED) { + onTimerFinish(); + } + clearInterval(intervalRef.current); + intervalRef.current = undefined; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timerStatus]); + + return { + milliseconds: remainingTime, + minutes, + seconds, + remainingTimeLabel: createLabel(), + }; +} diff --git a/packages/frontend/src/retro/hooks/useValidatedTimeInput.ts b/packages/frontend/src/retro/hooks/useValidatedTimeInput.ts new file mode 100644 index 00000000..e4c6793d --- /dev/null +++ b/packages/frontend/src/retro/hooks/useValidatedTimeInput.ts @@ -0,0 +1,53 @@ +import React, { useState } from "react"; +import { toNumber } from "lodash"; + +interface useValidatedTimeInputOptions { + initialValue?: number; + formatLength?: number; + minValue?: number; + maxValue?: number; +} + +export function useValidatedTimeInput({ + initialValue, + formatLength = 2, + maxValue = Number.MAX_VALUE, + minValue = Number.MIN_VALUE, +}: useValidatedTimeInputOptions | undefined = {}) { + const [value, setValue] = useState(initialValue ?? 0); + const isError = value < minValue || value > maxValue; + + const formattedValue = formatLength + ? value.toString().padStart(formatLength, "0") + : value.toString(); + + function trimInput(input: string) { + if (input.length > formatLength) { + return input.substring(input.length - formatLength, input.length); + } + return input; + } + + function incrementTime(change: number) { + setValue(value + change); + } + + function onChange(event: React.ChangeEvent) { + const input = trimInput(event.target.value); + const number = toNumber(input); + + if (Number.isNaN(number)) { + return; + } + + setValue(number); + } + + return { + value, + formattedValue, + isError, + onChange, + incrementTime, + }; +} diff --git a/packages/frontend/src/retro/reducers/retroReducer.ts b/packages/frontend/src/retro/reducers/retroReducer.ts index 5b19e6b9..876b60bf 100644 --- a/packages/frontend/src/retro/reducers/retroReducer.ts +++ b/packages/frontend/src/retro/reducers/retroReducer.ts @@ -1,4 +1,4 @@ -import { RetroCard, RetroColumn, RetroState, UserByUserId } from "../types/retroTypes"; +import { RetroCard, RetroColumn, RetroState, TimerStatus, UserByUserId } from "../types/retroTypes"; import { RetroAction } from "../types/retroActions"; import { insertCardIntoColumn, @@ -224,6 +224,22 @@ export const retroReducer = (state: RetroState, action: RetroAction): RetroState case "IS_VOTING_ENABLED_CHANGED": { return { ...state, isVotingEnabled: action.isEnabled }; } + case "START_TIMER": { + return { + ...state, + timerDuration: action.duration, + timerStatus: TimerStatus.RUNNING, + }; + } + case "STOP_TIMER": { + return { ...state, timerDuration: 0, timerStatus: TimerStatus.STOPPED }; + } + case "PAUSE_TIMER": { + return { ...state, timerStatus: TimerStatus.PAUSED }; + } + case "CHANGE_TIMER": { + return { ...state, timerDuration: action.duration }; + } case "DISCONNECT": { const { participants, waitingList } = state; const disconnectedUserId = action.payload; diff --git a/packages/frontend/src/retro/types/retroActions.ts b/packages/frontend/src/retro/types/retroActions.ts index c3ecfd16..4db3fd0a 100644 --- a/packages/frontend/src/retro/types/retroActions.ts +++ b/packages/frontend/src/retro/types/retroActions.ts @@ -95,6 +95,24 @@ export interface IsVotingEnabledChangedAction extends BaseAction { isEnabled: boolean; } +export interface StartTimerAction extends BaseAction { + type: "START_TIMER"; + duration: number; +} + +export interface PauseTimerAction extends BaseAction { + type: "PAUSE_TIMER"; +} + +export interface StopTimerAction extends BaseAction { + type: "STOP_TIMER"; +} + +export interface ChangeTimerAction extends BaseAction { + type: "CHANGE_TIMER"; + duration: number; +} + export type RetroAction = | PeerToPeerAction | CardUpvoteAction @@ -115,4 +133,8 @@ export type RetroAction = | ToggleCardDiscussedAction | ChangeRetroFormatAction | SortCardsByVotesDescendingAction - | IsVotingEnabledChangedAction; + | IsVotingEnabledChangedAction + | StartTimerAction + | PauseTimerAction + | StopTimerAction + | ChangeTimerAction; diff --git a/packages/frontend/src/retro/types/retroTypes.ts b/packages/frontend/src/retro/types/retroTypes.ts index 5e4e2387..9e5239aa 100644 --- a/packages/frontend/src/retro/types/retroTypes.ts +++ b/packages/frontend/src/retro/types/retroTypes.ts @@ -16,7 +16,11 @@ export interface RetroColumn { cards: RetroCard[]; isBlurred: boolean; } - +export enum TimerStatus { + RUNNING, + PAUSED, + STOPPED, +} export interface RetroState { title: string; format: string; @@ -27,6 +31,8 @@ export interface RetroState { participants: UserByUserId; waitingList: UserByUserId; isVotingEnabled: boolean; + timerStatus: TimerStatus; + timerDuration: number; } export type VotesByUserId = Record; diff --git a/packages/frontend/src/retro/utils/timerUtils.ts b/packages/frontend/src/retro/utils/timerUtils.ts new file mode 100644 index 00000000..293cd94c --- /dev/null +++ b/packages/frontend/src/retro/utils/timerUtils.ts @@ -0,0 +1,3 @@ +export function calculateMilliseconds(minutes: number, seconds: number) { + return (minutes * 60 + seconds) * 1000; +}