diff --git a/.gitignore b/.gitignore index db2f80e..232f584 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr .yalc/ yalc.lock +.env diff --git a/package-lock.json b/package-lock.json index 64fe1bc..19e21e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,8 @@ "name": "vite-template-redux", "version": "0.0.0", "dependencies": { - "@fluentui/react-components": "^9.68.2", - "@reduxjs/toolkit": "^2.6.1", + "@fluentui/react-components": "9.68.2", + "@reduxjs/toolkit": "^2.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.2.0" @@ -8522,4 +8522,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 590876b..52effbd 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@fluentui/react-components": "^9.68.2", - "@reduxjs/toolkit": "^2.6.1", + "@reduxjs/toolkit": "^2.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.2.0" diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 29b4758..0000000 --- a/src/App.tsx +++ /dev/null @@ -1 +0,0 @@ -export const App = () =>
diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..587c7db --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,91 @@ +import { useState } from "react" +import { + FluentProvider, + webLightTheme, + webDarkTheme, + makeStaticStyles, +} from "@fluentui/react-components" +import { + WrenchSettingsFilled, + WrenchSettingsRegular, + TimerFilled, + TimerRegular, + bundleIcon, +} from "@fluentui/react-icons" +import type { Theme } from "@fluentui/react-components" +import { useAppSelector } from "@/app/hooks" +import type { SelectTabData, SelectTabEvent } from "@fluentui/react-components" +import { TimersList } from "@/features/timersList/TimersList" +import { NewTimerForm } from "@/features/newTimerForm/NewTimerForm" +import { Settings } from "@/features/settings/Settings" +import { selectTabsView, selectTheme } from "@/features/settings/slice" +import { TabsView } from "@/components/TabsView" +import { useTimer } from "@/hooks/useTicker" +import { FREQUENCY_MS } from "./constants" +import type { AppTheme } from "./types" + +const TimerIcon = bundleIcon(TimerFilled, TimerRegular) +const SettingsIcon = bundleIcon(WrenchSettingsFilled, WrenchSettingsRegular) + +const useGlobalStyles = makeStaticStyles([ + "html, body, #root, .App { margin: 0; padding: 0; min-height: 100vh; }", +]) + +const THEMES: { [K in AppTheme]: Theme } = { + dark: webDarkTheme, + light: webLightTheme, +} + +export const App = () => { + useGlobalStyles() + useTimer(FREQUENCY_MS) + + const tabsView: boolean = useAppSelector(selectTabsView) + const theme: AppTheme = useAppSelector(selectTheme) + + const [selectedTab, setSelectedTab] = useState("timers") + + const onTabSelect = (_event: SelectTabEvent, data: SelectTabData) => { + setSelectedTab(data.value as string) + } + + const tabs = [ + { + id: "timers", + label: "Timers", + icon: , + content: ( + <> + + + + ), + }, + { + id: "settings", + label: "Settings", + icon: , + content: , + }, + ] + + return ( + +
+ {tabsView ? ( + + ) : ( +
+ + + +
+ )} +
+
+ ) +} diff --git a/src/app/constants.ts b/src/app/constants.ts new file mode 100644 index 0000000..0790fd1 --- /dev/null +++ b/src/app/constants.ts @@ -0,0 +1,14 @@ +export const FREQUENCY_MS = 250 +export const LOCAL = "local" + +export enum THEME { + dark = "dark", + light = "light", +} + +export enum STATUS { + idle = "idle", + pending = "pending", + succeeded = "succeeded", + failed = "failed", +} diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 0000000..8b116d0 --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector, useStore } from "react-redux" +import type { AppDispatch, AppStore, RootState } from "./store" + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() +export const useAppStore = useStore.withTypes() diff --git a/src/app/store.ts b/src/app/store.ts new file mode 100644 index 0000000..ca29928 --- /dev/null +++ b/src/app/store.ts @@ -0,0 +1,22 @@ +import { configureStore } from "@reduxjs/toolkit" +import settingsReducer from "@/features/settings/slice" +import timersReducer from "@/features/timersList/slice" +import timerReducer from "@/features/timer/slice" +import newTimerFormReducer from "@/features/newTimerForm/slice" +import { settingsMiddleware } from "@/features/settings/middleware" +import { timerMiddelware } from "@/features/timer/middelware" + +export const store = configureStore({ + reducer: { + settings: settingsReducer, + timers: timersReducer, + timer: timerReducer, + form: newTimerFormReducer, + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware().concat(settingsMiddleware, timerMiddelware), +}) + +export type AppStore = typeof store +export type RootState = ReturnType +export type AppDispatch = AppStore["dispatch"] diff --git a/src/app/types.ts b/src/app/types.ts new file mode 100644 index 0000000..fd7f8bb --- /dev/null +++ b/src/app/types.ts @@ -0,0 +1,13 @@ +import { STATUS, THEME } from "./constants" + +export type AppTheme = THEME.dark | THEME.light +export type Status = + | STATUS.idle + | STATUS.pending + | STATUS.succeeded + | STATUS.failed + +export interface FetchStatus { + status: Status + error: string | null +} diff --git a/src/app/withTypes.ts b/src/app/withTypes.ts new file mode 100644 index 0000000..1160a56 --- /dev/null +++ b/src/app/withTypes.ts @@ -0,0 +1,8 @@ +import { createAsyncThunk } from "@reduxjs/toolkit" + +import type { RootState, AppDispatch } from "./store" + +export const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState + dispatch: AppDispatch +}>() diff --git a/src/components/TabsView.tsx b/src/components/TabsView.tsx new file mode 100644 index 0000000..f745ed6 --- /dev/null +++ b/src/components/TabsView.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react" +import { TabList, Tab } from "@fluentui/react-components" +import type { + TabSlots, + SelectTabData, + SelectTabEvent, +} from "@fluentui/react-components" + +type TabConfig = { + id: string + label: string + icon?: TabSlots["icon"] + content: ReactNode +} + +interface TabsViewProps { + tabs: TabConfig[] + selected: string + onTabSelect: (event: SelectTabEvent, data: SelectTabData) => void +} + +export function TabsView({ tabs, selected, onTabSelect }: TabsViewProps) { + return ( + <> + + {tabs.map(({ id, icon, label }) => ( + + {label} + + ))} + + +
{tabs.find(tab => tab.id === selected)?.content}
+ + ) +} diff --git a/src/components/ThemeSpinner.tsx b/src/components/ThemeSpinner.tsx new file mode 100644 index 0000000..921e7c9 --- /dev/null +++ b/src/components/ThemeSpinner.tsx @@ -0,0 +1,15 @@ +import { Spinner, SpinnerProps } from "@fluentui/react-components" +import { selectTheme } from "@/features/settings/slice" +import { useAppSelector } from "@/app/hooks" +import { THEME } from "@/app/constants" + +export function ThemeSpinner(props: SpinnerProps) { + const theme = useAppSelector(selectTheme) + + return ( + + ) +} diff --git a/src/features/newTimerForm/NewTimerForm.tsx b/src/features/newTimerForm/NewTimerForm.tsx new file mode 100644 index 0000000..db54725 --- /dev/null +++ b/src/features/newTimerForm/NewTimerForm.tsx @@ -0,0 +1,80 @@ +import { useState, FormEvent } from "react" +import { + Input, + Label, + Button, + makeStyles, + useId, +} from "@fluentui/react-components" +import { useAppDispatch, useAppSelector } from "@/app/hooks" +import { createTimer } from "@/features/timersList/thunks" +import { resetTimer } from "@/features/timer/slice" +import { + selectFormStatus, + selectFormError, +} from "@/features/newTimerForm/slice" +import { LOCAL, STATUS } from "@/app/constants" +import { name } from "@/features/newTimerForm/slice" +import { ThemeSpinner } from "@/components/ThemeSpinner" + +const useStyles = makeStyles({ + form: { + display: "flex", + flexDirection: "column", + gap: "5px", + padding: "20px 0 0 20px", + }, + input: { + marginRight: "10px", + }, +}) + +export const NewTimerForm = () => { + const inputId = useId("input") + const styles = useStyles() + const dispatch = useAppDispatch() + const status = useAppSelector(selectFormStatus) + const error = useAppSelector(selectFormError) + const [newTimerName, setNewTimerName] = useState("") + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + + if (newTimerName === LOCAL) { + dispatch(resetTimer()) + } else { + dispatch(createTimer({ id: newTimerName, source: name })) + } + + setNewTimerName("") + } + + return ( +
+ +
+ setNewTimerName(e.target.value)} + title="Enter the name of a new timer" + placeholder="Enter the name" + /> + + {status === STATUS.pending && ( + + )} + {status === STATUS.failed && ( + {error} + )} +
+
+ ) +} diff --git a/src/features/newTimerForm/slice.ts b/src/features/newTimerForm/slice.ts new file mode 100644 index 0000000..b8711c3 --- /dev/null +++ b/src/features/newTimerForm/slice.ts @@ -0,0 +1,45 @@ +import { createSlice } from "@reduxjs/toolkit" +import type { RootState } from "@/app/store" +import { createTimer } from "@/features/timersList/thunks" +import { STATUS } from "@/app/constants" +import type { FetchStatus } from "@/app/types" + +export const name = "form" + +const initialState: FetchStatus = { + status: STATUS.idle, + error: null, +} + +export const form = createSlice({ + name, + initialState, + reducers: {}, + extraReducers: builder => { + builder.addCase(createTimer.pending, (state, action) => { + if (action.meta.arg.source === name) { + state.status = STATUS.pending + state.error = null + } + }) + builder.addCase(createTimer.fulfilled, (state, action) => { + if (action.meta.arg.source === name) { + state.status = STATUS.succeeded + state.error = null + } + }) + builder.addCase(createTimer.rejected, (state, action: any) => { + if (action.meta.arg.source === name) { + state.status = STATUS.failed + state.error = action.payload ?? "Unknown Error" + } + }) + }, +}) + +export const {} = form.actions + +export default form.reducer + +export const selectFormStatus = (state: RootState) => state.form.status +export const selectFormError = (state: RootState) => state.form.error diff --git a/src/features/settings/Settings.tsx b/src/features/settings/Settings.tsx new file mode 100644 index 0000000..e65fad7 --- /dev/null +++ b/src/features/settings/Settings.tsx @@ -0,0 +1,67 @@ +import { + Radio, + RadioGroup, + Checkbox, + Button, + makeStyles, +} from "@fluentui/react-components" +import { useAppDispatch, useAppSelector } from "@/app/hooks" +import { + toggleTabs, + setTheme, + togglePreserveLocalTimer, + selectPreserveLocalTimer, +} from "@/features/settings/slice" +import { selectTabsView, selectTheme } from "@/features/settings/slice" +import type { AppTheme } from "@/app/types" +import { THEME } from "@/app/constants" + +const useStyles = makeStyles({ + container: { + margin: "20px 0 0 20px", + display: "flex", + flexDirection: "column", + gap: "10px", + }, +}) + +export const Settings = () => { + const styles = useStyles() + const dispatch = useAppDispatch() + const tabsView = useAppSelector(selectTabsView) + const theme = useAppSelector(selectTheme) + const preserve = useAppSelector(selectPreserveLocalTimer) + + return ( +
+ + dispatch(setTheme(data.value as AppTheme))} + aria-label="theme" + > + + + + dispatch(togglePreserveLocalTimer())} + label="Preserve local timer between sessions" + /> + +
+ ) +} diff --git a/src/features/settings/middleware.ts b/src/features/settings/middleware.ts new file mode 100644 index 0000000..c113820 --- /dev/null +++ b/src/features/settings/middleware.ts @@ -0,0 +1,29 @@ +import { Middleware } from "@reduxjs/toolkit" +import { toggleTabs, togglePreserveLocalTimer } from "@/features/settings/slice" +import { saveLocalTimerToStorage } from "../timer/slice" + +export const settingsMiddleware: Middleware = store => next => action => { + const result = next(action) + const state = store.getState() + + if (toggleTabs.match(action)) { + const { tabsView } = state.settings + + const searchParams = new URLSearchParams(window.location.search) + searchParams.set("tabsView", tabsView) + const newUrl = `${window.location.pathname}?${searchParams.toString()}` + window.history.replaceState({}, "", newUrl) + } + + if (togglePreserveLocalTimer.match(action)) { + const { preserveLocalTimer } = state.settings + + if (preserveLocalTimer) { + store.dispatch(saveLocalTimerToStorage()) + } else { + localStorage.removeItem("localTimer") + } + } + + return result +} diff --git a/src/features/settings/slice.ts b/src/features/settings/slice.ts new file mode 100644 index 0000000..9752dd7 --- /dev/null +++ b/src/features/settings/slice.ts @@ -0,0 +1,43 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit" +import { RootState } from "@/app/store" +import { SettingState } from "./types" +import type { AppTheme } from "@/app/types" +import { THEME } from "@/app/constants" + +const queryString = window.location.search +const urlParams = new URLSearchParams(queryString) +const tabsViewParam = urlParams.get("tabsView") +const tabsView = !(tabsViewParam === "false") +const localTimer: string | null = localStorage.getItem("localTimer") + +const initialState: SettingState = { + tabsView, + theme: THEME.light, + preserveLocalTimer: !!localTimer, +} + +export const settings = createSlice({ + name: "tabsView", + initialState, + reducers: { + toggleTabs: state => { + state.tabsView = !state.tabsView + }, + setTheme: (state, action: PayloadAction) => { + state.theme = action.payload + }, + togglePreserveLocalTimer: state => { + state.preserveLocalTimer = !state.preserveLocalTimer + }, + }, +}) + +export const { toggleTabs, setTheme, togglePreserveLocalTimer } = + settings.actions + +export const selectTabsView = (state: RootState) => state.settings.tabsView +export const selectTheme = (state: RootState) => state.settings.theme +export const selectPreserveLocalTimer = (state: RootState) => + state.settings.preserveLocalTimer + +export default settings.reducer diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts new file mode 100644 index 0000000..d98ad48 --- /dev/null +++ b/src/features/settings/types.ts @@ -0,0 +1,7 @@ +import type { AppTheme } from "@/app/types" + +export interface SettingState { + tabsView: boolean + theme: AppTheme + preserveLocalTimer: boolean +} diff --git a/src/features/timer/LocalTimer.tsx b/src/features/timer/LocalTimer.tsx new file mode 100644 index 0000000..6796a72 --- /dev/null +++ b/src/features/timer/LocalTimer.tsx @@ -0,0 +1,27 @@ +import { useAppSelector } from "@/app/hooks" +import { + selectLocalTimer, + selectStart, + resetTimer, +} from "@/features/timer/slice" +import { formatTime } from "@/helpers.ts/formatTime" +import { TableRow, TableCell, Button } from "@fluentui/react-components" +import { useAppDispatch } from "@/app/hooks" +import { LOCAL } from "@/app/constants" + +export default function LocalTimer() { + useAppSelector(selectLocalTimer) + const start = useAppSelector(selectStart) + const now = Date.now() / 1000 + const dispatch = useAppDispatch() + + return ( + + {LOCAL} + {formatTime(now - start)} + + + + + ) +} diff --git a/src/features/timer/ServerTimer.tsx b/src/features/timer/ServerTimer.tsx new file mode 100644 index 0000000..fbd85b9 --- /dev/null +++ b/src/features/timer/ServerTimer.tsx @@ -0,0 +1,50 @@ +import { useAppSelector } from "@/app/hooks" +import { TableCell, TableRow, Button } from "@fluentui/react-components" +import { selectLocalTimer } from "./slice" +import { TimerWithId } from "@/features/timersList/types" +import { formatTime } from "@/helpers.ts/formatTime" +import { useAppDispatch } from "@/app/hooks" +import { createTimer } from "@/features/timersList/thunks" +import { name } from "@/features/timersList/slice" +import { + selectResetId, + selectResetStatus, + selectResetError, +} from "@/features/timersList/slice" +import { ThemeSpinner } from "@/components/ThemeSpinner" +import { STATUS } from "@/app/constants" + +export default function ServerTimer({ id, elapsed, receivedAt }: TimerWithId) { + useAppSelector(selectLocalTimer) + const now = Date.now() / 1000 + const dispatch = useAppDispatch() + const resetStatus = useAppSelector(selectResetStatus) + const resetId = useAppSelector(selectResetId) + const isCurrentId = id === resetId + const isPending = resetStatus === STATUS.pending && isCurrentId + const isFailed = resetStatus === STATUS.failed && isCurrentId + const error = useAppSelector(selectResetError) + + return ( + + {id} + {formatTime(elapsed + (now - receivedAt))} + + + {isPending && ( + + )} + {isFailed && ( + {error} + )} + + + ) +} diff --git a/src/features/timer/middelware.ts b/src/features/timer/middelware.ts new file mode 100644 index 0000000..ac28d66 --- /dev/null +++ b/src/features/timer/middelware.ts @@ -0,0 +1,53 @@ +import { Middleware } from "@reduxjs/toolkit" +import { + startTimer, + restoreTimer, + saveLocalTimerToStorage, + stopTimer, + resetTimer, + tick, +} from "@/features/timer/slice" +import { FREQUENCY_MS } from "@/app/constants" + +export const timerMiddelware: Middleware = store => { + let intervalId: ReturnType | null = null + + return next => action => { + const result = next(action) + const state = store.getState() + const { preserveLocalTimer } = state.settings + + if (startTimer.match(action)) { + if (preserveLocalTimer) { + const localTimer: string | null = localStorage.getItem("localTimer") + + localTimer !== null && store.dispatch(restoreTimer(+localTimer)) + } + + const frequencyMs = action.payload + + if (intervalId === null) { + intervalId = setInterval(() => { + store.dispatch(tick(frequencyMs || FREQUENCY_MS)) + }, frequencyMs) + } + } + + if (saveLocalTimerToStorage.match(action) || resetTimer.match(action)) { + const { start } = state.timer + + if (preserveLocalTimer) { + localStorage.setItem("localTimer", start) + } + } + + if (stopTimer.match(action)) { + intervalId !== null && clearInterval(intervalId) + intervalId = null + + store.dispatch(saveLocalTimerToStorage()) + } + + return result + } +} diff --git a/src/features/timer/slice.ts b/src/features/timer/slice.ts new file mode 100644 index 0000000..1a69127 --- /dev/null +++ b/src/features/timer/slice.ts @@ -0,0 +1,40 @@ +import { createSlice, PayloadAction, createAction } from "@reduxjs/toolkit" +import type { RootState } from "@/app/store" + +interface LocalTimerState { + start: number + localTimer: number +} + +const initialState: LocalTimerState = { + start: 0, + localTimer: 0, +} + +export const timer = createSlice({ + name: "timer", + initialState: initialState, + reducers: { + tick: (state, action: PayloadAction) => { + state.localTimer += action.payload + }, + startTimer: (state, _action: PayloadAction) => { + state.start = Date.now() / 1000 + }, + restoreTimer: (state, action: { payload: number }) => { + state.start = action.payload + }, + resetTimer: (state, _action: PayloadAction) => { + state.start = Date.now() / 1000 + }, + }, +}) + +export const { tick, startTimer, restoreTimer, resetTimer } = timer.actions +export const saveLocalTimerToStorage = createAction("timer/saveLocalToStorage") +export const stopTimer = createAction("timer/stopTimer") + +export default timer.reducer + +export const selectLocalTimer = (state: RootState) => state.timer.localTimer +export const selectStart = (state: RootState) => state.timer.start diff --git a/src/features/timersList/TimersList.tsx b/src/features/timersList/TimersList.tsx new file mode 100644 index 0000000..ff46b53 --- /dev/null +++ b/src/features/timersList/TimersList.tsx @@ -0,0 +1,86 @@ +import { useEffect } from "react" +import { + TableBody, + TableRow, + Table, + TableHeader, + TableHeaderCell, + makeStyles, +} from "@fluentui/react-components" +import { useAppSelector, useAppDispatch } from "@/app/hooks" +import { + selectAllTimers, + selectIsInitialLoading, + selectTimersStatus, + selectTimersError, +} from "@/features/timersList/slice" +import { fetchTimers } from "@/features/timersList/thunks" +import LocalTimer from "@/features/timer/LocalTimer" +import ServerTimer from "@/features/timer/ServerTimer" +import { ThemeSpinner } from "@/components/ThemeSpinner" +import { STATUS } from "@/app/constants" + +const columns = [ + { columnKey: "name", label: "Name" }, + { columnKey: "value", label: "Value" }, + { columnKey: "action", label: "Action" }, +] + +const useStyles = makeStyles({ + spinnerContainer: { + display: "flex", + justifyContent: "center", + alignItems: "center", + minHeight: "42px", + }, +}) + +export const TimersList = () => { + const styles = useStyles() + const dispatch = useAppDispatch() + const timers = useAppSelector(selectAllTimers) + const timersStatus = useAppSelector(selectTimersStatus) + const isInitialLoading = useAppSelector(selectIsInitialLoading) + const error = useAppSelector(selectTimersError) + + useEffect(() => { + if (timersStatus === "idle") { + dispatch(fetchTimers()) + } + + const interval = setInterval(() => { + dispatch(fetchTimers()) + }, 30000) + return () => clearInterval(interval) + }, []) + + return ( + <> + + + + {columns.map(column => ( + + {column.label} + + ))} + + + + + {timers.map(([id, timer]) => ( + + ))} + +
+
+ {timersStatus === STATUS.pending && isInitialLoading && ( + + )} + {timersStatus === STATUS.failed && ( + {error} + )} +
+ + ) +} diff --git a/src/features/timersList/api.ts b/src/features/timersList/api.ts new file mode 100644 index 0000000..c3da9a7 --- /dev/null +++ b/src/features/timersList/api.ts @@ -0,0 +1,61 @@ +import { Timer, TimerResponse } from "./types" +const URL = import.meta.env.VITE_API_URL + +export async function getTimer(id: string): Promise { + const response = await fetch(`${URL}/${id}`) + + if (!response.ok) { + let message = `Server returned ${response.status}` + + try { + const data = await response.json() + if (data?.message) message = data.message + } catch {} + + throw new Error(message) + } + + const data: Timer = await response.json() + + return data +} + +export async function getListOfTimers(): Promise { + const response = await fetch(`${URL}/list`) + + if (!response.ok) { + let message = `Server returned ${response.status}` + + try { + const data = await response.json() + if (data?.message) message = data.message + } catch {} + + throw new Error(message) + } + + const data: string[] = await response.json() + + return data +} + +export async function postTimer(id: string): Promise { + const response = await fetch(`${URL}/reset/${id}`, { + method: "POST", + }) + + if (!response.ok) { + let message = `Server returned ${response.status}` + + try { + const data = await response.json() + if (data?.message) message = data.message + } catch {} + + throw new Error(message) + } + + const data: Timer = await response.json() + + return data +} diff --git a/src/features/timersList/slice.ts b/src/features/timersList/slice.ts new file mode 100644 index 0000000..25d1e69 --- /dev/null +++ b/src/features/timersList/slice.ts @@ -0,0 +1,94 @@ +import { createSlice, createSelector } from "@reduxjs/toolkit" +import type { RootState } from "@/app/store" +import { Timer, TimersState } from "./types" +import { fetchTimers, createTimer } from "./thunks" +import { STATUS } from "@/app/constants" + +export const name = "timersList" + +const initialState: TimersState = { + map: {}, + fetchTimers: { + status: STATUS.idle, + error: null, + }, + resetTimer: { + id: "", + status: STATUS.idle, + error: null, + }, + isInitialLoading: true, +} + +export const timers = createSlice({ + name, + initialState, + reducers: {}, + extraReducers: builder => { + builder.addCase(fetchTimers.pending, state => { + state.fetchTimers.status = STATUS.pending + state.fetchTimers.error = null + }) + builder.addCase(fetchTimers.fulfilled, (state, action) => { + state.fetchTimers.status = STATUS.succeeded + state.fetchTimers.error = null + state.isInitialLoading = false + state.map = action.payload.reduce( + (acc, { id, start, elapsed, receivedAt }) => { + acc[id] = { start, elapsed, receivedAt } + return acc + }, + {} as Record, + ) + }) + builder.addCase(fetchTimers.rejected, (state, action) => { + state.fetchTimers.status = STATUS.failed + state.fetchTimers.error = action.payload ?? "Unknown Error" + }) + builder.addCase(createTimer.pending, (state, action) => { + const { source, id } = action.meta.arg + + if (source === name) { + state.resetTimer.status = STATUS.pending + state.resetTimer.error = null + state.resetTimer.id = id + } + }) + builder.addCase(createTimer.fulfilled, (state, action) => { + const { id, elapsed, start, receivedAt } = action.payload + if (action.meta.arg.source === name) { + state.resetTimer.status = STATUS.succeeded + state.resetTimer.error = null + } + state.map[id] = { elapsed, start, receivedAt } + }) + builder.addCase(createTimer.rejected, (state, action: any) => { + if (action.meta.arg.source === name) { + state.resetTimer.status = STATUS.failed + state.resetTimer.error = action.payload ?? "Unknown Error" + } + }) + }, +}) + +export const {} = timers.actions + +export default timers.reducer + +const selectTimerMap = (state: RootState) => state.timers.map +export const selectAllTimers = createSelector( + selectTimerMap, + (map: Record) => Object.entries(map), +) +export const selectTimersStatus = (state: RootState) => + state.timers.fetchTimers.status +export const selectTimersError = (state: RootState) => + state.timers.fetchTimers.error +export const selectIsInitialLoading = (state: RootState) => + state.timers.isInitialLoading + +export const selectResetError = (state: RootState) => + state.timers.resetTimer.error +export const selectResetStatus = (state: RootState) => + state.timers.resetTimer.status +export const selectResetId = (state: RootState) => state.timers.resetTimer.id diff --git a/src/features/timersList/thunks.ts b/src/features/timersList/thunks.ts new file mode 100644 index 0000000..0c0e95f --- /dev/null +++ b/src/features/timersList/thunks.ts @@ -0,0 +1,61 @@ +import { createAppAsyncThunk } from "@/app/withTypes" +import { SliceNames, TimerResponse, TimerWithId } from "./types" +import { getTimer, getListOfTimers, postTimer } from "./api" + +export const fetchTimerById = createAppAsyncThunk( + "timer/fetchTimerById", + async (id: string, { rejectWithValue }) => { + try { + const response: TimerResponse = await getTimer(id) + + return response + } catch (err: unknown) { + if (err instanceof Error) { + return rejectWithValue(err.message) + } + return rejectWithValue("Unknown error") + } + }, +) + +export const fetchTimers = createAppAsyncThunk< + TimerWithId[], + void, + { rejectValue: string } +>("timer/fetchTimers", async (_, { rejectWithValue }) => { + try { + const ids: string[] = await getListOfTimers() + + return Promise.all( + ids.map(async id => { + const response: TimerResponse = await getTimer(id) + const receivedAt = Date.now() / 1000 + + return { id, ...response, receivedAt } + }), + ) + } catch (err: unknown) { + if (err instanceof Error) { + return rejectWithValue(err.message) + } + return rejectWithValue("Unknown error") + } +}) + +export const createTimer = createAppAsyncThunk< + TimerWithId, + { id: string; source: SliceNames }, + { rejectValue: string } +>("timer/createTimer", async ({ id }, { rejectWithValue }) => { + try { + const response: TimerResponse = await postTimer(id) + const receivedAt = Date.now() / 1000 + + return { id, ...response, receivedAt } + } catch (err: unknown) { + if (err instanceof Error) { + return rejectWithValue(err.message) + } + return rejectWithValue("Unknown error") + } +}) diff --git a/src/features/timersList/types.ts b/src/features/timersList/types.ts new file mode 100644 index 0000000..ea1d332 --- /dev/null +++ b/src/features/timersList/types.ts @@ -0,0 +1,23 @@ +import type { FetchStatus } from "@/app/types" + +type WithId = T & { id: string } + +export interface TimerResponse { + start: number + elapsed: number +} + +export interface Timer extends TimerResponse { + receivedAt: number +} + +export type TimerWithId = WithId + +export interface TimersState { + map: Record + fetchTimers: FetchStatus + resetTimer: WithId + isInitialLoading: boolean +} + +export type SliceNames = "form" | "timersList" diff --git a/src/helpers.ts/formatTime.ts b/src/helpers.ts/formatTime.ts new file mode 100644 index 0000000..2f2b564 --- /dev/null +++ b/src/helpers.ts/formatTime.ts @@ -0,0 +1,9 @@ +export function formatTime(totalSeconds: number): string { + const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, "0") + const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart( + 2, + "0", + ) + const seconds = String(Math.trunc(totalSeconds % 60)).padStart(2, "0") + return `${hours}:${minutes}:${seconds}` +} diff --git a/src/hooks/useTicker.ts b/src/hooks/useTicker.ts new file mode 100644 index 0000000..48cc5bb --- /dev/null +++ b/src/hooks/useTicker.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react" +import { useAppDispatch } from "@/app/hooks" +import { startTimer, stopTimer } from "@/features/timer/slice" + +export function useTimer(frequencyMs: number) { + const dispatch = useAppDispatch() + + useEffect(() => { + dispatch(startTimer(frequencyMs)) + + return () => { + dispatch(stopTimer()) + } + }, []) +} diff --git a/src/main.tsx b/src/main.tsx index bf27bf0..d23c174 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,8 @@ import { StrictMode } from "react" import { createRoot } from "react-dom/client" -import { App } from "./App" +import { App } from "./app/App" +import { store } from "./app/store" +import { Provider } from "react-redux" const container = document.getElementById("root") @@ -9,7 +11,9 @@ if (container) { root.render( - + + + , ) } else { diff --git a/tsconfig.app.json b/tsconfig.app.json index ea6a38d..67f76ff 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -22,7 +22,11 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "types": ["vitest/globals"] + "types": ["vitest/globals"], + "baseUrl": "src", + "paths": { + "@/*": ["*"] + } }, "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts index 0fd6681..93125a9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,12 @@ import packageJson from "./package.json" with { type: "json" } export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { open: true, },