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 (
+ : }
+ sx={{ minWidth: '140px', fontSize: '0.75rem' }}
+ >
+ {isActiveForThisTask
+ ? `${formatElapsedTime(elapsedTime)}`
+ : 'Start Timer'}
+
+ );
+};