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