diff --git a/ui/src/App.css b/ui/src/App.css index 8b908d5..1c69a9f 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,2 +1,9 @@ .App { +} + +.countdown-area { + width: 100%; + text-align: center; + font-weight: bold; + font-size: 2rem; } \ No newline at end of file diff --git a/ui/src/App.js b/ui/src/App.js index f27ad68..bb625c7 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -4,13 +4,13 @@ import { Container, Form } from 'react-bootstrap'; import { connect } from 'react-redux'; import NotificationsArea from './components/NotificationsArea.js'; -import APIAddressField from './components/APIAddressField'; -import PourTimeField from './components/PourTimeField'; -import SystemControls from './components/SystemControls'; -import SystemStatusArea from './components/SystemStatusArea'; +import APIAddressField from './components/APIAddressField.js'; +import PourTimeField from './components/PourTimeField.js'; +import SystemControls from './components/SystemControls.js'; +import SystemStatusArea from './components/SystemStatusArea.js'; +import CurrentOperationInfoArea from './components/CurrentOperationInfoArea.js'; function App({ isConnected }) { - // TODO: Add a fake countdown timer of timeLeft return (

Tea System UI

@@ -21,6 +21,7 @@ function App({ isConnected }) { {isConnected ? ( <> + ) : null} diff --git a/ui/src/App.test.js b/ui/src/App.test.js deleted file mode 100644 index 0d76197..0000000 --- a/ui/src/App.test.js +++ /dev/null @@ -1,6 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('1 == 1', () => { - expect(1).toEqual(1); -}); \ No newline at end of file diff --git a/ui/src/Utils/time.js b/ui/src/Utils/time.js new file mode 100644 index 0000000..248738e --- /dev/null +++ b/ui/src/Utils/time.js @@ -0,0 +1,20 @@ +function toTimeStr(diff) { + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + const secondsStr = (seconds % 60).toString().padStart(2, '0'); + const minutesStr = (minutes % 60).toString().padStart(2, '0'); + const hoursStr = hours.toString().padStart(2, '0'); + + return `${hoursStr}:${minutesStr}:${secondsStr}`; +} + +export function timeBetweenAsString({endTime=null, startTime=null}) { + if (null === startTime) startTime = new Date(); + if (null === endTime) endTime = new Date(); + + const diff = endTime - startTime; // in ms + if (diff < 0) return '-' + toTimeStr(-diff); + return toTimeStr(diff); +} \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 978eb55..33b6a8a 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js'; // helper function to preprocess the API host function preprocessApiHost(apiHost) { @@ -11,26 +12,15 @@ function preprocessApiHost(apiHost) { } class CWaterPumpAPI { - constructor({ client=null, URL }) { - this._client = client || axios.create({ baseURL: preprocessApiHost(URL) }); - } - - async start(runTimeMs) { - const response = await this._client.get('/pour_tea', { - milliseconds: runTimeMs, + constructor({ URL }) { + this._impl = new CWaterPumpAPIImpl({ + client: axios.create({ baseURL: preprocessApiHost(URL) }), }); - return response.data; } - async stop() { - const response = await this._client.get('/stop'); - return response.data; - } - - async status() { - const response = await this._client.get('/status'); - return response.data; - } + async start(runTimeMs) { return await this._impl.start(runTimeMs); } + async stop() { return await this._impl.stop(); } + async status() { return await this._impl.status(); } } export default CWaterPumpAPI; diff --git a/ui/src/api/CWaterPumpAPIImpl.js b/ui/src/api/CWaterPumpAPIImpl.js new file mode 100644 index 0000000..995f260 --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -0,0 +1,65 @@ +class CWaterPumpAPIImpl { + constructor({ client, currentTime=null }) { + this._client = client; + this._currentTime = currentTime || Date.now; + } + + async _execute(callback) { + const start = this._currentTime(); + const response = await callback(); + const end = this._currentTime(); + return { response, requestTime: end - start }; + } + + async start(runTimeMs) { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/pour_tea', { params: { milliseconds: runTimeMs } }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async stop() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/stop', { params: {} }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async status() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/status', { params: {} }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + /////////////////////// + // helper functions + preprocessResponse({ response, requestTime }) { + if(null == response) return null; + if('error' in response) { + throw new Error(response.error); + } + // make a deep copy of response + response = JSON.parse(JSON.stringify(response)); + // normal response + // convert "water threshold" to "waterThreshold" + response.waterThreshold = response["water threshold"]; + delete response["water threshold"]; + + // convert "time left" to "timeLeft" and adjust time + response.pump.timeLeft = response.pump["time left"]; + delete response.pump["time left"]; + + // adjust time by network delay + const oneWayTripTime = Math.round(requestTime / 2); + response.time += oneWayTripTime; + response.pump.timeLeft -= oneWayTripTime; + + const now = this._currentTime(); + response.updated = now; + response.pump.estimatedEndTime = response.pump.timeLeft + now; + return response; + } +} + +export default CWaterPumpAPIImpl; +export { CWaterPumpAPIImpl }; \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPIImpl.test.js b/ui/src/api/CWaterPumpAPIImpl.test.js new file mode 100644 index 0000000..5a61036 --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.test.js @@ -0,0 +1,135 @@ +import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js'; + +describe('CWaterPumpAPIImpl', () => { + const DUMMY_STATUS = { + pump: { + "running": true, + "time left": 1000, + "water threshold": 100, + }, + time: 1000, + }; + // common test cases + async function shouldThrowErrorFromResponse(apiCall) { + const mockClient = { get: jest.fn() }; + const errorMessage = 'Error ' + Math.random(); + mockClient.get.mockResolvedValue({ data: { error: errorMessage } }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await expect(apiCall(api)).rejects.toThrow(errorMessage); + } + + async function shouldBeCalledWith(apiCall, url, params) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await apiCall(api); + + expect(mockClient.get).toHaveBeenCalledWith(url, { params }); + } + + async function shouldRethrowError(apiCall) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockRejectedValue(new Error('Network Error')); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await expect(apiCall(api)).rejects.toThrow('Network Error'); + } + + async function shouldPreprocessResponse(apiCall) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + const response = await apiCall(api); + + expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]); + expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]); + expect(response).toHaveProperty('updated'); + } + // end of common test cases + // tests per method + describe('start', () => { + it('common test cases', async () => { + const T = Math.random() * 1000; + const callback = async (api) => await api.start(T); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/pour_tea', { milliseconds: T }); + }); + }); + + describe('stop', () => { + it('common test cases', async () => { + const callback = async (api) => await api.stop(); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/stop', {}); + }); + }); + + describe('status', () => { + it('common test cases', async () => { + const callback = async (api) => await api.status(); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/status', {}); + }); + }); + // tests for helper function preprocessResponse + describe('preprocessResponse', () => { + it('should return null if response is null', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + expect(api.preprocessResponse({ response: null, requestTime: 0 })).toBeNull(); + }); + + it('should throw error if response has error', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + const errorMessage = 'Error ' + Math.random(); + expect(() => api.preprocessResponse({ + response: { error: errorMessage }, + requestTime: 0, + })).toThrow(errorMessage); + }); + + it('should preprocess response', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 }); + expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]); + expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]); + }); + + it('should add field "updated" with current time', () => { + const T = Math.random() * 1000; + const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => T }); + const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 }); + expect(response.updated).toBe(T); + }); + + /////////// + // Scenario: + // 00:00.000 - client sends request + // 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1234ms + // 00:00.200 - server sends response + // 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1234ms + // total time: 300ms + // on average, time to one-way trip is 150ms + // so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 1084ms + // estimatedEndTime = 00:00.300 + 1084ms = 00:01.384 + it('should adjust time', () => { + const responseObj = JSON.parse(JSON.stringify(DUMMY_STATUS)); + responseObj.time = 100; + responseObj.pump["time left"] = 1234; + + const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => 300 }); + const response = api.preprocessResponse({ response: responseObj, requestTime: 300 }); + expect(response.time).toBe(250); + expect(response.pump.timeLeft).toBe(1084); + expect(response.pump.estimatedEndTime).toBe(1384); + }); + }); +}); \ No newline at end of file diff --git a/ui/src/components/CurrentOperationInfoArea.js b/ui/src/components/CurrentOperationInfoArea.js new file mode 100644 index 0000000..b4ed46c --- /dev/null +++ b/ui/src/components/CurrentOperationInfoArea.js @@ -0,0 +1,22 @@ +import React from "react"; +import { connect } from "react-redux"; +import TimerArea from "./TimerArea"; + +export function CurrentOperationInfoAreaComponent({ + isRunning, estimatedEndTime +}) { + if (!isRunning) return null; + return ( +
+ +
+ ); +} + +export default connect( + state => ({ + isRunning: state.systemStatus.pump.running, + estimatedEndTime: state.systemStatus.pump.estimatedEndTime, + }), + [] +)(CurrentOperationInfoAreaComponent); \ No newline at end of file diff --git a/ui/src/components/NotificationsArea.js b/ui/src/components/NotificationsArea.js index afb38c0..c6e1832 100644 --- a/ui/src/components/NotificationsArea.js +++ b/ui/src/components/NotificationsArea.js @@ -1,19 +1,23 @@ import React from 'react'; +import { connect } from 'react-redux'; import { Alert } from 'react-bootstrap'; -import { useNotificationsSystem } from '../contexts/NotificationsContext'; +import { NotificationsSystemActions } from '../store/slices/Notifications'; -function NotificationsArea() { - const NotificationsSystem = useNotificationsSystem(); - const { currentNotifications } = NotificationsSystem; - if(!currentNotifications) return null; - - const hideNotifications = () => { NotificationsSystem.clear(); }; +function NotificationsArea({ hasNotifications, message, clearNotifications }) { + if(!hasNotifications) return null; return ( - - {currentNotifications.message} + + {message} ); } -export default NotificationsArea; +export default connect( + (state) => ({ + hasNotifications: state.notifications.currentNotifications != null, + message: state.notifications.currentNotifications?.message + }), { + clearNotifications: NotificationsSystemActions.clear + } +)(NotificationsArea); \ No newline at end of file diff --git a/ui/src/components/SystemControls.js b/ui/src/components/SystemControls.js index 6076831..16d1a8a 100644 --- a/ui/src/components/SystemControls.js +++ b/ui/src/components/SystemControls.js @@ -3,29 +3,18 @@ import { connect } from 'react-redux'; import { Button } from 'react-bootstrap'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; -import { useNotificationsSystem } from '../contexts/NotificationsContext.js'; import { startPump, stopPump } from '../store/slices/SystemStatus.js'; export function SystemControlsComponent({ pouringTime, systemStatus, startPump, stopPump }) { const api = useWaterPumpAPI().API; - const NotificationsSystem = useNotificationsSystem(); - const handleStart = async () => { - try { - await startPump({ api , pouringTime }); - } catch (error) { - NotificationsSystem.alert('Error starting water pump: ' + error.message); - } + await startPump({ api , pouringTime }); }; const handleStop = async () => { - try { - await stopPump({ api }); - } catch (error) { - NotificationsSystem.alert('Error stopping water pump: ' + error.message); - } + await stopPump({ api }); }; const isRunning = systemStatus.pump.running; diff --git a/ui/src/components/SystemStatusArea.js b/ui/src/components/SystemStatusArea.js index 1279b0b..b95373f 100644 --- a/ui/src/components/SystemStatusArea.js +++ b/ui/src/components/SystemStatusArea.js @@ -1,25 +1,7 @@ import React from 'react'; import { Card } from 'react-bootstrap'; import { connect } from 'react-redux'; - -// time elapsed since last update -function TimeElapsedComponent({ updated }) { - const [diffString, setDiffString] = React.useState(''); - React.useEffect(() => { - const interval = setInterval(() => { - const now = new Date(); - const diff = now - updated; - const newDiffString = new Date(diff).toISOString().substr(11, 8); - setDiffString(newDiffString); - }, 1000); - - return () => clearInterval(interval); - }, [updated]); - - return ( - {diffString} - ); -} +import TimerArea from '../components/TimerArea'; function _systemStatus(status) { if (null === status) { @@ -30,7 +12,7 @@ function _systemStatus(status) { return ( <> Time since last update:{' '} - +
Pump Running: {pump.running ? "Yes" : "No"}
Time Left: {pump.timeLeft} ms diff --git a/ui/src/components/TimerArea.js b/ui/src/components/TimerArea.js new file mode 100644 index 0000000..2eb0a69 --- /dev/null +++ b/ui/src/components/TimerArea.js @@ -0,0 +1,22 @@ +import React from "react"; +import { timeBetweenAsString } from "../Utils/time"; + +export function TimerArea({ startTime=null, endTime=null, interval=450 }) { + const [countdown, setCountdown] = React.useState(''); + + React.useEffect(() => { + const tid = setInterval(() => { + setCountdown(timeBetweenAsString({ startTime, endTime })); + }, interval); + + return () => clearInterval(tid); + }, [startTime, endTime, interval]); + + return ( + + {countdown} + + ); +} + +export default TimerArea; \ No newline at end of file diff --git a/ui/src/contexts/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js similarity index 75% rename from ui/src/contexts/WaterPumpStatusProvider.js rename to ui/src/components/WaterPumpStatusProvider.js index 915caa5..035cfed 100644 --- a/ui/src/contexts/WaterPumpStatusProvider.js +++ b/ui/src/components/WaterPumpStatusProvider.js @@ -1,15 +1,13 @@ import React from 'react'; import { connect } from 'react-redux'; import { updateSystemStatus } from '../store/slices/SystemStatus'; -import { useWaterPumpAPI } from './WaterPumpAPIContext'; -import { useNotificationsSystem } from './NotificationsContext'; +import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; const FETCH_INTERVAL = 5000; const CHECK_INTERVAL = Math.round(FETCH_INTERVAL / 10); function WaterPumpStatusProviderComoponent({ children, updateStatus, systemStatus }) { const { API } = useWaterPumpAPI(); - const NotificationsSystem = useNotificationsSystem(); const nextFetchTime = React.useRef(0); // Function to fetch water pump status @@ -19,16 +17,10 @@ function WaterPumpStatusProviderComoponent({ children, updateStatus, systemStatu if(null == API) return; nextFetchTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent fetches - try { - const status = await API.status(); - updateStatus(status); - } catch (error) { - NotificationsSystem.alert('Error fetching system status: ' + error.message); - updateStatus(null); - } + await updateStatus(API); nextFetchTime.current = Date.now() + FETCH_INTERVAL; }, - [API, NotificationsSystem, updateStatus, nextFetchTime] + [API, updateStatus, nextFetchTime] ); // Effect to start fetching periodically and when API changes @@ -58,6 +50,5 @@ export default connect( systemStatus: state.systemStatus }), { updateStatus: updateSystemStatus - } )(WaterPumpStatusProviderComoponent); \ No newline at end of file diff --git a/ui/src/contexts/NotificationsContext.js b/ui/src/contexts/NotificationsContext.js deleted file mode 100644 index 6685a68..0000000 --- a/ui/src/contexts/NotificationsContext.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -const NotificationsContext = React.createContext(); - -export function useNotificationsSystem() { - return React.useContext(NotificationsContext); -} - -export function NotificationsProvider({ children }) { - const [notifications, setNotifications] = React.useState(null); - - const value = { - alert: (message) => { setNotifications({ message }); }, - clear: () => { setNotifications(null); }, - currentNotifications: notifications, - }; - - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/ui/src/contexts/WaterPumpAPIContext.js b/ui/src/contexts/WaterPumpAPIContext.js index aff1fac..9f5ae25 100644 --- a/ui/src/contexts/WaterPumpAPIContext.js +++ b/ui/src/contexts/WaterPumpAPIContext.js @@ -1,7 +1,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { CWaterPumpAPI } from '../api/CWaterPumpAPI.js'; -import WaterPumpStatusProvider from './WaterPumpStatusProvider.js'; +import WaterPumpStatusProvider from '../components/WaterPumpStatusProvider.js'; const WaterPumpAPIContext = React.createContext(); diff --git a/ui/src/index.js b/ui/src/index.js index 6777eb1..aa24bae 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -2,7 +2,6 @@ import React from 'react'; import App from './App.js'; import 'bootstrap/dist/css/bootstrap.min.css'; // Importing Bootstrap CSS -import { NotificationsProvider } from './contexts/NotificationsContext.js'; import { WaterPumpAPIProvider } from './contexts/WaterPumpAPIContext.js'; // Redux store import { AppStore } from './store'; @@ -12,11 +11,9 @@ const root = createRoot(document.getElementById('root')); root.render( - - - - - + + + ); diff --git a/ui/src/store/slices/Notifications.js b/ui/src/store/slices/Notifications.js new file mode 100644 index 0000000..9fbc377 --- /dev/null +++ b/ui/src/store/slices/Notifications.js @@ -0,0 +1,18 @@ +import { createSlice } from '@reduxjs/toolkit'; + +export const NotificationsSlice = createSlice({ + name: 'notifications', + initialState: { + currentNotifications: null + }, + reducers: { + alert: (state, action) => { + state.currentNotifications = action.payload; + }, + clear: state => { + state.currentNotifications = null; + } + } +}); + +export const NotificationsSystemActions = NotificationsSlice.actions; \ No newline at end of file diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 8abba9f..8e80d2f 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -1,59 +1,70 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { NotificationsSystemActions } from './Notifications'; -function preprocessSystemStatus(systemStatus) { - if(null == systemStatus) return null; - // convert "water threshold" to "waterThreshold" - systemStatus.waterThreshold = systemStatus["water threshold"]; - delete systemStatus["water threshold"]; - - // convert "time left" to "timeLeft" - systemStatus.pump.timeLeft = systemStatus.pump["time left"]; - delete systemStatus.pump["time left"]; - - // add field "updated" - systemStatus.updated = Date.now(); - return systemStatus; +function withNotification(action, message) { + return async (params, { dispatch }) => { + try { + return await action(params); + } catch(error) { + dispatch(NotificationsSystemActions.alert({ + type: 'error', + message: `${message} (${error.message})` + })); + throw error; + } + }; } // Async thunks export const startPump = createAsyncThunk( 'systemStatus/startPump', - async ({ api, pouringTime }, { dispatch }) => { - console.log('startPump: pouringTime = ' + pouringTime); - const response = await api.start(pouringTime); - return response; - } + withNotification( + async ({ api, pouringTime }) => { + return await api.start(pouringTime); + }, + 'Failed to start pump' + ) ); export const stopPump = createAsyncThunk( 'systemStatus/stopPump', - async ({ api }, { dispatch }) => { - console.log('stopPump'); - const response = await api.stop(); - return response; - } + withNotification( + async ({ api }) => { + return await api.stop(); + }, + 'Failed to stop pump' + ) +); + +export const updateSystemStatus = createAsyncThunk( + 'systemStatus/update', + withNotification( + async ( api ) => { + return await api.status(); + }, + 'Failed to update system status' + ) ); // slice for system status const bindStatus = (state, action) => { - return preprocessSystemStatus(action.payload); + return action.payload; }; export const SystemStatusSlice = createSlice({ name: 'systemStatus', initialState: null, - reducers: { - updateSystemStatus: bindStatus, - }, + reducers: {}, extraReducers: (builder) => { // update system status on start/stop pump builder.addCase(startPump.fulfilled, bindStatus); builder.addCase(stopPump.fulfilled, bindStatus); + builder.addCase(updateSystemStatus.fulfilled, bindStatus); // on error, do not update system status builder.addCase(startPump.rejected, (state, action) => state); builder.addCase(stopPump.rejected, (state, action) => state); + builder.addCase(updateSystemStatus.rejected, (state, action) => state); } }); -export const actions = SystemStatusSlice.actions; -export const { updateSystemStatus } = actions; \ No newline at end of file +export const actions = SystemStatusSlice.actions; \ No newline at end of file diff --git a/ui/src/store/slices/index.js b/ui/src/store/slices/index.js index 6f7cd36..9031b1d 100644 --- a/ui/src/store/slices/index.js +++ b/ui/src/store/slices/index.js @@ -1,7 +1,8 @@ import { SystemStatusSlice } from "./SystemStatus"; import { UISlice } from "./UI"; +import { NotificationsSlice } from "./Notifications"; -const slices = [ SystemStatusSlice, UISlice ]; +const slices = [ SystemStatusSlice, UISlice, NotificationsSlice ]; // export all slices as an object { [sliceName]: slice } export const ALL_APP_SLICES = slices.reduce((acc, slice) => { acc[slice.name] = slice;