diff --git a/src/app/slices/timeSlice.test.ts b/src/app/slices/timeSlice.test.ts index b8ebf61..85a4a40 100644 --- a/src/app/slices/timeSlice.test.ts +++ b/src/app/slices/timeSlice.test.ts @@ -3,12 +3,14 @@ import timeReducer, { createDate, startTime, stopTime, + cancelTimer, recordTime, removeTime, toggleSegment, getSegment, getTimesForDate, getTimesForTask, + selectActiveTimer, type TimeState, type TaskTime, } from './timeSlice'; @@ -19,6 +21,7 @@ import type { RootState } from '../store'; describe('timeSlice', () => { const initialState: TimeState = { dateTimes: [], + activeTimer: undefined, }; describe('createDate', () => { @@ -46,9 +49,10 @@ describe('timeSlice', () => { }); describe('startTime', () => { - it('should add a time entry without an end time', () => { + it('should add a time entry without an end time and set activeTimer', () => { const stateWithDate: TimeState = { dateTimes: [{ date: mockToday, taskTimes: [] }], + activeTimer: undefined, }; const action = startTime({ date: mockToday, taskId: 1 }); @@ -60,6 +64,12 @@ describe('timeSlice', () => { start: expect.any(Number), }); expect(newState.dateTimes[0].taskTimes[0].end).toBeUndefined(); + expect(newState.activeTimer).toBeDefined(); + expect(newState.activeTimer?.taskId).toBe(1); + expect(newState.activeTimer?.date).toBe(mockToday); + expect(newState.activeTimer?.startTime).toBe( + newState.dateTimes[0].taskTimes[0].start + ); }); it('should not add time if date does not exist', () => { @@ -67,11 +77,36 @@ describe('timeSlice', () => { const newState = timeReducer(initialState, action); expect(newState.dateTimes).toHaveLength(0); + expect(newState.activeTimer).toBeUndefined(); + }); + + it('should not start a timer if another timer is already active', () => { + const existingStartTime = Date.now() - 5000; + const stateWithActiveTimer: TimeState = { + dateTimes: [ + { + date: mockToday, + taskTimes: [{ task: 1, start: existingStartTime }], + }, + ], + activeTimer: { + taskId: 1, + startTime: existingStartTime, + date: mockToday, + }, + }; + + const action = startTime({ date: mockToday, taskId: 2 }); + const newState = timeReducer(stateWithActiveTimer, action); + + // Should not change state + expect(newState.dateTimes[0].taskTimes).toHaveLength(1); + expect(newState.activeTimer?.taskId).toBe(1); }); }); describe('stopTime', () => { - it('should add end time to an existing time entry', () => { + it('should add end time to an existing time entry and clear activeTimer', () => { const startTimestamp = new Date('2024-01-15T10:00:00').getTime(); const stateWithStartedTime: TimeState = { dateTimes: [ @@ -80,45 +115,136 @@ describe('timeSlice', () => { taskTimes: [{ task: 1, start: startTimestamp }], }, ], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, }; - const action = stopTime({ - date: mockToday, - taskId: 1, - start: startTimestamp, - }); + const action = stopTime(); const newState = timeReducer(stateWithStartedTime, action); expect(newState.dateTimes[0].taskTimes[0].end).toBeDefined(); expect(newState.dateTimes[0].taskTimes[0].end).toBeGreaterThan( startTimestamp ); + expect(newState.activeTimer).toBeUndefined(); + }); + + it('should not modify state if no active timer', () => { + const stateWithDate: TimeState = { + dateTimes: [{ date: mockToday, taskTimes: [] }], + activeTimer: undefined, + }; + + const action = stopTime(); + const newState = timeReducer(stateWithDate, action); + + expect(newState.dateTimes[0].taskTimes).toHaveLength(0); + expect(newState.activeTimer).toBeUndefined(); }); it('should not modify state if date does not exist', () => { - const action = stopTime({ - date: mockToday, - taskId: 1, - start: Date.now(), - }); - const newState = timeReducer(initialState, action); + const startTimestamp = Date.now(); + const stateWithActiveTimer: TimeState = { + dateTimes: [], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, + }; + + const action = stopTime(); + const newState = timeReducer(stateWithActiveTimer, action); expect(newState.dateTimes).toHaveLength(0); + expect(newState.activeTimer).toBeUndefined(); }); it('should not modify state if task time does not exist', () => { + const startTimestamp = Date.now(); const stateWithDate: TimeState = { dateTimes: [{ date: mockToday, taskTimes: [] }], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, }; - const action = stopTime({ - date: mockToday, - taskId: 1, - start: Date.now(), - }); + const action = stopTime(); + const newState = timeReducer(stateWithDate, action); + + expect(newState.dateTimes[0].taskTimes).toHaveLength(0); + expect(newState.activeTimer).toBeUndefined(); + }); + }); + + describe('cancelTimer', () => { + it('should remove the active timer entry and clear activeTimer', () => { + const startTimestamp = Date.now() - 5000; + const stateWithActiveTimer: TimeState = { + dateTimes: [ + { + date: mockToday, + taskTimes: [{ task: 1, start: startTimestamp }], + }, + ], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, + }; + + const action = cancelTimer(); + const newState = timeReducer(stateWithActiveTimer, action); + + expect(newState.dateTimes[0].taskTimes).toHaveLength(0); + expect(newState.activeTimer).toBeUndefined(); + }); + + it('should not modify state if no active timer', () => { + const stateWithDate: TimeState = { + dateTimes: [{ date: mockToday, taskTimes: [] }], + activeTimer: undefined, + }; + + const action = cancelTimer(); const newState = timeReducer(stateWithDate, action); expect(newState.dateTimes[0].taskTimes).toHaveLength(0); + expect(newState.activeTimer).toBeUndefined(); + }); + + it('should not remove other time entries', () => { + const startTimestamp1 = Date.now() - 10000; + const startTimestamp2 = Date.now() - 5000; + const stateWithMultipleTimes: TimeState = { + dateTimes: [ + { + date: mockToday, + taskTimes: [ + { task: 1, start: startTimestamp1, end: Date.now() - 6000 }, + { task: 1, start: startTimestamp2 }, + ], + }, + ], + activeTimer: { + taskId: 1, + startTime: startTimestamp2, + date: mockToday, + }, + }; + + const action = cancelTimer(); + const newState = timeReducer(stateWithMultipleTimes, action); + + expect(newState.dateTimes[0].taskTimes).toHaveLength(1); + expect(newState.dateTimes[0].taskTimes[0].start).toBe(startTimestamp1); + expect(newState.activeTimer).toBeUndefined(); }); }); @@ -438,6 +564,7 @@ describe('timeSlice', () => { it('should handle date without entry', () => { const state: TimeState = { dateTimes: [], + activeTimer: undefined, }; const result = getSegment(state, mockToday, 1, 0); @@ -471,6 +598,7 @@ describe('timeSlice', () => { taskTimes: [{ task: 1, start, end }], }, ], + activeTimer: undefined, }; const result = getSegment(state, mockToday, 1, 0); @@ -496,6 +624,7 @@ describe('timeSlice', () => { const state: TimeState = { dateTimes: [{ date: mockToday, taskTimes }], + activeTimer: undefined, }; const result = getTimesForDate(state, mockToday); @@ -538,6 +667,7 @@ describe('timeSlice', () => { const state: RootState = { time: { dateTimes: [{ date: mockToday, taskTimes }], + activeTimer: undefined, }, } as RootState; @@ -552,6 +682,7 @@ describe('timeSlice', () => { const state: RootState = { time: { dateTimes: [], + activeTimer: undefined, }, } as unknown as RootState; @@ -572,6 +703,7 @@ describe('timeSlice', () => { const state: RootState = { time: { dateTimes: [{ date: mockToday, taskTimes }], + activeTimer: undefined, }, } as RootState; @@ -580,4 +712,45 @@ describe('timeSlice', () => { expect(result).toEqual([]); }); }); + + describe('selectActiveTimer', () => { + it('should return undefined when no timer is active', () => { + const state = { + time: { + dateTimes: [], + activeTimer: undefined, + }, + } as unknown as RootState; + + const result = selectActiveTimer(state); + + expect(result).toBeUndefined(); + }); + + it('should return the active timer when one exists', () => { + const startTimestamp = Date.now(); + const state: RootState = { + time: { + dateTimes: [ + { + date: mockToday, + taskTimes: [{ task: 1, start: startTimestamp }], + }, + ], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, + }, + } as RootState; + + const result = selectActiveTimer(state); + + expect(result).toBeDefined(); + expect(result?.taskId).toBe(1); + expect(result?.startTime).toBe(startTimestamp); + expect(result?.date).toBe(mockToday); + }); + }); }); diff --git a/src/app/slices/timeSlice.ts b/src/app/slices/timeSlice.ts index ce7e649..5dac1df 100644 --- a/src/app/slices/timeSlice.ts +++ b/src/app/slices/timeSlice.ts @@ -8,6 +8,11 @@ import { START_HOUR } from '../constants'; export interface TimeState { dateTimes: DateTimes[]; + activeTimer?: { + taskId: number; + startTime: number; + date: number; + }; } export interface DateTimes { @@ -30,6 +35,7 @@ const initialState: TimeState = { taskTimes: [], }, ], + activeTimer: undefined, }; export const timeSlice = createSlice({ @@ -58,37 +64,66 @@ export const timeSlice = createSlice({ taskId: number; }> ) => { + // Only allow one timer at a time + if (state.activeTimer) { + return; + } + const dateTimes = state.dateTimes.find( (dateTimes) => dateTimes.date === action.payload.date ); if (dateTimes) { + const startTime = Date.now(); dateTimes.taskTimes.push({ task: action.payload.taskId, - start: Date.now(), + start: startTime, }); + state.activeTimer = { + taskId: action.payload.taskId, + startTime: startTime, + date: action.payload.date, + }; } }, - stopTime: ( - state, - action: PayloadAction<{ - date: number; - taskId: number; - start: number; - }> - ) => { + stopTime: (state) => { + if (!state.activeTimer) { + return; + } + const dateTimes = state.dateTimes.find( - (dateTimes) => dateTimes.date === action.payload.date + (dateTimes) => dateTimes.date === state.activeTimer!.date ); if (dateTimes) { const taskTime = dateTimes.taskTimes.find( (taskTime) => - taskTime.task === action.payload.taskId && - taskTime.start === action.payload.start + taskTime.task === state.activeTimer!.taskId && + taskTime.start === state.activeTimer!.startTime ); if (taskTime) { taskTime.end = Date.now(); } } + state.activeTimer = undefined; + }, + cancelTimer: (state) => { + if (!state.activeTimer) { + return; + } + + const dateTimes = state.dateTimes.find( + (dateTimes) => dateTimes.date === state.activeTimer!.date + ); + if (dateTimes) { + const taskTimeIndex = dateTimes.taskTimes.findIndex( + (taskTime) => + taskTime.task === state.activeTimer!.taskId && + taskTime.start === state.activeTimer!.startTime + ); + if (taskTimeIndex !== -1) { + dateTimes.taskTimes.splice(taskTimeIndex, 1); + } + } + state.activeTimer = undefined; }, recordTime: ( state, @@ -176,6 +211,7 @@ export const { createDate, startTime, stopTime, + cancelTimer, recordTime, removeTime, toggleSegment, @@ -253,4 +289,6 @@ export const getTimesForTask = createSelector( } ); +export const selectActiveTimer = (state: RootState) => state.time.activeTimer; + export default timeSlice.reducer; diff --git a/src/components/layout/Table.tsx b/src/components/layout/Table.tsx index 13d3c62..f72af94 100644 --- a/src/components/layout/Table.tsx +++ b/src/components/layout/Table.tsx @@ -66,6 +66,7 @@ export const Table = () => { Task + Timer Total diff --git a/src/components/task/TaskRow.tsx b/src/components/task/TaskRow.tsx index 9e024e2..c362753 100644 --- a/src/components/task/TaskRow.tsx +++ b/src/components/task/TaskRow.tsx @@ -13,6 +13,7 @@ import { import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import { TimeRowCell } from '../time/TimeRow'; import { TimeSummaryCell } from '../time/TimeSummaryCell'; +import { TimerButton } from '../time/TimerButton'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; import { deleteTask, getTask, updateTask } from '../../app/slices/taskSlice'; import { @@ -213,6 +214,9 @@ export const TaskRow = (props: TaskRowProps) => { /> {task.description} + + + {taskRowTime()} ); @@ -279,6 +283,9 @@ export const TaskRow = (props: TaskRowProps) => { + + + {taskRowTime()} ); diff --git a/src/components/time/TimerButton.test.tsx b/src/components/time/TimerButton.test.tsx new file mode 100644 index 0000000..5cafd5d --- /dev/null +++ b/src/components/time/TimerButton.test.tsx @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { TimerButton } from './TimerButton'; +import { renderWithProviders, mockToday } from '../../test-utils/test-utils'; +import type { RootState } from '../../app/store'; + +describe('TimerButton', () => { + const createPreloadedState = ( + override: Partial = {} + ): Partial => ({ + app: { + version: '1.0', + selectedDate: mockToday, + }, + time: { + dateTimes: [{ date: mockToday, taskTimes: [] }], + activeTimer: undefined, + }, + task: { + nextTaskId: 2, + tasks: [{ id: 1, description: 'Test Task', type: 'task' }], + }, + edit: {}, + ...override, + }); + + it('should render "Start Timer" button when no timer is active', () => { + const { container } = renderWithProviders( + , + { preloadedState: createPreloadedState() } + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toContain('Start Timer'); + }); + + it('should start a timer when "Start Timer" is clicked', async () => { + const user = userEvent.setup(); + const { container, store } = renderWithProviders( + , + { preloadedState: createPreloadedState() } + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + + await user.click(button!); + + const state = store.getState() as RootState; + expect(state.time.activeTimer).toBeDefined(); + expect(state.time.activeTimer?.taskId).toBe(1); + expect(state.time.activeTimer?.date).toBe(mockToday); + }); + + it('should show elapsed time display when timer is active', () => { + const startTimestamp = Date.now(); + const preloadedState = createPreloadedState({ + time: { + dateTimes: [ + { + date: mockToday, + taskTimes: [{ task: 1, start: startTimestamp }], + }, + ], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, + }, + }); + + const { container } = renderWithProviders( + , + { preloadedState } + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + // Should show time in format HH:MM:SS + expect(button?.textContent).toMatch(/\d{2}:\d{2}:\d{2}/); + }); + + it('should stop the timer when clicked while active', async () => { + const user = userEvent.setup(); + const startTimestamp = Date.now() - 5000; + const preloadedState = createPreloadedState({ + time: { + dateTimes: [ + { + date: mockToday, + taskTimes: [{ task: 1, start: startTimestamp }], + }, + ], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, + }, + }); + + const { container, store } = renderWithProviders( + , + { preloadedState } + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + + await user.click(button!); + + const state = store.getState() as RootState; + expect(state.time.activeTimer).toBeUndefined(); + expect(state.time.dateTimes[0].taskTimes[0].end).toBeDefined(); + }); + + it('should disable the button when another task has an active timer', () => { + const startTimestamp = Date.now(); + const preloadedState = createPreloadedState({ + time: { + dateTimes: [ + { + date: mockToday, + taskTimes: [{ task: 2, start: startTimestamp }], + }, + ], + activeTimer: { + taskId: 2, + startTime: startTimestamp, + date: mockToday, + }, + }, + task: { + nextTaskId: 3, + tasks: [ + { id: 1, description: 'Test Task 1', type: 'task' }, + { id: 2, description: 'Test Task 2', type: 'task' }, + ], + }, + }); + + const { container } = renderWithProviders( + , + { preloadedState } + ); + + const button = container.querySelector('button') as HTMLButtonElement; + expect(button).toBeTruthy(); + expect(button.disabled).toBe(true); + }); + + it('should show contained error style when timer is active for this task', () => { + const startTimestamp = Date.now(); + const preloadedState = createPreloadedState({ + time: { + dateTimes: [ + { + date: mockToday, + taskTimes: [{ task: 1, start: startTimestamp }], + }, + ], + activeTimer: { + taskId: 1, + startTime: startTimestamp, + date: mockToday, + }, + }, + }); + + const { container } = renderWithProviders( + , + { preloadedState } + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.classList.contains('MuiButton-contained')).toBe(true); + expect(button?.classList.contains('MuiButton-containedError')).toBe(true); + }); + + it('should show outlined primary style when no timer is active', () => { + const { container } = renderWithProviders( + , + { preloadedState: createPreloadedState() } + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.classList.contains('MuiButton-outlined')).toBe(true); + expect(button?.classList.contains('MuiButton-outlinedPrimary')).toBe(true); + }); +}); diff --git a/src/components/time/TimerButton.tsx b/src/components/time/TimerButton.tsx new file mode 100644 index 0000000..aaf1b5e --- /dev/null +++ b/src/components/time/TimerButton.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import { Button } from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import StopIcon from '@mui/icons-material/Stop'; +import { useAppDispatch, useAppSelector } from '../../app/hooks'; +import { + startTime, + stopTime, + selectActiveTimer, +} from '../../app/slices/timeSlice'; + +interface TimerButtonProps { + taskId: number; + date: number; +} + +export const TimerButton = ({ taskId, date }: TimerButtonProps) => { + const dispatch = useAppDispatch(); + const activeTimer = useAppSelector(selectActiveTimer); + const [elapsedTime, setElapsedTime] = useState(0); + + const isActiveForThisTask = activeTimer?.taskId === taskId; + const isActiveForOtherTask = activeTimer && !isActiveForThisTask; + + // Update elapsed time every second when timer is active for this task + useEffect(() => { + if (!isActiveForThisTask || !activeTimer) { + setElapsedTime(0); + return; + } + + const updateElapsed = () => { + const elapsed = Date.now() - activeTimer.startTime; + setElapsedTime(elapsed); + }; + + // Update immediately + updateElapsed(); + + // Then update every second + const interval = setInterval(updateElapsed, 1000); + return () => clearInterval(interval); + }, [isActiveForThisTask, activeTimer]); + + const formatElapsedTime = (ms: number): string => { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }; + + const handleClick = () => { + if (isActiveForThisTask) { + dispatch(stopTime()); + } else { + dispatch(startTime({ date, taskId })); + } + }; + + return ( + + ); +};