Skip to content

Commit 17c057f

Browse files
committed
✨ feat(project-activity): Implement time tracking and visualization
1 parent 4dcf102 commit 17c057f

18 files changed

+434
-33
lines changed

src/__tests__/components/FocusOverlay.test.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ vi.mock("framer-motion", () => ({
2222
{children}
2323
</div>
2424
),
25+
span: ({ children, className, ...props }: any) => (
26+
<span className={className} {...props}>
27+
{children}
28+
</span>
29+
),
2530
},
2631
}));
2732

@@ -63,8 +68,7 @@ describe("FocusOverlay", () => {
6368
it("renders timer view when active", () => {
6469
render(<FocusOverlay />);
6570
expect(screen.getByText("Test Task")).toBeInTheDocument();
66-
expect(screen.getByText("Focus Session")).toBeInTheDocument();
67-
expect(screen.getByText("00:00:00")).toBeInTheDocument();
71+
expect(screen.getAllByText("0")).toHaveLength(6);
6872
});
6973

7074
it("toggles timer on button click", () => {
@@ -90,6 +94,6 @@ describe("FocusOverlay", () => {
9094
const completeButton = screen.getByRole("button", { name: "Complete task" });
9195
fireEvent.click(completeButton);
9296

93-
expect(screen.getByText("Focus session ended")).toBeInTheDocument();
97+
expect(screen.getByText("Session Complete")).toBeInTheDocument();
9498
});
9599
});
Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
1-
import { render, screen } from "@testing-library/react";
1+
import { render, screen, waitFor } from "@testing-library/react";
22
import { describe, expect, it, vi } from "vitest";
33
import { ProjectMiniChart } from "@/components/project-view/ProjectMiniChart";
44
import * as useProjectChartDataHook from "@/hooks/useProjectChartData";
55

66
vi.mock("@/hooks/useProjectChartData");
77

88
describe("ProjectMiniChart", () => {
9-
it("renders correctly with data", () => {
9+
it("renders correctly with data", async () => {
1010
vi.mocked(useProjectChartDataHook.useProjectChartData).mockReturnValue([
11-
{ date: "Mon", fullDate: "Jan 5", completed: 1, created: 2 },
12-
{ date: "Tue", fullDate: "Jan 6", completed: 2, created: 1 },
11+
{ date: "Mon", fullDate: "Jan 5", completed: 1, created: 2, timeSpent: 1.5 },
12+
{ date: "Tue", fullDate: "Jan 6", completed: 2, created: 1, timeSpent: 3.0 },
1313
]);
1414

15+
// Mock clientWidth
16+
Object.defineProperty(window.SVGElement.prototype, "clientWidth", {
17+
value: 500,
18+
configurable: true,
19+
});
20+
1521
render(<ProjectMiniChart projectId="p1" />);
1622

1723
expect(screen.getByText("Week Activity")).toBeInTheDocument();
18-
// SVG is rendered
19-
expect(document.querySelector("svg")).toBeInTheDocument();
24+
25+
// Wait for D3 to render
26+
await waitFor(() => {
27+
const svg = screen.getByTestId("mini-chart-svg");
28+
expect(svg).toBeInTheDocument();
29+
expect(svg.querySelectorAll("path").length).toBe(4);
30+
});
2031
});
2132
});

src/__tests__/components/ProjectSummary.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render, screen } from "@testing-library/react";
22
import { describe, expect, it, vi } from "vitest";
33
import { ProjectSummary } from "@/components/project-view/ProjectSummary";
4+
import * as useProjectChartDataHook from "@/hooks/useProjectChartData";
45

56
// Mock child components to isolate the test
67
vi.mock("@/components/project-view/ProjectStatsCard", () => ({
@@ -11,8 +12,15 @@ vi.mock("@/components/project-view/ProjectMiniChart", () => ({
1112
ProjectMiniChart: () => <div data-testid="mini-chart">Mini Chart</div>,
1213
}));
1314

15+
vi.mock("@/hooks/useProjectChartData");
16+
1417
describe("ProjectSummary", () => {
15-
it("renders both child components", () => {
18+
it("renders child components and time metrics", () => {
19+
vi.mocked(useProjectChartDataHook.useProjectChartData).mockReturnValue([
20+
{ date: "Mon", fullDate: "Jan 5", completed: 1, created: 2, timeSpent: 1.5 },
21+
{ date: "Tue", fullDate: "Jan 6", completed: 2, created: 1, timeSpent: 2.5 },
22+
]);
23+
1624
render(<ProjectSummary projectId="p1" projectColor="#000" />);
1725
expect(screen.getByTestId("stats-card")).toBeInTheDocument();
1826
expect(screen.getByTestId("mini-chart")).toBeInTheDocument();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { useProjectChartData } from "@/hooks/useProjectChartData";
4+
import { useStore } from "@/store/useStore";
5+
6+
describe("useProjectChartData Time Aggregation", () => {
7+
beforeEach(() => {
8+
useStore.getState().reset();
9+
vi.useFakeTimers();
10+
vi.setSystemTime(new Date("2026-01-11T12:00:00Z")); // Jan 11 is Sun
11+
});
12+
13+
afterEach(() => {
14+
vi.useRealTimers();
15+
});
16+
17+
it("should aggregate timeSpentPerDay into chart data", () => {
18+
const project = useStore.getState().addProject("Test Project");
19+
const task1 = useStore.getState().addTask(project.id, "Task 1");
20+
const task2 = useStore.getState().addTask(project.id, "Task 2");
21+
22+
// Jan 11 (Sun): 1h on task1
23+
vi.setSystemTime(new Date("2026-01-11T12:00:00Z"));
24+
useStore.getState().updateTaskTime(task1.id, 3600000);
25+
26+
// Verify task state directly
27+
const t1 = useStore.getState().tasks.find(t => t.id === task1.id);
28+
expect(t1?.timeSpentPerDay?.["2026-01-11"]).toBe(3600000);
29+
30+
// Jan 10 (Sat): 30m on task1, 30m on task2
31+
vi.setSystemTime(new Date("2026-01-10T12:00:00Z"));
32+
useStore.getState().updateTaskTime(task1.id, 1800000);
33+
useStore.getState().updateTaskTime(task2.id, 1800000);
34+
35+
// Set time back to "today" (Sun Jan 11) before hook runs
36+
vi.setSystemTime(new Date("2026-01-11T12:00:00Z"));
37+
38+
const { result } = renderHook(() => useProjectChartData(project.id, "week"));
39+
40+
// Verify Sun (Jan 11)
41+
const sunData = result.current.find(d => d.date === "Sun");
42+
expect(sunData?.timeSpent).toBe(1); // 1 hour
43+
44+
// Verify Sat (Jan 10)
45+
const satData = result.current.find(d => d.date === "Sat");
46+
expect(satData?.timeSpent).toBe(1); // 30m + 30m = 1 hour
47+
48+
// Verify Fri (Jan 09)
49+
const friData = result.current.find(d => d.date === "Fri");
50+
expect(friData?.timeSpent).toBe(0);
51+
});
52+
});

src/__tests__/hooks/useProjectStats.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("useProjectStats", () => {
1616
inProgress: 0,
1717
done: 0,
1818
progress: 0,
19+
totalTimeSpent: 0,
1920
});
2021
});
2122

@@ -41,6 +42,7 @@ describe("useProjectStats", () => {
4142
inProgress: 1,
4243
done: 1,
4344
progress: 33, // (1/3) * 100 = 33.33 -> 33
45+
totalTimeSpent: 0,
4446
});
4547
});
4648

@@ -63,6 +65,7 @@ describe("useProjectStats", () => {
6365
inProgress: 0,
6466
done: 0,
6567
progress: 0,
68+
totalTimeSpent: 0,
6669
});
6770
});
6871

@@ -75,6 +78,7 @@ describe("useProjectStats", () => {
7578
inProgress: 0,
7679
done: 0,
7780
progress: 0,
81+
totalTimeSpent: 0,
7882
});
7983
});
8084
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { useStore } from "@/store/useStore";
3+
4+
describe("FocusSlice", () => {
5+
beforeEach(() => {
6+
useStore.getState().reset();
7+
vi.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
vi.useRealTimers();
12+
});
13+
14+
it("should track time spent per day correctly", () => {
15+
// Setup: Create task
16+
const project = useStore.getState().addProject("Test Project");
17+
const task = useStore.getState().addTask(project.id, "Test Task");
18+
19+
// Set a specific date
20+
const dateStr = "2024-01-20";
21+
vi.setSystemTime(new Date(dateStr + "T12:00:00Z"));
22+
23+
// Action: Update time
24+
useStore.getState().updateTaskTime(task.id, 3600000); // 1 hour
25+
26+
// Verify
27+
const updatedTask = useStore.getState().tasks.find(t => t.id === task.id);
28+
expect(updatedTask?.totalTimeSpent).toBe(3600000);
29+
expect(updatedTask?.timeSpentPerDay).toBeDefined();
30+
// Keys in Record<string, number> are ISO date strings YYYY-MM-DD usually,
31+
// but I need to decide on the key format. ISO date string YYYY-MM-DD is standard.
32+
expect(updatedTask?.timeSpentPerDay?.[dateStr]).toBe(3600000);
33+
});
34+
35+
it("should accumulate time for the same day", () => {
36+
const project = useStore.getState().addProject("Test Project");
37+
const task = useStore.getState().addTask(project.id, "Test Task");
38+
const dateStr = "2024-01-20";
39+
vi.setSystemTime(new Date(dateStr + "T12:00:00Z"));
40+
41+
useStore.getState().updateTaskTime(task.id, 1000);
42+
useStore.getState().updateTaskTime(task.id, 2000);
43+
44+
const updatedTask = useStore.getState().tasks.find(t => t.id === task.id);
45+
expect(updatedTask?.timeSpentPerDay?.[dateStr]).toBe(3000);
46+
});
47+
48+
it("should track time for different days separately", () => {
49+
const project = useStore.getState().addProject("Test Project");
50+
const task = useStore.getState().addTask(project.id, "Test Task");
51+
52+
// Day 1
53+
const date1 = "2024-01-20";
54+
vi.setSystemTime(new Date(date1 + "T12:00:00Z"));
55+
useStore.getState().updateTaskTime(task.id, 1000);
56+
57+
// Day 2
58+
const date2 = "2024-01-21";
59+
vi.setSystemTime(new Date(date2 + "T12:00:00Z"));
60+
useStore.getState().updateTaskTime(task.id, 2000);
61+
62+
const updatedTask = useStore.getState().tasks.find(t => t.id === task.id);
63+
expect(updatedTask?.timeSpentPerDay?.[date1]).toBe(1000);
64+
expect(updatedTask?.timeSpentPerDay?.[date2]).toBe(2000);
65+
expect(updatedTask?.totalTimeSpent).toBe(3000);
66+
});
67+
});

src/__tests__/store/useStore.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,19 @@ describe("useStore (New Store)", () => {
3030
expect(state.tasks[0]).toEqual(task);
3131
});
3232

33-
it("should change active view", () => {
34-
useStore.getState().setActiveView("analytics");
35-
expect(useStore.getState().activeView).toBe("analytics");
33+
it("should pause focus session and record time", () => {
34+
const project = useStore.getState().addProject("Test Project");
35+
const task = useStore.getState().addTask(project.id, "Test Task");
36+
37+
useStore.getState().startFocusSession(task.id);
38+
expect(useStore.getState().isFocusModeActive).toBe(true);
39+
40+
// Pause session with 5 minutes elapsed
41+
useStore.getState().pauseFocusSession(300000);
42+
43+
const state = useStore.getState();
44+
expect(state.isFocusModeActive).toBe(false);
45+
expect(state.tasks[0].totalTimeSpent).toBe(300000);
46+
expect(state.activeFocusTaskId).toBeNull();
3647
});
3748
});

src/components/focus/FocusOverlay.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function FocusOverlay() {
1919
const {
2020
isFocusModeActive,
2121
activeFocusTaskId,
22+
pauseFocusSession,
2223
endFocusSession,
2324
cancelFocusSession,
2425
updateTaskTime,
@@ -31,6 +32,7 @@ export function FocusOverlay() {
3132
elapsedTime,
3233
isRunning,
3334
setIsRunning,
35+
toggleTimer,
3436
showSummary,
3537
setShowSummary,
3638
summaryNote,
@@ -39,25 +41,48 @@ export function FocusOverlay() {
3941
setShowCancelDialog,
4042
handleCloseAttempt,
4143
handleConfirmCancel,
44+
45+
// Editing props
46+
isEditing,
47+
editedTime,
48+
handleTimeEdit,
49+
isDirty,
50+
showConfirmResume,
51+
confirmTimeUpdate,
52+
cancelTimeUpdate,
4253
} = useFocusTimer({
4354
isFocusModeActive,
4455
task,
4556
cancelFocusSession,
57+
initialTime: task?.totalTimeSpent || 0,
4658
});
4759

48-
const formatTime = (ms: number) => {
49-
const seconds = Math.floor((ms / 1000) % 60);
50-
const minutes = Math.floor((ms / (1000 * 60)) % 60);
51-
const hours = Math.floor(ms / (1000 * 60 * 60));
60+
const formatDuration = (ms: number) => {
61+
const totalSeconds = Math.floor(ms / 1000);
62+
const minutes = Math.floor(totalSeconds / 60);
63+
const seconds = totalSeconds % 60;
64+
65+
if (minutes === 0) return `${seconds}s`;
66+
return `${minutes}m ${seconds}s`;
67+
};
5268

53-
const pad = (num: number) => num.toString().padStart(2, "0");
54-
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
69+
const handleBreak = () => {
70+
if (activeFocusTaskId) {
71+
const finalTime = isDirty ? editedTime : elapsedTime;
72+
const sessionDuration = finalTime - (task?.totalTimeSpent || 0);
73+
pauseFocusSession(sessionDuration);
74+
toast.info("Session paused", {
75+
description: `Recorded ${formatDuration(sessionDuration)} of focus time.`,
76+
});
77+
}
5578
};
5679

5780
const handleFinish = () => {
5881
if (activeFocusTaskId) {
82+
const finalTime = isDirty ? editedTime : elapsedTime;
83+
const sessionDuration = finalTime - (task?.totalTimeSpent || 0);
5984
// Record time
60-
updateTaskTime(activeFocusTaskId, elapsedTime);
85+
updateTaskTime(activeFocusTaskId, sessionDuration);
6186

6287
// Update status and description
6388
const updates: any = { status: "done" };
@@ -67,7 +92,7 @@ export function FocusOverlay() {
6792
updateTask(activeFocusTaskId, updates);
6893

6994
toast.success("Task completed!", {
70-
description: `Focused for ${formatTime(elapsedTime)}`,
95+
description: `Total time focused: ${formatDuration(finalTime)}`,
7196
});
7297
}
7398
endFocusSession();
@@ -90,12 +115,21 @@ export function FocusOverlay() {
90115
taskDescription={task.description}
91116
elapsedTime={elapsedTime}
92117
isRunning={isRunning}
93-
onToggleTimer={() => setIsRunning(!isRunning)}
118+
onToggleTimer={toggleTimer}
119+
onBreak={handleBreak}
94120
onEndSession={() => {
95121
setIsRunning(false);
96122
setShowSummary(true);
97123
}}
98124
onClose={handleCloseAttempt}
125+
126+
// Editing props
127+
isEditing={!isRunning} // Always allow editing when paused
128+
editedTime={editedTime}
129+
onTimeEdit={handleTimeEdit}
130+
showConfirmResume={showConfirmResume}
131+
onConfirmResume={confirmTimeUpdate}
132+
onCancelResume={cancelTimeUpdate}
99133
/>
100134
) : (
101135
<div className="flex flex-col items-center justify-center h-full text-center max-w-2xl mx-auto">

0 commit comments

Comments
 (0)