From 5664c09221c8c39963ff551175b3697d2317e273 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Mon, 16 Jun 2025 14:25:00 +0800 Subject: [PATCH 01/35] feat(time-tracker): enhance UX with user task prioritization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add assignee information to tasks API with is_assigned_to_current_user flag - Implement smart task sorting: assigned tasks → priority → creation date - Add 'My Tasks' and 'Unassigned' filters with count badges - Add filter persistence using localStorage with workspace-specific keys - Enhance task cards with assignee avatars and assignment status - Add visual indicators for user-assigned tasks (blue styling) - Implement active filter chips with individual remove functionality - Add feature parity between sidebar and timer controls filtering" --- .../components/timer-controls.tsx | 225 +++++++++-- .../time-tracker/time-tracker-content.tsx | 356 +++++++++++++++--- .../api/v1/workspaces/[wsId]/tasks/route.ts | 23 +- 3 files changed, 531 insertions(+), 73 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 6235a6d37..2020728a2 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -47,6 +47,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; interface ExtendedWorkspaceTask extends Partial { board_name?: string; list_name?: string; + assignees?: Array<{ + id: string; + display_name?: string; + avatar_url?: string; + email?: string; + }>; + is_assigned_to_current_user?: boolean; } interface SessionWithRelations extends TimeTrackingSession { @@ -143,6 +150,7 @@ export function TimerControls({ status: 'all', board: 'all', list: 'all', + assignee: 'all', }); const [isTaskDropdownOpen, setIsTaskDropdownOpen] = useState(false); const [isSearchMode, setIsSearchMode] = useState(false); @@ -594,31 +602,59 @@ export function TimerControls({ const selectedBoard = boards.find((board) => board.id === selectedBoardId); const availableLists = selectedBoard?.task_lists || []; - // Filter tasks based on search and filters + // Filter and sort tasks with user prioritization const getFilteredTasks = () => { - return tasks.filter((task) => { - const matchesSearch = task.name - ?.toLowerCase() - .includes(taskSearchQuery.toLowerCase()); - const matchesPriority = - taskFilters.priority === 'all' || - String(task.priority) === taskFilters.priority; - const matchesStatus = - taskFilters.status === 'all' || - (task.completed ? 'completed' : 'active') === taskFilters.status; - const matchesBoard = - taskFilters.board === 'all' || task.board_name === taskFilters.board; - const matchesList = - taskFilters.list === 'all' || task.list_name === taskFilters.list; - - return ( - matchesSearch && - matchesPriority && - matchesStatus && - matchesBoard && - matchesList - ); - }); + return tasks + .filter((task) => { + const matchesSearch = task.name + ?.toLowerCase() + .includes(taskSearchQuery.toLowerCase()) || + task.description + ?.toLowerCase() + .includes(taskSearchQuery.toLowerCase()); + const matchesPriority = + taskFilters.priority === 'all' || + String(task.priority) === taskFilters.priority; + const matchesStatus = + taskFilters.status === 'all' || + (task.completed ? 'completed' : 'active') === taskFilters.status; + const matchesBoard = + taskFilters.board === 'all' || task.board_name === taskFilters.board; + const matchesList = + taskFilters.list === 'all' || task.list_name === taskFilters.list; + const matchesAssignee = + taskFilters.assignee === 'all' || + (taskFilters.assignee === 'mine' && task.is_assigned_to_current_user) || + (taskFilters.assignee === 'unassigned' && (!task.assignees || task.assignees.length === 0)); + + return ( + matchesSearch && + matchesPriority && + matchesStatus && + matchesBoard && + matchesList && + matchesAssignee + ); + }) + .sort((a, b) => { + // Prioritize user's assigned tasks + if (a.is_assigned_to_current_user && !b.is_assigned_to_current_user) { + return -1; + } + if (!a.is_assigned_to_current_user && b.is_assigned_to_current_user) { + return 1; + } + + // Then sort by priority (higher priority first) + const aPriority = a.priority || 0; + const bPriority = b.priority || 0; + if (aPriority !== bPriority) { + return bPriority - aPriority; + } + + // Finally sort by creation date (newest first) + return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime(); + }); }; // Get unique boards and lists for filter options @@ -1344,6 +1380,74 @@ export function TimerControls({
Quick Filters
+ + {/* Assignee Filters */} +
+ + +
+ + {/* Board Filters */}
+ +
+ + {/* Search and Dropdown Filters */}
+ + {/* Active Filters Display */} + {(tasksSidebarSearch || + tasksSidebarFilters.board !== 'all' || + tasksSidebarFilters.list !== 'all' || + tasksSidebarFilters.assignee !== 'all') && ( +
+ Active filters: + {tasksSidebarSearch && ( + + Search: "{tasksSidebarSearch}" + + + )} + {tasksSidebarFilters.board !== 'all' && ( + + Board: {tasksSidebarFilters.board} + + + )} + {tasksSidebarFilters.list !== 'all' && ( + + List: {tasksSidebarFilters.list} + + + )} + {tasksSidebarFilters.assignee !== 'all' && ( + + {tasksSidebarFilters.assignee === 'mine' ? 'My Tasks' : + tasksSidebarFilters.assignee === 'unassigned' ? 'Unassigned' : + 'Assignee Filter'} + + + )} + +
+ )}
{/* Task List with Scrollable Container */}
{(() => { - // Filter tasks for sidebar - const filteredSidebarTasks = tasks.filter((task) => { - if ( - tasksSidebarSearch && - !task.name - ?.toLowerCase() - .includes(tasksSidebarSearch.toLowerCase()) - ) { - return false; - } - if ( - tasksSidebarFilters.board && - tasksSidebarFilters.board !== 'all' && - task.board_name !== tasksSidebarFilters.board - ) { - return false; - } - if ( - tasksSidebarFilters.list && - tasksSidebarFilters.list !== 'all' && - task.list_name !== tasksSidebarFilters.list - ) { - return false; - } - return true; - }); + // Filter and sort tasks for sidebar with user prioritization + const filteredSidebarTasks = tasks + .filter((task) => { + // Search filter + if ( + tasksSidebarSearch && + !task.name + ?.toLowerCase() + .includes(tasksSidebarSearch.toLowerCase()) && + !task.description + ?.toLowerCase() + .includes(tasksSidebarSearch.toLowerCase()) + ) { + return false; + } + + // Board filter + if ( + tasksSidebarFilters.board && + tasksSidebarFilters.board !== 'all' && + task.board_name !== tasksSidebarFilters.board + ) { + return false; + } + + // List filter + if ( + tasksSidebarFilters.list && + tasksSidebarFilters.list !== 'all' && + task.list_name !== tasksSidebarFilters.list + ) { + return false; + } + + // Assignee filter + if (tasksSidebarFilters.assignee === 'mine') { + return task.is_assigned_to_current_user; + } else if (tasksSidebarFilters.assignee === 'unassigned') { + return !task.assignees || task.assignees.length === 0; + } else if ( + tasksSidebarFilters.assignee && + tasksSidebarFilters.assignee !== 'all' + ) { + return task.assignees?.some( + (assignee) => assignee.id === tasksSidebarFilters.assignee + ); + } + + return true; + }) + .sort((a, b) => { + // Prioritize user's assigned tasks + if (a.is_assigned_to_current_user && !b.is_assigned_to_current_user) { + return -1; + } + if (!a.is_assigned_to_current_user && b.is_assigned_to_current_user) { + return 1; + } + + // Then sort by priority (higher priority first) + const aPriority = a.priority || 0; + const bPriority = b.priority || 0; + if (aPriority !== bPriority) { + return bPriority - aPriority; + } + + // Finally sort by creation date (newest first) + return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime(); + }); if (tasks.length === 0) { return ( @@ -1616,10 +1827,9 @@ export default function TimeTrackerContent({ : ''}{' '} available {(tasksSidebarSearch || - (tasksSidebarFilters.board && - tasksSidebarFilters.board !== 'all') || - (tasksSidebarFilters.list && - tasksSidebarFilters.list !== 'all')) && + tasksSidebarFilters.board !== 'all' || + tasksSidebarFilters.list !== 'all' || + tasksSidebarFilters.assignee !== 'all') && ` (filtered from ${tasks.length} total)`} @@ -1634,7 +1844,11 @@ export default function TimeTrackerContent({
-
- +
+
-

- {task.name} -

+
+

+ {task.name} + {task.is_assigned_to_current_user && ( + + Assigned to you + + )} +

+
{task.description && (

{task.description}

)} + + {/* Assignees Display */} + {task.assignees && task.assignees.length > 0 && ( +
+
+ {task.assignees.slice(0, 3).map((assignee) => ( +
+ {assignee.avatar_url ? ( + {assignee.display_name + ) : ( +
+ {(assignee.display_name || assignee.email || '?')[0].toUpperCase()} +
+ )} +
+ ))} + {task.assignees.length > 3 && ( +
+ +{task.assignees.length - 3} +
+ )} +
+ + {task.assignees.length} assigned + +
+ )} + {task.board_name && task.list_name && (
@@ -1804,4 +2076,4 @@ export default function TimeTrackerContent({
); -} +} \ No newline at end of file diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts index a85fabc67..949e33c87 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts @@ -42,7 +42,7 @@ export async function GET( const boardId = url.searchParams.get('boardId'); const listId = url.searchParams.get('listId'); - // Build the query for fetching tasks + // Build the query for fetching tasks with assignee information let query = supabase .from('tasks') .select( @@ -65,6 +65,14 @@ export async function GET( name, ws_id ) + ), + assignees:task_assignees( + user:users( + id, + display_name, + avatar_url, + email + ) ) ` ) @@ -100,6 +108,19 @@ export async function GET( // Add board information for context board_name: task.task_lists?.workspace_boards?.name, list_name: task.task_lists?.name, + // Add assignee information + assignees: task.assignees + ?.map((a: any) => a.user) + .filter( + (user: any, index: number, self: any[]) => + user && + user.id && + self.findIndex((u: any) => u.id === user.id) === index + ) || [], + // Add helper field to identify if current user is assigned + is_assigned_to_current_user: task.assignees?.some( + (a: any) => a.user?.id === user.id + ) || false, })) || []; return NextResponse.json({ tasks }); From 94f4b5fe2937e6ae206a7715510013af65feb6c9 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Mon, 16 Jun 2025 15:22:18 +0800 Subject: [PATCH 02/35] feat: enhance time tracker UX with assignee filtering and persistence - Add assignee information to tasks API endpoint - Implement 'My Tasks' and 'Unassigned' quick filters with count badges - Add filter state persistence using localStorage - Enhance task sorting to prioritize user's assigned tasks - Add visual indicators for assigned tasks (blue styling, badges) - Display assignee avatars in task cards - Ensure feature parity between sidebar and timer controls - Fix TypeScript linter errors" --- .../components/activity-heatmap.tsx | 2 +- .../components/timer-controls.tsx | 105 +++--------- .../time-tracker/time-tracker-content.tsx | 154 +++++------------- 3 files changed, 59 insertions(+), 202 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx index 0ba3f18f5..5790ba98e 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx @@ -352,7 +352,7 @@ export function ActivityHeatmap({ }; return ( -
+
{/* Header */}
diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 2020728a2..acd9f7042 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -5,6 +5,8 @@ import type { TimeTrackingSession, WorkspaceTask, } from '@tuturuuu/types/db'; +import type { ExtendedWorkspaceTask, TaskFilters } from '../types'; +import { getFilteredAndSortedTasks, useTaskCounts, generateAssigneeInitials } from '../utils'; import { Badge } from '@tuturuuu/ui/badge'; import { Button } from '@tuturuuu/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; @@ -44,17 +46,7 @@ import { Textarea } from '@tuturuuu/ui/textarea'; import { cn } from '@tuturuuu/utils/format'; import { useCallback, useEffect, useRef, useState } from 'react'; -interface ExtendedWorkspaceTask extends Partial { - board_name?: string; - list_name?: string; - assignees?: Array<{ - id: string; - display_name?: string; - avatar_url?: string; - email?: string; - }>; - is_assigned_to_current_user?: boolean; -} + interface SessionWithRelations extends TimeTrackingSession { category: TimeTrackingCategory | null; @@ -145,7 +137,7 @@ export function TimerControls({ // Task search and filter state const [taskSearchQuery, setTaskSearchQuery] = useState(''); - const [taskFilters, setTaskFilters] = useState({ + const [taskFilters, setTaskFilters] = useState({ priority: 'all', status: 'all', board: 'all', @@ -173,6 +165,9 @@ export function TimerControls({ const [newTaskDescription, setNewTaskDescription] = useState(''); const [isCreatingTask, setIsCreatingTask] = useState(false); + // Use memoized task counts + const { myTasksCount, unassignedCount } = useTaskCounts(tasks); + // Fetch boards with lists const fetchBoards = useCallback(async () => { try { @@ -602,59 +597,9 @@ export function TimerControls({ const selectedBoard = boards.find((board) => board.id === selectedBoardId); const availableLists = selectedBoard?.task_lists || []; - // Filter and sort tasks with user prioritization + // Use shared task filtering and sorting utility const getFilteredTasks = () => { - return tasks - .filter((task) => { - const matchesSearch = task.name - ?.toLowerCase() - .includes(taskSearchQuery.toLowerCase()) || - task.description - ?.toLowerCase() - .includes(taskSearchQuery.toLowerCase()); - const matchesPriority = - taskFilters.priority === 'all' || - String(task.priority) === taskFilters.priority; - const matchesStatus = - taskFilters.status === 'all' || - (task.completed ? 'completed' : 'active') === taskFilters.status; - const matchesBoard = - taskFilters.board === 'all' || task.board_name === taskFilters.board; - const matchesList = - taskFilters.list === 'all' || task.list_name === taskFilters.list; - const matchesAssignee = - taskFilters.assignee === 'all' || - (taskFilters.assignee === 'mine' && task.is_assigned_to_current_user) || - (taskFilters.assignee === 'unassigned' && (!task.assignees || task.assignees.length === 0)); - - return ( - matchesSearch && - matchesPriority && - matchesStatus && - matchesBoard && - matchesList && - matchesAssignee - ); - }) - .sort((a, b) => { - // Prioritize user's assigned tasks - if (a.is_assigned_to_current_user && !b.is_assigned_to_current_user) { - return -1; - } - if (!a.is_assigned_to_current_user && b.is_assigned_to_current_user) { - return 1; - } - - // Then sort by priority (higher priority first) - const aPriority = a.priority || 0; - const bPriority = b.priority || 0; - if (aPriority !== bPriority) { - return bPriority - aPriority; - } - - // Finally sort by creation date (newest first) - return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime(); - }); + return getFilteredAndSortedTasks(tasks, taskSearchQuery, taskFilters); }; // Get unique boards and lists for filter options @@ -1402,16 +1347,11 @@ export function TimerControls({ > My Tasks - {(() => { - const myTasksCount = tasks.filter( - (task) => task.is_assigned_to_current_user - ).length; - return myTasksCount > 0 ? ( - - {myTasksCount} - - ) : null; - })()} + {myTasksCount > 0 && ( + + {myTasksCount} + + )}
@@ -1672,7 +1607,7 @@ export function TimerControls({ /> ) : (
- {(assignee.display_name?.[0] || assignee.email?.[0] || '?').toUpperCase()} + {generateAssigneeInitials(assignee)}
)}
diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index e4899a5a5..2f267c26b 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -13,6 +13,8 @@ import type { TimeTrackingSession, WorkspaceTask, } from '@tuturuuu/types/db'; +import type { ExtendedWorkspaceTask, TaskSidebarFilters } from './types'; +import { getFilteredAndSortedSidebarTasks, useTaskCounts, generateAssigneeInitials } from './utils'; import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; import { Button } from '@tuturuuu/ui/button'; import { @@ -90,17 +92,7 @@ export interface TimeTrackingGoal { category: TimeTrackingCategory | null; } -interface ExtendedWorkspaceTask extends Partial { - board_name?: string; - list_name?: string; - assignees?: Array<{ - id: string; - display_name?: string; - avatar_url?: string; - email?: string; - }>; - is_assigned_to_current_user?: boolean; -} + export interface TimeTrackerData { categories: TimeTrackingCategory[]; @@ -503,7 +495,7 @@ export default function TimeTrackerContent({ // Tasks sidebar search and filter state with persistence const [tasksSidebarSearch, setTasksSidebarSearch] = useState(''); - const [tasksSidebarFilters, setTasksSidebarFilters] = useState(() => { + const [tasksSidebarFilters, setTasksSidebarFilters] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem(`time-tracker-filters-${wsId}`); if (saved) { @@ -524,6 +516,9 @@ export default function TimeTrackerContent({ } }, [tasksSidebarFilters, wsId]); + // Use memoized task counts + const { myTasksCount, unassignedCount } = useTaskCounts(tasks); + if (isLoadingUser || !currentUserId) { return (
@@ -1302,7 +1297,7 @@ export default function TimeTrackerContent({ {sidebarView === 'analytics' && ( <> {/* Stats Overview - Enhanced for sidebar */} -
+
@@ -1321,9 +1316,9 @@ export default function TimeTrackerContent({ {/* Custom sidebar-optimized stats layout */}
{/* Today */} -
+
-
+
@@ -1351,9 +1346,9 @@ export default function TimeTrackerContent({
{/* This Week */} -
+
-
+
@@ -1386,9 +1381,9 @@ export default function TimeTrackerContent({
{/* This Month */} -
+
-
+
@@ -1412,9 +1407,9 @@ export default function TimeTrackerContent({
{/* Streak */} -
+
-
+
@@ -1442,7 +1437,7 @@ export default function TimeTrackerContent({ {/* Activity Heatmap - Enhanced with better header */} {timerStats.dailyActivity && ( -
+
@@ -1483,7 +1478,7 @@ export default function TimeTrackerContent({ {sidebarView === 'tasks' && (
{/* Tasks Header */} -
+
{/* Header Section */}
@@ -1521,16 +1516,11 @@ export default function TimeTrackerContent({ > My Tasks - {(() => { - const myTasksCount = tasks.filter( - (task) => task.is_assigned_to_current_user - ).length; - return myTasksCount > 0 ? ( - - {myTasksCount} - - ) : null; - })()} + {myTasksCount > 0 && ( + + {myTasksCount} + + )}
@@ -1724,74 +1709,11 @@ export default function TimeTrackerContent({
{(() => { // Filter and sort tasks for sidebar with user prioritization - const filteredSidebarTasks = tasks - .filter((task) => { - // Search filter - if ( - tasksSidebarSearch && - !task.name - ?.toLowerCase() - .includes(tasksSidebarSearch.toLowerCase()) && - !task.description - ?.toLowerCase() - .includes(tasksSidebarSearch.toLowerCase()) - ) { - return false; - } - - // Board filter - if ( - tasksSidebarFilters.board && - tasksSidebarFilters.board !== 'all' && - task.board_name !== tasksSidebarFilters.board - ) { - return false; - } - - // List filter - if ( - tasksSidebarFilters.list && - tasksSidebarFilters.list !== 'all' && - task.list_name !== tasksSidebarFilters.list - ) { - return false; - } - - // Assignee filter - if (tasksSidebarFilters.assignee === 'mine') { - return task.is_assigned_to_current_user; - } else if (tasksSidebarFilters.assignee === 'unassigned') { - return !task.assignees || task.assignees.length === 0; - } else if ( - tasksSidebarFilters.assignee && - tasksSidebarFilters.assignee !== 'all' - ) { - return task.assignees?.some( - (assignee) => assignee.id === tasksSidebarFilters.assignee - ); - } - - return true; - }) - .sort((a, b) => { - // Prioritize user's assigned tasks - if (a.is_assigned_to_current_user && !b.is_assigned_to_current_user) { - return -1; - } - if (!a.is_assigned_to_current_user && b.is_assigned_to_current_user) { - return 1; - } - - // Then sort by priority (higher priority first) - const aPriority = a.priority || 0; - const bPriority = b.priority || 0; - if (aPriority !== bPriority) { - return bPriority - aPriority; - } - - // Finally sort by creation date (newest first) - return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime(); - }); + const filteredSidebarTasks = getFilteredAndSortedSidebarTasks( + tasks, + tasksSidebarSearch, + tasksSidebarFilters + ); if (tasks.length === 0) { return ( @@ -1889,13 +1811,13 @@ export default function TimeTrackerContent({ ? 'text-blue-900 dark:text-blue-100' : 'text-gray-900 dark:text-gray-100' )}> - {task.name} + {task.name} {task.is_assigned_to_current_user && ( Assigned to you )} - +
{task.description && (

@@ -1921,7 +1843,7 @@ export default function TimeTrackerContent({ /> ) : (

- {(assignee.display_name || assignee.email || '?')[0].toUpperCase()} + {generateAssigneeInitials(assignee)}
)}
@@ -2011,7 +1933,7 @@ export default function TimeTrackerContent({ {/* Reports View */} {sidebarView === 'reports' && (
-
+
@@ -2043,7 +1965,7 @@ export default function TimeTrackerContent({ {/* Settings View */} {sidebarView === 'settings' && (
-
+
From cf3cf842dccd5beda6c1763ac89acb07f41422f6 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Mon, 16 Jun 2025 15:27:43 +0800 Subject: [PATCH 03/35] fix(time-tracker): add missing files --- .../(dashboard)/[wsId]/time-tracker/types.ts | 27 +++ .../(dashboard)/[wsId]/time-tracker/utils.ts | 178 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts new file mode 100644 index 000000000..2481c10ff --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts @@ -0,0 +1,27 @@ +import type { WorkspaceTask } from '@tuturuuu/types/db'; + +export interface ExtendedWorkspaceTask extends Partial { + board_name?: string; + list_name?: string; + assignees?: Array<{ + id: string; + display_name?: string; + avatar_url?: string; + email?: string; + }>; + is_assigned_to_current_user?: boolean; +} + +export interface TaskFilters { + priority: string; + status: string; + board: string; + list: string; + assignee: string; +} + +export interface TaskSidebarFilters { + board: string; + list: string; + assignee: string; +} \ No newline at end of file diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts new file mode 100644 index 000000000..4120c194a --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts @@ -0,0 +1,178 @@ +import { useMemo } from 'react'; +import type { ExtendedWorkspaceTask, TaskFilters, TaskSidebarFilters } from './types'; + +/** + * Shared task sorting logic - prioritizes user's assigned tasks, then by priority, then by creation date + */ +export function sortTasks(a: ExtendedWorkspaceTask, b: ExtendedWorkspaceTask): number { + // Prioritize user's assigned tasks + if (a.is_assigned_to_current_user && !b.is_assigned_to_current_user) { + return -1; + } + if (!a.is_assigned_to_current_user && b.is_assigned_to_current_user) { + return 1; + } + + // Then sort by priority (higher priority first) + const aPriority = a.priority || 0; + const bPriority = b.priority || 0; + if (aPriority !== bPriority) { + return bPriority - aPriority; + } + + // Finally sort by creation date (newest first) + return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime(); +} + +/** + * Filter tasks based on search query and filters for timer controls + */ +export function filterTasksForTimer( + tasks: ExtendedWorkspaceTask[], + searchQuery: string, + filters: TaskFilters +): ExtendedWorkspaceTask[] { + return tasks.filter((task) => { + const matchesSearch = task.name + ?.toLowerCase() + .includes(searchQuery.toLowerCase()) || + task.description + ?.toLowerCase() + .includes(searchQuery.toLowerCase()); + + const matchesPriority = + filters.priority === 'all' || + String(task.priority) === filters.priority; + + const matchesStatus = + filters.status === 'all' || + (task.completed ? 'completed' : 'active') === filters.status; + + const matchesBoard = + filters.board === 'all' || task.board_name === filters.board; + + const matchesList = + filters.list === 'all' || task.list_name === filters.list; + + const matchesAssignee = + filters.assignee === 'all' || + (filters.assignee === 'mine' && task.is_assigned_to_current_user) || + (filters.assignee === 'unassigned' && (!task.assignees || task.assignees.length === 0)); + + return ( + matchesSearch && + matchesPriority && + matchesStatus && + matchesBoard && + matchesList && + matchesAssignee + ); + }); +} + +/** + * Filter tasks for sidebar with different filter structure + */ +export function filterTasksForSidebar( + tasks: ExtendedWorkspaceTask[], + searchQuery: string, + filters: TaskSidebarFilters +): ExtendedWorkspaceTask[] { + return tasks.filter((task) => { + // Search filter + if ( + searchQuery && + !task.name + ?.toLowerCase() + .includes(searchQuery.toLowerCase()) && + !task.description + ?.toLowerCase() + .includes(searchQuery.toLowerCase()) + ) { + return false; + } + + // Board filter + if ( + filters.board && + filters.board !== 'all' && + task.board_name !== filters.board + ) { + return false; + } + + // List filter + if ( + filters.list && + filters.list !== 'all' && + task.list_name !== filters.list + ) { + return false; + } + + // Assignee filter + if (filters.assignee === 'mine') { + return task.is_assigned_to_current_user; + } else if (filters.assignee === 'unassigned') { + return !task.assignees || task.assignees.length === 0; + } else if ( + filters.assignee && + filters.assignee !== 'all' + ) { + return task.assignees?.some( + (assignee) => assignee.id === filters.assignee + ); + } + + return true; + }); +} + +/** + * Get filtered and sorted tasks + */ +export function getFilteredAndSortedTasks( + tasks: ExtendedWorkspaceTask[], + searchQuery: string, + filters: TaskFilters +): ExtendedWorkspaceTask[] { + return filterTasksForTimer(tasks, searchQuery, filters).sort(sortTasks); +} + +/** + * Get filtered and sorted tasks for sidebar + */ +export function getFilteredAndSortedSidebarTasks( + tasks: ExtendedWorkspaceTask[], + searchQuery: string, + filters: TaskSidebarFilters +): ExtendedWorkspaceTask[] { + return filterTasksForSidebar(tasks, searchQuery, filters).sort(sortTasks); +} + +/** + * Hook to calculate task counts with memoization + */ +export function useTaskCounts(tasks: ExtendedWorkspaceTask[]) { + return useMemo(() => { + const myTasksCount = tasks.filter( + (task) => task.is_assigned_to_current_user + ).length; + + const unassignedCount = tasks.filter( + (task) => !task.assignees || task.assignees.length === 0 + ).length; + + return { + myTasksCount, + unassignedCount, + }; + }, [tasks]); +} + +/** + * Generate initials from assignee name or email consistently + */ +export function generateAssigneeInitials(assignee: { display_name?: string; email?: string }): string { + return (assignee.display_name?.[0] || assignee.email?.[0] || '?').toUpperCase(); +} \ No newline at end of file From 9a795a096feebdb766c904e3266c8ba41a1a3b7f Mon Sep 17 00:00:00 2001 From: Adinorio Date: Mon, 16 Jun 2025 15:38:08 +0800 Subject: [PATCH 04/35] feat(time-tracker): added quick option for recent task --- .../time-tracker/time-tracker-content.tsx | 93 ++++++++++++++++--- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index 2f267c26b..88ab3a1a0 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -708,26 +708,93 @@ export default function TimeTrackerContent({
{/* Continue Last Session */} From 615c6143b354b3037404ce72d5ce4b1347218f61 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Mon, 16 Jun 2025 16:25:02 +0800 Subject: [PATCH 05/35] fix(time-tracker): resolved issues and comments --- .../components/timer-controls.tsx | 168 ++++++---- .../time-tracker/time-tracker-content.tsx | 303 +++++++++++------- 2 files changed, 290 insertions(+), 181 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index acd9f7042..3e865bb56 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -1,12 +1,16 @@ 'use client'; +import type { ExtendedWorkspaceTask, TaskFilters } from '../types'; +import { + generateAssigneeInitials, + getFilteredAndSortedTasks, + useTaskCounts, +} from '../utils'; import type { TimeTrackingCategory, TimeTrackingSession, WorkspaceTask, } from '@tuturuuu/types/db'; -import type { ExtendedWorkspaceTask, TaskFilters } from '../types'; -import { getFilteredAndSortedTasks, useTaskCounts, generateAssigneeInitials } from '../utils'; import { Badge } from '@tuturuuu/ui/badge'; import { Button } from '@tuturuuu/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; @@ -46,8 +50,6 @@ import { Textarea } from '@tuturuuu/ui/textarea'; import { cn } from '@tuturuuu/utils/format'; import { useCallback, useEffect, useRef, useState } from 'react'; - - interface SessionWithRelations extends TimeTrackingSession { category: TimeTrackingCategory | null; task: WorkspaceTask | null; @@ -1325,7 +1327,7 @@ export function TimerControls({
Quick Filters
- + {/* Assignee Filters */}
- + {!isViewingOtherUser && ( + + )}
)} + + {/* Focus Score Badge */} +
= 80 ? "text-green-600 bg-green-50 border-green-200" : + avgFocusScore >= 60 ? "text-blue-600 bg-blue-50 border-blue-200" : + avgFocusScore >= 40 ? "text-yellow-600 bg-yellow-50 border-yellow-200" : + "text-red-600 bg-red-50 border-red-200" + )}> + + Focus {avgFocusScore} +
+ + {/* Productivity Type Badge */} +
+ {getProductivityIcon(productivityType)} + {productivityType.replace('-', ' ')} +
+
@@ -712,7 +772,12 @@ export function SessionHistory({ }: SessionHistoryProps) { const [searchQuery, setSearchQuery] = useState(''); const [filterCategoryId, setFilterCategoryId] = useState('all'); - const [filterTaskId, setFilterTaskId] = useState('all'); + const [filterDuration, setFilterDuration] = useState('all'); + const [filterProductivity, setFilterProductivity] = useState('all'); + const [filterTimeOfDay, setFilterTimeOfDay] = useState('all'); + const [filterProjectContext, setFilterProjectContext] = useState('all'); + const [filterSessionQuality, setFilterSessionQuality] = useState('all'); + const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [sessionToDelete, setSessionToDelete] = useState(null); const [sessionToEdit, setSessionToEdit] = @@ -728,6 +793,74 @@ export function SessionHistory({ const userTimezone = dayjs.tz.guess(); const today = dayjs().tz(userTimezone); + // Advanced analytics functions + const calculateFocusScore = (session: SessionWithRelations): number => { + if (!session.duration_seconds) return 0; + + // Base score from duration (longer sessions = higher focus) + const durationScore = Math.min(session.duration_seconds / 7200, 1) * 40; // Max 40 points for 2+ hours + + // Bonus for consistency (sessions without interruptions) + const consistencyBonus = session.description?.includes('resumed') ? 0 : 20; + + // Time of day bonus (peak hours get bonus) + const sessionHour = dayjs.utc(session.start_time).tz(userTimezone).hour(); + const peakHoursBonus = (sessionHour >= 9 && sessionHour <= 11) || (sessionHour >= 14 && sessionHour <= 16) ? 20 : 0; + + // Category bonus (work categories get slight bonus) + const categoryBonus = session.category?.name?.toLowerCase().includes('work') ? 10 : 0; + + // Task completion bonus + const taskBonus = session.task_id ? 10 : 0; + + return Math.min(durationScore + consistencyBonus + peakHoursBonus + categoryBonus + taskBonus, 100); + }; + + const getSessionProductivityType = (session: SessionWithRelations): string => { + const duration = session.duration_seconds || 0; + const focusScore = calculateFocusScore(session); + + if (focusScore >= 80 && duration >= 3600) return 'deep-work'; + if (focusScore >= 60 && duration >= 1800) return 'focused'; + if (duration < 900 && focusScore < 40) return 'interrupted'; + if (duration >= 1800 && focusScore < 50) return 'scattered'; + return 'standard'; + }; + + const getTimeOfDayCategory = (session: SessionWithRelations): string => { + const hour = dayjs.utc(session.start_time).tz(userTimezone).hour(); + if (hour >= 6 && hour < 12) return 'morning'; + if (hour >= 12 && hour < 18) return 'afternoon'; + if (hour >= 18 && hour < 24) return 'evening'; + return 'night'; + }; + + const getProjectContext = (session: SessionWithRelations): string => { + if (session.task_id) { + const task = tasks.find(t => t.id === session.task_id); + return task?.board_name || 'project-work'; + } + if (session.category?.name?.toLowerCase().includes('meeting')) return 'meetings'; + if (session.category?.name?.toLowerCase().includes('learn')) return 'learning'; + if (session.category?.name?.toLowerCase().includes('admin')) return 'administrative'; + return 'general'; + }; + + const getDurationCategory = (session: SessionWithRelations): string => { + const duration = session.duration_seconds || 0; + if (duration < 1800) return 'short'; // < 30 min + if (duration < 7200) return 'medium'; // 30 min - 2 hours + return 'long'; // 2+ hours + }; + + const getSessionQuality = (session: SessionWithRelations): string => { + const focusScore = calculateFocusScore(session); + if (focusScore >= 80) return 'excellent'; + if (focusScore >= 60) return 'good'; + if (focusScore >= 40) return 'average'; + return 'needs-improvement'; + }; + const goToPrevious = () => { setCurrentDate(currentDate.subtract(1, viewMode)); }; @@ -758,6 +891,7 @@ export function SessionHistory({ const filteredSessions = useMemo( () => sessions.filter((session) => { + // Search filter if ( searchQuery && !session.title.toLowerCase().includes(searchQuery.toLowerCase()) && @@ -767,16 +901,37 @@ export function SessionHistory({ ) { return false; } + + // Category filter if ( filterCategoryId !== 'all' && session.category_id !== filterCategoryId ) return false; - if (filterTaskId !== 'all' && session.task_id !== filterTaskId) + + // Duration filter + if (filterDuration !== 'all' && getDurationCategory(session) !== filterDuration) + return false; + + // Productivity filter + if (filterProductivity !== 'all' && getSessionProductivityType(session) !== filterProductivity) + return false; + + // Time of day filter + if (filterTimeOfDay !== 'all' && getTimeOfDayCategory(session) !== filterTimeOfDay) return false; + + // Project context filter + if (filterProjectContext !== 'all' && getProjectContext(session) !== filterProjectContext) + return false; + + // Session quality filter + if (filterSessionQuality !== 'all' && getSessionQuality(session) !== filterSessionQuality) + return false; + return true; }), - [sessions, searchQuery, filterCategoryId, filterTaskId] + [sessions, searchQuery, filterCategoryId, filterDuration, filterProductivity, filterTimeOfDay, filterProjectContext, filterSessionQuality, tasks] ); const { startOfPeriod, endOfPeriod } = useMemo(() => { @@ -807,6 +962,37 @@ export function SessionHistory({ [id: string]: { name: string; duration: number; color: string }; } = {}; + // Enhanced analytics + const focusScores = sessionsForPeriod.map(s => calculateFocusScore(s)); + const avgFocusScore = focusScores.length > 0 ? focusScores.reduce((sum, score) => sum + score, 0) / focusScores.length : 0; + + const productivityBreakdown = { + 'deep-work': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'deep-work').length, + 'focused': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'focused').length, + 'standard': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'standard').length, + 'scattered': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'scattered').length, + 'interrupted': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'interrupted').length, + }; + + const timeOfDayBreakdown = { + morning: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'morning').length, + afternoon: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'afternoon').length, + evening: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'evening').length, + night: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'night').length, + }; + + const bestTimeOfDay = Object.entries(timeOfDayBreakdown).reduce((a, b) => + timeOfDayBreakdown[a[0]] > timeOfDayBreakdown[b[0]] ? a : b + )[0]; + + const longestSession = sessionsForPeriod.reduce((longest, session) => + (session.duration_seconds || 0) > (longest.duration_seconds || 0) ? session : longest + , sessionsForPeriod[0]); + + const shortSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) < 1800).length; + const mediumSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) >= 1800 && (s.duration_seconds || 0) < 7200).length; + const longSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) >= 7200).length; + sessionsForPeriod.forEach((s) => { const id = s.category?.id || 'uncategorized'; const name = s.category?.name || 'No Category'; @@ -821,7 +1007,20 @@ export function SessionHistory({ const breakdown = Object.values(categoryDurations) .filter((c) => c.duration > 0) .sort((a, b) => b.duration - a.duration); - return { totalDuration, breakdown }; + + return { + totalDuration, + breakdown, + avgFocusScore, + productivityBreakdown, + timeOfDayBreakdown, + bestTimeOfDay, + longestSession, + shortSessions, + mediumSessions, + longSessions, + sessionCount: sessionsForPeriod.length + }; }, [sessionsForPeriod]); const groupedStackedSessions = useMemo(() => { @@ -1042,79 +1241,221 @@ export function SessionHistory({ - +
-
- - + {showAdvancedFilters ? 'Simple' : 'Advanced'} +
-
- - + + {/* Basic Filters */} +
+
+ + +
+ +
+ + +
-
+ + {/* Advanced Filters */} + {showAdvancedFilters && ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} + +
+ + {/* Quick Analytics Preview */} + {filteredSessions.length > 0 && ( +
+
📊 Filter Analytics
+
+
+
{filteredSessions.length}
+
Sessions
+
+
+
+ {Math.round(filteredSessions.reduce((sum, s) => sum + calculateFocusScore(s), 0) / filteredSessions.length)} +
+
Avg Focus
+
+
+
+ )}
{sessionsForPeriod.length > 0 && ( )}
@@ -1283,90 +1624,211 @@ export function SessionHistory({

- + Productivity Insights

+ {/* Focus Score */}
- Daily Average - - - {formatDuration( - Math.floor( - periodStats.totalDuration / - Math.max( - 1, - new Set( - sessionsForPeriod.map((s) => - dayjs - .utc(s.start_time) - .tz(userTimezone) - .format('YYYY-MM-DD') - ) - ).size - ) - ) - )} + Average Focus Score +
+
= 80 ? "bg-green-500" : + periodStats.avgFocusScore >= 60 ? "bg-yellow-500" : + periodStats.avgFocusScore >= 40 ? "bg-orange-500" : "bg-red-500" + )}> +
+
+ + {Math.round(periodStats.avgFocusScore)} + +
+ + {/* Best Time of Day */}
- Active Days + Most Productive Time - { - new Set( - sessionsForPeriod.map((s) => - dayjs - .utc(s.start_time) - .tz(userTimezone) - .format('YYYY-MM-DD') - ) - ).size - }{' '} - days + {periodStats.bestTimeOfDay === 'morning' && '🌅 Morning'} + {periodStats.bestTimeOfDay === 'afternoon' && '☀️ Afternoon'} + {periodStats.bestTimeOfDay === 'evening' && '🌇 Evening'} + {periodStats.bestTimeOfDay === 'night' && '🌙 Night'}
-
- - Avg Session Length - - - {formatDuration( - Math.floor( - periodStats.totalDuration / - Math.max(1, sessionsForPeriod.length) - ) - )} - + + {/* Session Types Breakdown */} +
+
Session Types
+
+
+
{periodStats.longSessions}
+
Deep (2h+)
+
+
+
{periodStats.mediumSessions}
+
Focus (30m-2h)
+
+
+
{periodStats.shortSessions}
+
Quick (<30m)
+
+
-
- - Most Productive Day - - - {(() => { - const dailyTotals = sessionsForPeriod.reduce( - (acc, session) => { - const day = dayjs - .utc(session.start_time) - .tz(userTimezone) - .format('dddd'); - acc[day] = - (acc[day] || 0) + - (session.duration_seconds || 0); - return acc; - }, - {} as Record - ); - const topDay = Object.entries(dailyTotals).sort( - ([, a], [, b]) => b - a - )[0]; - return topDay ? topDay[0] : 'N/A'; - })()} - + + {/* Longest Session Highlight */} + {periodStats.longestSession && ( +
+
🏆 Longest Session
+
{periodStats.longestSession.title}
+
+ {formatDuration(periodStats.longestSession.duration_seconds || 0)} • + Focus: {Math.round(calculateFocusScore(periodStats.longestSession))} +
+
+ )} + + {/* Productivity Pattern */} +
+
Work Pattern
+
+ {Object.entries(periodStats.productivityBreakdown).map(([type, count]) => { + const total = periodStats.sessionCount; + const percentage = total > 0 ? (count / total) * 100 : 0; + return percentage > 0 ? ( +
+ ) : null; + })} +
+
+ 🧠 Deep: {periodStats.productivityBreakdown['deep-work']} + 🎯 Focus: {periodStats.productivityBreakdown['focused']} + ⚡ Quick: {periodStats.productivityBreakdown['interrupted']} +
+
+
+
+ + {/* AI Insights Section */} +
+

+
+ ✨
+ AI Productivity Insights +

+
+ {(() => { + const insights = []; + + // Focus Score Analysis + if (periodStats.avgFocusScore >= 80) { + insights.push("🎯 Excellent focus this month! You're maintaining deep work consistently."); + } else if (periodStats.avgFocusScore >= 60) { + insights.push("👍 Good focus patterns. Consider blocking longer time chunks for deeper work."); + } else if (periodStats.avgFocusScore < 40) { + insights.push("💡 Focus opportunity: Try the 25-minute Pomodoro technique for better concentration."); + } + + // Session Length Analysis + const deepWorkRatio = periodStats.longSessions / Math.max(1, periodStats.sessionCount); + if (deepWorkRatio > 0.3) { + insights.push("🏔️ Great job on deep work sessions! You're building strong focus habits."); + } else if (periodStats.shortSessions > periodStats.longSessions + periodStats.mediumSessions) { + insights.push("⚡ Many short sessions detected. Consider batching similar tasks for efficiency."); + } + + // Time of Day Analysis + if (periodStats.bestTimeOfDay === 'morning') { + insights.push("🌅 You're a morning person! Schedule your most important work before 11 AM."); + } else if (periodStats.bestTimeOfDay === 'night') { + insights.push("🌙 Night owl detected! Just ensure you're getting enough rest for sustained productivity."); + } + + // Productivity Type Analysis + const interruptedRatio = periodStats.productivityBreakdown['interrupted'] / Math.max(1, periodStats.sessionCount); + if (interruptedRatio > 0.3) { + insights.push("🔕 High interruption rate detected. Try enabling 'Do Not Disturb' mode during work blocks."); + } + + const deepWorkCount = periodStats.productivityBreakdown['deep-work']; + const focusedCount = periodStats.productivityBreakdown['focused']; + if (deepWorkCount + focusedCount > periodStats.sessionCount * 0.6) { + insights.push("🧠 Outstanding focused work ratio! You're in the productivity zone."); + } + + // Consistency Analysis + const activeDays = new Set( + sessionsForPeriod.map((s) => + dayjs.utc(s.start_time).tz(userTimezone).format('YYYY-MM-DD') + ) + ).size; + const daysInPeriod = currentDate.daysInMonth(); + const consistencyRatio = activeDays / daysInPeriod; + + if (consistencyRatio > 0.8) { + insights.push("🔥 Amazing consistency! You're showing up almost every day."); + } else if (consistencyRatio < 0.3) { + insights.push("📅 Opportunity for more consistency. Even 15 minutes daily builds momentum."); + } + + // Duration vs Focus Correlation + const avgDurationPerSession = periodStats.totalDuration / Math.max(1, periodStats.sessionCount); + if (avgDurationPerSession > 7200 && periodStats.avgFocusScore > 70) { + insights.push("🏆 Perfect combo: Long sessions with high focus. You've mastered deep work!"); + } + + return insights.slice(0, 3); // Show max 3 insights + })().map((insight, index) => ( +
+
+ {insight} +
+ ))} + + {(() => { + // Predictive suggestion based on patterns + const totalHours = periodStats.totalDuration / 3600; + const avgHoursPerDay = totalHours / Math.max(1, new Set( + sessionsForPeriod.map((s) => + dayjs.utc(s.start_time).tz(userTimezone).format('YYYY-MM-DD') + ) + ).size); + + if (avgHoursPerDay > 6) { + return ( +
+
+ 🚀 + Power User Detected! +
+

+ You're averaging {avgHoursPerDay.toFixed(1)} hours/day. Consider setting up automated time tracking for even better insights! +

+
+ ); + } + + return null; + })()}
@@ -1577,6 +2039,8 @@ export function SessionHistory({ onDelete={setSessionToDelete} actionStates={actionStates} tasks={tasks} + calculateFocusScore={calculateFocusScore} + getSessionProductivityType={getSessionProductivityType} /> ))}
From c02544089f9e391d966283d6378c1ab758a2b1be Mon Sep 17 00:00:00 2001 From: Adinorio Date: Tue, 17 Jun 2025 20:16:16 +0800 Subject: [PATCH 09/35] feat(time-tracker): enhance analytics and fix task assignment issues - Add comprehensive session analytics with focus score calculation - Implement advanced filtering (duration, productivity, time-of-day, quality) - Add real-time productivity insights and AI coaching suggestions - Fix task assignment API errors by using direct Supabase operations - Improve Quick Actions with live focus scores and smart timer suggestions - Disable Continue Last Session card when timer is already running - Add Next Task preview with priority-based smart selection - Enhance session history with productivity type classification - Update command center with real-time metrics and streak tracking" --- .../components/session-history.tsx | 80 +- .../components/timer-controls.tsx | 1 + .../time-tracker/time-tracker-content.tsx | 1036 +++++++++++------ .../src/components/command/quick-actions.tsx | 38 +- .../components/command/quick-time-tracker.tsx | 108 +- 5 files changed, 817 insertions(+), 446 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 406cdcb27..90a9f16ca 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -1266,57 +1266,57 @@ export function SessionHistory({ {/* Basic Filters */}
-
+
- -
+ +
-
+
- -
+ + +
{/* Advanced Filters */} @@ -1647,8 +1647,8 @@ export function SessionHistory({
{Math.round(periodStats.avgFocusScore)} - -
+ +
{/* Best Time of Day */} @@ -1717,7 +1717,7 @@ export function SessionHistory({ /> ) : null; })} -
+
🧠 Deep: {periodStats.productivityBreakdown['deep-work']} 🎯 Focus: {periodStats.productivityBreakdown['focused']} @@ -1736,7 +1736,7 @@ export function SessionHistory({ AI Productivity Insights
- {(() => { + {(() => { const insights = []; // Focus Score Analysis @@ -1828,7 +1828,7 @@ export function SessionHistory({ } return null; - })()} + })()}
diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 3e865bb56..206184f84 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -1773,6 +1773,7 @@ export function TimerControls({ handleManualTitleChange(e.target.value)} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index 567ca68fb..ad6542c65 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -23,15 +23,21 @@ import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; import { Button } from '@tuturuuu/ui/button'; import { AlertCircle, + BarChart2, + Brain, Calendar, CheckCircle, + CheckSquare, ChevronLeft, ChevronRight, Clock, Copy, History, + LayoutDashboard, MapPin, Pause, + Play, + PlusCircle, RefreshCw, RotateCcw, Settings, @@ -54,7 +60,13 @@ import { import { toast } from '@tuturuuu/ui/sonner'; import { Tabs, TabsContent } from '@tuturuuu/ui/tabs'; import { cn } from '@tuturuuu/utils/format'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; + +dayjs.extend(utc); +dayjs.extend(timezone); interface TimeTrackerContentProps { wsId: string; @@ -178,6 +190,12 @@ export default function TimeTrackerContent({ initialData.tasks || [] ); + // Quick actions state + const [showContinueConfirm, setShowContinueConfirm] = useState(false); + const [showTaskSelector, setShowTaskSelector] = useState(false); + const [availableTasks, setAvailableTasks] = useState([]); + const [nextTaskPreview, setNextTaskPreview] = useState(null); + // Enhanced loading and error states const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -195,6 +213,111 @@ export default function TimeTrackerContent({ // Whether we're viewing another user's data const isViewingOtherUser = selectedUserId !== null; + // Get user timezone + const userTimezone = dayjs.tz.guess(); + + // Calculate focus score for sessions + const calculateFocusScore = useCallback((session: SessionWithRelations): number => { + if (!session.duration_seconds) return 0; + + // Base score from duration (longer sessions = higher focus) + const durationScore = Math.min(session.duration_seconds / 7200, 1) * 40; // Max 40 points for 2+ hours + + // Bonus for consistency (sessions without interruptions) + const consistencyBonus = session.description?.includes('resumed') ? 0 : 20; + + // Time of day bonus (peak hours get bonus) + const sessionHour = dayjs.utc(session.start_time).tz(userTimezone).hour(); + const peakHoursBonus = (sessionHour >= 9 && sessionHour <= 11) || (sessionHour >= 14 && sessionHour <= 16) ? 20 : 0; + + // Category bonus (work categories get slight bonus) + const categoryBonus = session.category?.name?.toLowerCase().includes('work') ? 10 : 0; + + // Task completion bonus + const taskBonus = session.task_id ? 10 : 0; + + return Math.min(durationScore + consistencyBonus + peakHoursBonus + categoryBonus + taskBonus, 100); + }, [userTimezone]); + + // Calculate productivity metrics + const productivityMetrics = useMemo(() => { + if (!recentSessions.length) { + return { + avgFocusScore: 0, + todaySessionCount: 0 + }; + } + + const today = dayjs().tz(userTimezone); + const todaySessions = recentSessions.filter(session => { + const sessionDate = dayjs.utc(session.start_time).tz(userTimezone); + return sessionDate.isSame(today, 'day'); + }); + + const focusScores = recentSessions.slice(0, 10).map(session => calculateFocusScore(session)); + const avgFocusScore = focusScores.length > 0 + ? Math.round(focusScores.reduce((sum, score) => sum + score, 0) / focusScores.length) + : 0; + + return { + avgFocusScore, + todaySessionCount: todaySessions.length + }; + }, [recentSessions, calculateFocusScore, userTimezone]); + + // Function to fetch next tasks with smart priority logic + const fetchNextTasks = useCallback(async () => { + try { + const response = await apiCall(`/api/v1/workspaces/${wsId}/tasks?limit=100`); + let prioritizedTasks = []; + + // 1. First priority: Urgent tasks (priority 1) assigned to current user + const myUrgentTasks = response.tasks.filter((task: ExtendedWorkspaceTask) => { + const isUrgent = task.priority === 1; // Priority 1 = Urgent + const isNotCompleted = !task.completed; + const isAssignedToMe = task.is_assigned_to_current_user; + return isUrgent && isNotCompleted && isAssignedToMe; + }); + + // 2. Second priority: Urgent unassigned tasks (user can assign themselves) + const urgentUnassigned = response.tasks.filter((task: ExtendedWorkspaceTask) => { + const isUrgent = task.priority === 1; // Priority 1 = Urgent + const isNotCompleted = !task.completed; + const isUnassigned = !task.assignees || task.assignees.length === 0; + return isUrgent && isNotCompleted && isUnassigned; + }); + + // 3. Third priority: Other tasks assigned to current user (High → Medium → Low) + const myOtherTasks = response.tasks.filter((task: ExtendedWorkspaceTask) => { + const isNotUrgent = !task.priority || task.priority > 1; // Priority 2,3,4 = High, Medium, Low + const isNotCompleted = !task.completed; + const isAssignedToMe = task.is_assigned_to_current_user; + return isNotUrgent && isNotCompleted && isAssignedToMe; + }); + + // Combine and sort by priority within each group (lower number = higher priority) + prioritizedTasks = [ + ...myUrgentTasks.sort((a, b) => (a.priority || 99) - (b.priority || 99)), + ...urgentUnassigned.sort((a, b) => (a.priority || 99) - (b.priority || 99)), + ...myOtherTasks.sort((a, b) => (a.priority || 99) - (b.priority || 99)) + ]; + + setAvailableTasks(prioritizedTasks); + setNextTaskPreview(prioritizedTasks[0] || null); + } catch (error) { + console.error('Error fetching next tasks:', error); + setAvailableTasks([]); + setNextTaskPreview(null); + } + }, [wsId]); + + // Fetch next task preview on mount + useEffect(() => { + if (!isViewingOtherUser) { + fetchNextTasks(); + } + }, [fetchNextTasks, isViewingOtherUser]); + // Memoized formatters const formatTime = useCallback((seconds: number): string => { const safeSeconds = Math.max(0, Math.floor(seconds)); @@ -288,7 +411,7 @@ export default function TimeTrackerContent({ name: 'running', call: () => !isViewingOtherUser ? apiCall(`/api/v1/workspaces/${wsId}/time-tracking/sessions?type=running`) - : Promise.resolve({ session: null }), + : Promise.resolve({ session: null }), fallback: { session: null } }, { @@ -687,449 +810,300 @@ export default function TimeTrackerContent({
- {/* Quick Actions Carousel */} + {/* Enhanced Quick Actions - Single Row */} {!isViewingOtherUser && ( -
- {/* Carousel Navigation */} +
-
- - -
- {[0, 1, 2].map((index) => ( -
- - -
- +

⚡ Quick Actions

- {carouselView === 0 && 'Smart Quick Actions'} - {carouselView === 1 && 'Context-Aware Dashboard'} - {carouselView === 2 && 'Productivity Command Center'} + {(() => { + const hour = new Date().getHours(); + const isPeakTime = (hour >= 9 && hour <= 11) || (hour >= 14 && hour <= 16); + return isPeakTime ? '🧠 Peak focus time' : '📈 Building momentum'; + })()}
- {/* Carousel Content */} -
-
- {/* View 0: Smart Quick Actions */} -
-
+ {/* Action Grid with proper spacing to prevent cutoff */} +
{/* Continue Last Session */} - - {/* Start Most Used Task */} - - - {/* Quick 25min Focus */} - - - {/* From Template */} - -
-
- - {/* View 1: Context-Aware Dashboard */} -
-
- {/* Today's Calendar */} - - - {/* Suggested Tasks */} - - - {/* Goal Progress */} - - {/* Quick Actions */} - + + ) : ( + <> +

No tasks available

+

+ Create or assign tasks +

+ + )}
-
- - {/* View 2: Productivity Command Center */} -
-
- {/* Active Tasks */} - - - {/* Focus Score */} - {/* Break Timer */} - {/* Session History */} + {/* Analytics Dashboard */} -
-
-
)} @@ -1381,18 +1355,18 @@ export default function TimeTrackerContent({ Analytics {!isViewingOtherUser && ( - + )}
+ + {/* Continue Last Session Confirmation Dialog */} + {showContinueConfirm && recentSessions[0] && ( +
+
+
+
+ +
+
+

+ Continue Last Session? +

+

+ Resume your previous work session +

+
+
+ +
+

+ {recentSessions[0].title} +

+ {recentSessions[0].description && ( +

+ {recentSessions[0].description} +

+ )} + {recentSessions[0].category && ( +
+
+ + {recentSessions[0].category.name} + +
+ )} +
+ +
+ + +
+
+
+ )} + + {/* Task Selector Dialog */} + {showTaskSelector && ( +
+
+
+
+ +
+
+

+ Choose Your Next Task +

+

+ Tasks prioritized: Your urgent tasks → Urgent unassigned → Your other tasks +

+
+
+ +
+ {availableTasks.length === 0 ? ( + // No tasks available - show creation options +
+
+ +

+ No Tasks Available +

+

+ You don't have any assigned tasks. Create a new task or check available boards. +

+ +
+ + +
+
+
+ ) : ( + availableTasks.map((task) => { + const getPriorityBadge = (priority: number | null | undefined) => { + switch (priority) { + case 1: + return { text: 'Urgent', color: 'bg-red-500' }; + case 2: + return { text: 'High', color: 'bg-orange-500' }; + case 3: + return { text: 'Medium', color: 'bg-yellow-500' }; + case 4: + return { text: 'Low', color: 'bg-green-500' }; + default: + return { text: 'No Priority', color: 'bg-gray-500' }; + } + }; + + const priorityBadge = getPriorityBadge(task.priority); + const isUnassigned = !task.assignees || task.assignees.length === 0; + + return ( + + ); + }) + )} +
+ +
+ + {availableTasks.length > 0 && ( +
+

+ {availableTasks.length} task{availableTasks.length !== 1 ? 's' : ''} prioritized +

+ +
+ )} +
+
+
+ )}
); } diff --git a/apps/web/src/components/command/quick-actions.tsx b/apps/web/src/components/command/quick-actions.tsx index 4516a12e6..75b60cd11 100644 --- a/apps/web/src/components/command/quick-actions.tsx +++ b/apps/web/src/components/command/quick-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { CommandGroup, CommandItem } from '@tuturuuu/ui/command'; -import { Calendar, Clock, PlusCircle, Timer } from 'lucide-react'; +import { Calendar, Clock, PlusCircle, Timer, Brain, TrendingUp } from 'lucide-react'; interface QuickActionsProps { onAddTask: () => void; @@ -16,6 +16,10 @@ export function QuickActions({ onQuickTimeTracker, onCalendar, }: QuickActionsProps) { + // Calculate current hour for productivity suggestions + const currentHour = new Date().getHours(); + const isPeakHour = (currentHour >= 9 && currentHour <= 11) || (currentHour >= 14 && currentHour <= 16); + return (
- - Quick timer - +
+ + Quick timer + + {isPeakHour && ( +
+ + Peak Time +
+ )} +
- Start tracking time instantly + {isPeakHour + ? "Perfect timing for deep focus work" + : "Start tracking time instantly"}
@@ -80,11 +94,17 @@ export function QuickActions({
- - Time Tracker - +
+ + Time Tracker + +
+ + Analytics +
+
- Advanced time tracking and analytics + Advanced time tracking with focus scores & insights
diff --git a/apps/web/src/components/command/quick-time-tracker.tsx b/apps/web/src/components/command/quick-time-tracker.tsx index ca47e05f1..d38055f9b 100644 --- a/apps/web/src/components/command/quick-time-tracker.tsx +++ b/apps/web/src/components/command/quick-time-tracker.tsx @@ -10,6 +10,7 @@ import { CheckCircle, ExternalLink, Play, Square, Timer } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; +import { cn } from '@tuturuuu/utils/format'; interface QuickTimeTrackerProps { wsId: string; @@ -349,24 +350,101 @@ export function QuickTimeTracker({ )}
- {/* Category and Task Info */} -
- {runningSession.category && ( -
-
- - {runningSession.category.name} - + {/* Enhanced Analytics Display */} +
+ {/* Category and Task Info */} +
+ {runningSession.category && ( +
+
+ + {runningSession.category.name} + +
+ )} + {runningSession.task && ( +
+ + + {runningSession.task.name} + +
+ )} +
+ + {/* Live Focus Score */} +
+
+ Live Focus Score +
+
= 7200 ? "bg-green-500" : + elapsedTime >= 3600 ? "bg-blue-500" : + elapsedTime >= 1800 ? "bg-yellow-500" : "bg-gray-500" + )}> +
+
+ + {(() => { + // Real-time focus score calculation + const durationScore = Math.min(elapsedTime / 7200, 1) * 40; + const consistencyBonus = 20; // Assume consistent for live session + const timeBonus = (() => { + const hour = new Date().getHours(); + return (hour >= 9 && hour <= 11) || (hour >= 14 && hour <= 16) ? 20 : 0; + })(); + const categoryBonus = runningSession.category?.name?.toLowerCase().includes('work') ? 10 : 0; + const taskBonus = runningSession.task_id ? 10 : 0; + return Math.min(Math.round(durationScore + consistencyBonus + timeBonus + categoryBonus + taskBonus), 100); + })()} + +
- )} - {runningSession.task && ( -
- - - {runningSession.task.name} + + {/* Productivity Tips */} +
+ {elapsedTime >= 7200 ? ( + 🧠 Deep work mode! Excellent focus. + ) : elapsedTime >= 3600 ? ( + 🎯 Great focus! Consider a break soon. + ) : elapsedTime >= 1800 ? ( + 📈 Building momentum! Keep going. + ) : elapsedTime >= 900 ? ( + ⏰ Good start! Focus is building. + ) : ( + 🚀 Just started! Focus will improve. + )} +
+
+ + {/* Session Type Indicator */} +
+ Session Type: +
= 3600 ? "text-green-600 bg-green-100" : + elapsedTime >= 1800 ? "text-blue-600 bg-blue-100" : + elapsedTime >= 900 ? "text-yellow-600 bg-yellow-100" : + "text-gray-600 bg-gray-100" + )}> + + {elapsedTime >= 3600 ? '🧠' : + elapsedTime >= 1800 ? '🎯' : + elapsedTime >= 900 ? '📋' : '⚡'} + + + {elapsedTime >= 3600 ? 'Deep Work' : + elapsedTime >= 1800 ? 'Focused' : + elapsedTime >= 900 ? 'Standard' : 'Quick Task'}
- )} +
From b3b7629024ba2c22f2f94a637469a853737dd2bc Mon Sep 17 00:00:00 2001 From: Adinorio Date: Tue, 17 Jun 2025 20:58:11 +0800 Subject: [PATCH 10/35] feat(time-tracker): better controls for tracking time - fixed issues --- .../components/session-history.tsx | 2 +- .../components/timer-controls.tsx | 350 +++++++++++++++++- .../time-tracker/time-tracker-content.tsx | 1 + 3 files changed, 337 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 90a9f16ca..567778d37 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -982,7 +982,7 @@ export function SessionHistory({ }; const bestTimeOfDay = Object.entries(timeOfDayBreakdown).reduce((a, b) => - timeOfDayBreakdown[a[0]] > timeOfDayBreakdown[b[0]] ? a : b + timeOfDayBreakdown[a[0] as keyof typeof timeOfDayBreakdown] > timeOfDayBreakdown[b[0] as keyof typeof timeOfDayBreakdown] ? a : b )[0]; const longestSession = sessionsForPeriod.reduce((longest, session) => diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 206184f84..0bb9492d1 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -103,6 +103,7 @@ interface TimerControlsProps { apiCall: (url: string, options?: RequestInit) => Promise; isDraggingTask?: boolean; onGoToTasksTab?: () => void; + currentUserId?: string; } export function TimerControls({ @@ -121,6 +122,7 @@ export function TimerControls({ apiCall, isDraggingTask = false, onGoToTasksTab, + currentUserId, }: TimerControlsProps) { const [isLoading, setIsLoading] = useState(false); const [newSessionTitle, setNewSessionTitle] = useState(''); @@ -133,6 +135,100 @@ export function TimerControls({ const [justCompleted, setJustCompleted] = useState(null); + // Enhanced pause/resume state + const [pausedSession, setPausedSession] = useState(null); + const [pausedElapsedTime, setPausedElapsedTime] = useState(0); + const [pauseStartTime, setPauseStartTime] = useState(null); + + // localStorage keys for persistence + const PAUSED_SESSION_KEY = `paused-session-${wsId}-${currentUserId || 'user'}`; + const PAUSED_ELAPSED_KEY = `paused-elapsed-${wsId}-${currentUserId || 'user'}`; + const PAUSE_TIME_KEY = `pause-time-${wsId}-${currentUserId || 'user'}`; + + // Helper functions for localStorage persistence + const savePausedSessionToStorage = useCallback((session: SessionWithRelations, elapsed: number, pauseTime: Date) => { + if (typeof window !== 'undefined') { + try { + localStorage.setItem(PAUSED_SESSION_KEY, JSON.stringify(session)); + localStorage.setItem(PAUSED_ELAPSED_KEY, elapsed.toString()); + localStorage.setItem(PAUSE_TIME_KEY, pauseTime.toISOString()); + } catch (error) { + console.warn('Failed to save paused session to localStorage:', error); + } + } + }, [PAUSED_SESSION_KEY, PAUSED_ELAPSED_KEY, PAUSE_TIME_KEY]); + + const loadPausedSessionFromStorage = useCallback(() => { + if (typeof window !== 'undefined') { + try { + const sessionData = localStorage.getItem(PAUSED_SESSION_KEY); + const elapsedData = localStorage.getItem(PAUSED_ELAPSED_KEY); + const pauseTimeData = localStorage.getItem(PAUSE_TIME_KEY); + + if (sessionData && elapsedData && pauseTimeData) { + const session = JSON.parse(sessionData); + const elapsed = parseInt(elapsedData); + const pauseTime = new Date(pauseTimeData); + + setPausedSession(session); + setPausedElapsedTime(elapsed); + setPauseStartTime(pauseTime); + + return { session, elapsed, pauseTime }; + } + } catch (error) { + console.warn('Failed to load paused session from localStorage:', error); + clearPausedSessionFromStorage(); + } + } + return null; + }, [PAUSED_SESSION_KEY, PAUSED_ELAPSED_KEY, PAUSE_TIME_KEY]); + + const clearPausedSessionFromStorage = useCallback(() => { + if (typeof window !== 'undefined') { + try { + localStorage.removeItem(PAUSED_SESSION_KEY); + localStorage.removeItem(PAUSED_ELAPSED_KEY); + localStorage.removeItem(PAUSE_TIME_KEY); + } catch (error) { + console.warn('Failed to clear paused session from localStorage:', error); + } + } + }, [PAUSED_SESSION_KEY, PAUSED_ELAPSED_KEY, PAUSE_TIME_KEY]); + + // Load paused session on component mount + useEffect(() => { + const pausedData = loadPausedSessionFromStorage(); + if (pausedData) { + console.log('Restored paused session from localStorage:', pausedData.session.title); + + // Show a toast to let user know their paused session was restored + toast.success('Paused session restored!', { + description: `${pausedData.session.title} - ${formatDuration(pausedData.elapsed)} tracked`, + duration: 5000, + }); + } + }, [loadPausedSessionFromStorage, formatDuration]); + + // Cleanup paused session if user changes or component unmounts + useEffect(() => { + return () => { + // Only clear if we have a different user or workspace + const keys = Object.keys(localStorage).filter(key => + key.startsWith('paused-session-') && + !key.includes(`-${wsId}-${currentUserId}`) + ); + keys.forEach(key => { + const relatedKeys = [ + key, + key.replace('paused-session-', 'paused-elapsed-'), + key.replace('paused-session-', 'pause-time-') + ]; + relatedKeys.forEach(k => localStorage.removeItem(k)); + }); + }; + }, [wsId, currentUserId]); + // Drag and drop state const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); @@ -447,15 +543,16 @@ export function TimerControls({ } }; - // Stop timer + // Stop timer - handle both active and paused sessions const stopTimer = async () => { - if (!currentSession) return; + const sessionToStop = currentSession || pausedSession; + if (!sessionToStop) return; setIsLoading(true); try { const response = await apiCall( - `/api/v1/workspaces/${wsId}/time-tracking/sessions/${currentSession.id}`, + `/api/v1/workspaces/${wsId}/time-tracking/sessions/${sessionToStop.id}`, { method: 'PATCH', body: JSON.stringify({ action: 'stop' }), @@ -464,9 +561,17 @@ export function TimerControls({ const completedSession = response.session; setJustCompleted(completedSession); + + // Clear all session states setCurrentSession(null); + setPausedSession(null); setIsRunning(false); setElapsedTime(0); + setPausedElapsedTime(0); + setPauseStartTime(null); + + // Clear from localStorage since session is completed + clearPausedSessionFromStorage(); // Show completion celebration setTimeout(() => setJustCompleted(null), 3000); @@ -486,7 +591,7 @@ export function TimerControls({ } }; - // Pause timer + // Pause timer - properly maintain session state const pauseTimer = async () => { if (!currentSession) return; @@ -501,12 +606,26 @@ export function TimerControls({ } ); + const pauseTime = new Date(); + + // Store paused session data instead of clearing it + setPausedSession(currentSession); + setPausedElapsedTime(elapsedTime); + setPauseStartTime(pauseTime); + + // Save to localStorage for persistence across sessions + savePausedSessionToStorage(currentSession, elapsedTime, pauseTime); + + // Clear active session but keep paused state setCurrentSession(null); setIsRunning(false); setElapsedTime(0); onSessionUpdate(); - toast.success('Timer paused'); + toast.success('Timer paused - Click Resume to continue', { + description: `Session: ${currentSession.title}`, + duration: 4000, + }); } catch (error) { console.error('Error pausing timer:', error); toast.error('Failed to pause timer'); @@ -515,6 +634,53 @@ export function TimerControls({ } }; + // Resume paused timer + const resumeTimer = async () => { + if (!pausedSession) return; + + setIsLoading(true); + + try { + const response = await apiCall( + `/api/v1/workspaces/${wsId}/time-tracking/sessions/${pausedSession.id}`, + { + method: 'PATCH', + body: JSON.stringify({ action: 'resume' }), + } + ); + + // Restore session from paused state + setCurrentSession(response.session || pausedSession); + setElapsedTime(pausedElapsedTime); + setIsRunning(true); + + // Clear paused state + setPausedSession(null); + setPausedElapsedTime(0); + setPauseStartTime(null); + + // Clear from localStorage since session is now active + clearPausedSessionFromStorage(); + + const pauseDuration = pauseStartTime + ? Math.floor((new Date().getTime() - pauseStartTime.getTime()) / 1000) + : 0; + + onSessionUpdate(); + toast.success('Timer resumed!', { + description: pauseDuration > 0 + ? `Paused for ${formatDuration(pauseDuration)}` + : 'Welcome back to your session', + duration: 3000, + }); + } catch (error) { + console.error('Error resuming timer:', error); + toast.error('Failed to resume timer'); + } finally { + setIsLoading(false); + } + }; + // Start from template const startFromTemplate = async (template: SessionTemplate) => { setNewSessionTitle(template.title); @@ -773,10 +939,14 @@ export function TimerControls({ } } - // Ctrl/Cmd + P to pause - if ((event.ctrlKey || event.metaKey) && event.key === 'p' && isRunning) { + // Ctrl/Cmd + P to pause/resume + if ((event.ctrlKey || event.metaKey) && event.key === 'p') { event.preventDefault(); - pauseTimer(); + if (isRunning) { + pauseTimer(); + } else if (pausedSession) { + resumeTimer(); + } } // Ctrl/Cmd + T to open task dropdown @@ -849,6 +1019,8 @@ export function TimerControls({ startTimer, stopTimer, pauseTimer, + resumeTimer, + pausedSession, isTaskDropdownOpen, isDraggingTask, selectedTaskId, @@ -877,7 +1049,7 @@ export function TimerControls({ to start/stop ⌘/Ctrl + P - to pause + to pause/resume ⌘/Ctrl + T for tasks ⌘/Ctrl + M @@ -892,6 +1064,7 @@ export function TimerControls({ {currentSession ? (
+ {/* Enhanced Active Session Display */}
@@ -902,6 +1075,11 @@ export function TimerControls({
Started at{' '} {new Date(currentSession.start_time).toLocaleTimeString()} + {elapsedTime > 1800 && ( + + {elapsedTime > 3600 ? 'Long session!' : 'Deep work'} + + )}
@@ -966,15 +1144,143 @@ export function TimerControls({ })()}
+ {/* Enhanced Session Controls */} +
+ {/* Productivity Insights */} + {elapsedTime > 600 && ( +
+
+ + + Session Insights + +
+
+
+ Duration: + + {elapsedTime < 1500 ? 'Warming up' : + elapsedTime < 3600 ? 'Focused session' : + 'Deep work zone!'} + +
+
+ Productivity: + + {elapsedTime < 900 ? 'Getting started' : + elapsedTime < 2700 ? 'In the flow' : + 'Exceptional focus'} + +
+
+
+ )} + + {/* Enhanced Control Buttons */} +
+ + +
+ + {/* Quick Actions during session */} +
+ ⌘/Ctrl + P + for break + ⌘/Ctrl + Enter + to complete +
+
+
+ ) : pausedSession ? ( + /* Paused Session Display */ +
+
+
+
+
+ + + Session Paused + +
+
+ {formatTime(pausedElapsedTime)} +
+
+
+ Paused at {pauseStartTime?.toLocaleTimeString()} + {pauseStartTime && ( + + • Break: {formatDuration(Math.floor((new Date().getTime() - pauseStartTime.getTime()) / 1000))} + + )} +
+
+ Session was running for {formatDuration(pausedElapsedTime)} before pause +
+
+
+
+ +
+

{pausedSession.title}

+ {pausedSession.description && ( +

+ {pausedSession.description} +

+ )} +
+ {pausedSession.category && ( + + {pausedSession.category.name} + + )} + {pausedSession.task && ( +
+
+ + + {pausedSession.task.name} + +
+
+ )} + + On break + +
+
+ + {/* Enhanced Resume/Stop buttons */}
+ + {/* Quick Break Suggestions */} +
+

+ 💡 Break suggestions: +

+
+ 🚶 Short walk + 💧 Hydrate + 👁️ Rest eyes (20-20-20) + 🧘 Quick meditation + 🍎 Healthy snack +
+
) : (
diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index ad6542c65..f446ded85 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -1263,6 +1263,7 @@ export default function TimeTrackerContent({ 'Switched to Tasks tab - create your first task!' ); }} + currentUserId={currentUserId} />
From 0e855a88c9ebf61cb2ad59a31dcd9b7c37795cc0 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Tue, 17 Jun 2025 21:07:05 +0800 Subject: [PATCH 11/35] chore(time-tracker): edit errors --- .../[wsId]/time-tracker/components/session-history.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 567778d37..9acee70d1 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -985,9 +985,11 @@ export function SessionHistory({ timeOfDayBreakdown[a[0] as keyof typeof timeOfDayBreakdown] > timeOfDayBreakdown[b[0] as keyof typeof timeOfDayBreakdown] ? a : b )[0]; - const longestSession = sessionsForPeriod.reduce((longest, session) => - (session.duration_seconds || 0) > (longest.duration_seconds || 0) ? session : longest - , sessionsForPeriod[0]); + const longestSession = sessionsForPeriod.length > 0 + ? sessionsForPeriod.reduce((longest, session) => + (session.duration_seconds || 0) > (longest.duration_seconds || 0) ? session : longest + ) + : null; const shortSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) < 1800).length; const mediumSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) >= 1800 && (s.duration_seconds || 0) < 7200).length; From cfa2af4f2e2d132c4d96cb80f27f813dd205dec5 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Tue, 17 Jun 2025 23:21:54 +0800 Subject: [PATCH 12/35] refactor(time-tracker): fixing issues --- .../calendar/components/time-tracker.tsx | 18 +---- .../time-tracker/components/goal-manager.tsx | 14 +--- .../components/session-history.tsx | 2 +- .../components/stats-overview.tsx | 13 +--- .../components/timer-controls.tsx | 8 +- .../time-tracker/time-tracker-content.tsx | 74 ++----------------- .../(dashboard)/[wsId]/time-tracker/types.ts | 64 ++++++++++++++-- 7 files changed, 73 insertions(+), 120 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx index 7ee3f594b..12638aa5d 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx @@ -70,29 +70,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs'; import { Textarea } from '@tuturuuu/ui/textarea'; import { cn } from '@tuturuuu/utils/format'; import { useCallback, useEffect, useState } from 'react'; - -interface ExtendedWorkspaceTask extends Partial { - board_name?: string; - list_name?: string; -} +import type { ExtendedWorkspaceTask, TimerStats, SessionWithRelations } from '../../time-tracker/types'; interface TimeTrackerProps { wsId: string; tasks?: ExtendedWorkspaceTask[]; } -interface TimerStats { - todayTime: number; - weekTime: number; - monthTime: number; - streak: number; -} - -interface SessionWithRelations extends TimeTrackingSession { - category?: TimeTrackingCategory; - task?: WorkspaceTask; -} - interface SessionTemplate { title: string; description?: string; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx index 834c2a37d..2ee303699 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { TimeTrackingGoal } from '../time-tracker-content'; +import type { TimeTrackingGoal, TimerStats } from '../types'; import type { TimeTrackingCategory } from '@tuturuuu/types/db'; import { AlertDialog, @@ -56,17 +56,7 @@ import { Switch } from '@tuturuuu/ui/switch'; import { cn } from '@tuturuuu/utils/format'; import { useState } from 'react'; -interface TimerStats { - todayTime: number; - weekTime: number; - monthTime: number; - streak: number; - categoryBreakdown?: { - today: Record; - week: Record; - month: Record; - }; -} + interface GoalManagerProps { wsId: string; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 9acee70d1..4d577fcbe 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { SessionWithRelations } from '../time-tracker-content'; +import type { SessionWithRelations } from '../types'; import type { TimeTrackingCategory, WorkspaceTask } from '@tuturuuu/types/db'; import { AlertDialog, diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx index 50a8fd83e..e4a2d3e1c 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx @@ -4,18 +4,7 @@ import { Card, CardContent } from '@tuturuuu/ui/card'; import { Calendar, Clock, TrendingUp, Zap } from '@tuturuuu/ui/icons'; import { cn } from '@tuturuuu/utils/format'; import { useMemo } from 'react'; - -interface TimerStats { - todayTime: number; - weekTime: number; - monthTime: number; - streak: number; - dailyActivity?: Array<{ - date: string; - duration: number; - sessions: number; - }>; -} +import type { TimerStats } from '../types'; interface StatsOverviewProps { timerStats: TimerStats; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 0bb9492d1..31a3c843b 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ExtendedWorkspaceTask, TaskFilters } from '../types'; +import type { ExtendedWorkspaceTask, TaskFilters, SessionWithRelations } from '../types'; import { generateAssigneeInitials, getFilteredAndSortedTasks, @@ -8,7 +8,6 @@ import { } from '../utils'; import type { TimeTrackingCategory, - TimeTrackingSession, WorkspaceTask, } from '@tuturuuu/types/db'; import { Badge } from '@tuturuuu/ui/badge'; @@ -50,10 +49,7 @@ import { Textarea } from '@tuturuuu/ui/textarea'; import { cn } from '@tuturuuu/utils/format'; import { useCallback, useEffect, useRef, useState } from 'react'; -interface SessionWithRelations extends TimeTrackingSession { - category: TimeTrackingCategory | null; - task: WorkspaceTask | null; -} + interface SessionTemplate { title: string; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index f446ded85..415e3ca00 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -7,7 +7,14 @@ import { SessionHistory } from './components/session-history'; import { TimerControls } from './components/timer-controls'; import { UserSelector } from './components/user-selector'; import { useCurrentUser } from './hooks/use-current-user'; -import type { ExtendedWorkspaceTask, TaskSidebarFilters } from './types'; +import type { + ExtendedWorkspaceTask, + TaskSidebarFilters, + TimerStats, + SessionWithRelations, + TimeTrackingGoal, + TimeTrackerData +} from './types'; import { generateAssigneeInitials, getFilteredAndSortedSidebarTasks, @@ -16,7 +23,6 @@ import { import { useQuery } from '@tanstack/react-query'; import type { TimeTrackingCategory, - TimeTrackingSession, WorkspaceTask, } from '@tuturuuu/types/db'; import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; @@ -24,14 +30,10 @@ import { Button } from '@tuturuuu/ui/button'; import { AlertCircle, BarChart2, - Brain, Calendar, CheckCircle, CheckSquare, - ChevronLeft, - ChevronRight, Clock, - Copy, History, LayoutDashboard, MapPin, @@ -41,7 +43,6 @@ import { RefreshCw, RotateCcw, Settings, - Sparkles, Tag, Target, Timer, @@ -73,50 +74,6 @@ interface TimeTrackerContentProps { initialData: TimeTrackerData; } -interface TimerStats { - todayTime: number; - weekTime: number; - monthTime: number; - streak: number; - categoryBreakdown?: { - today: Record; - week: Record; - month: Record; - }; - dailyActivity?: Array<{ - date: string; - duration: number; - sessions: number; - }>; -} - -// Unified SessionWithRelations type that matches both TimerControls and SessionHistory expectations -export interface SessionWithRelations extends TimeTrackingSession { - category: TimeTrackingCategory | null; - task: WorkspaceTask | null; -} - -// Unified TimeTrackingGoal type that matches GoalManager expectations -export interface TimeTrackingGoal { - id: string; - ws_id: string; - user_id: string; - category_id: string | null; - daily_goal_minutes: number; - weekly_goal_minutes: number | null; - is_active: boolean | null; - category: TimeTrackingCategory | null; -} - -export interface TimeTrackerData { - categories: TimeTrackingCategory[]; - runningSession: SessionWithRelations | null; - recentSessions: SessionWithRelations[] | null; - goals: TimeTrackingGoal[] | null; - tasks: ExtendedWorkspaceTask[]; - stats: TimerStats; -} - export default function TimeTrackerContent({ wsId, initialData, @@ -628,22 +585,7 @@ export default function TimeTrackerContent({ fetchData(true, true); }, []); // Remove fetchData dependency - // Quick Actions Carousel - const [carouselView, setCarouselView] = useState(0); - const [lastUserInteraction, setLastUserInteraction] = useState(Date.now()); - - // Auto-advance carousel every 15 seconds (pauses when user interacts) - useEffect(() => { - const interval = setInterval(() => { - const timeSinceLastInteraction = Date.now() - lastUserInteraction; - if (timeSinceLastInteraction >= 15000) { - // 15 seconds - setCarouselView((prev) => (prev === 2 ? 0 : prev + 1)); - } - }, 15000); - return () => clearInterval(interval); - }, [lastUserInteraction]); // Sidebar View Switching const [sidebarView, setSidebarView] = useState< diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts index 569cf6ded..00a37a4d9 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/types.ts @@ -1,17 +1,57 @@ -import type { WorkspaceTask } from '@tuturuuu/types/db'; +import type { TimeTrackingCategory, TimeTrackingSession, WorkspaceTask } from '@tuturuuu/types/db'; -export interface ExtendedWorkspaceTask extends Partial { +// Main timer statistics interface +export interface TimerStats { + todayTime: number; + weekTime: number; + monthTime: number; + streak: number; + categoryBreakdown?: { + today: Record; + week: Record; + month: Record; + }; + dailyActivity?: Array<{ + date: string; + duration: number; + sessions: number; + }>; +} + +// Session with related data +export interface SessionWithRelations extends TimeTrackingSession { + category: TimeTrackingCategory | null; + task: WorkspaceTask | null; +} + +// Goal tracking interface +export interface TimeTrackingGoal { + id: string; + ws_id: string; + user_id: string; + category_id: string | null; + daily_goal_minutes: number; + weekly_goal_minutes: number | null; + is_active: boolean | null; + category: TimeTrackingCategory | null; +} + +// Extended task interface with additional properties +export interface ExtendedWorkspaceTask extends WorkspaceTask { board_name?: string; list_name?: string; + assignee_name?: string; + assignee_avatar?: string; + is_assigned_to_current_user?: boolean; assignees?: Array<{ id: string; display_name?: string; avatar_url?: string; email?: string; }>; - is_assigned_to_current_user?: boolean; } +// Task filters interface for timer controls export interface TaskFilters { priority: string; status: string; @@ -20,8 +60,20 @@ export interface TaskFilters { assignee: string; } +// Sidebar task filters export interface TaskSidebarFilters { - board: string; - list: string; - assignee: string; + status: 'all' | 'todo' | 'in_progress' | 'done'; + priority: 'all' | 'high' | 'medium' | 'low'; + assignee: 'all' | 'me' | string; + project: 'all' | string; +} + +// Complete time tracker data structure +export interface TimeTrackerData { + categories: TimeTrackingCategory[]; + runningSession: SessionWithRelations | null; + recentSessions: SessionWithRelations[] | null; + goals: TimeTrackingGoal[] | null; + tasks: ExtendedWorkspaceTask[]; + stats: TimerStats; } From c5f785e2f889e91cb99b2338e993c5c639d15599 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Tue, 17 Jun 2025 23:29:24 +0800 Subject: [PATCH 13/35] chore(time-tracker): fixing errors --- .../calendar/components/tasks-sidebar-content.tsx | 15 +++++++++++++-- .../[wsId]/calendar/components/time-tracker.tsx | 1 - .../[wsId]/time-tracker/time-tracker-content.tsx | 2 -- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx index 9fc5930d2..a3f231156 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx @@ -11,6 +11,7 @@ import type { WorkspaceTask, WorkspaceTaskBoard, } from '@tuturuuu/types/db'; +import type { ExtendedWorkspaceTask } from '../../time-tracker/types'; import { Accordion, AccordionContent, @@ -186,11 +187,21 @@ export default function TasksSidebarContent({ // Get all tasks from all boards for time tracker const allTasks = useMemo(() => { - const tasks: Partial[] = []; + const tasks: ExtendedWorkspaceTask[] = []; initialTaskBoards.forEach((board) => { board.lists?.forEach((list) => { if (list.tasks) { - tasks.push(...list.tasks); + // Transform Partial to ExtendedWorkspaceTask + const extendedTasks = list.tasks.map((task) => ({ + ...task, + board_name: board.name, + list_name: list.name, + assignee_name: undefined, + assignee_avatar: undefined, + is_assigned_to_current_user: undefined, + assignees: undefined, + } as ExtendedWorkspaceTask)); + tasks.push(...extendedTasks); } }); }); diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx index 12638aa5d..9e7cd13a6 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/time-tracker.tsx @@ -2,7 +2,6 @@ import type { TimeTrackingCategory, - TimeTrackingSession, WorkspaceTask, } from '@tuturuuu/types/db'; import { diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index 415e3ca00..32e8c680a 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -23,7 +23,6 @@ import { import { useQuery } from '@tanstack/react-query'; import type { TimeTrackingCategory, - WorkspaceTask, } from '@tuturuuu/types/db'; import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; import { Button } from '@tuturuuu/ui/button'; @@ -44,7 +43,6 @@ import { RotateCcw, Settings, Tag, - Target, Timer, TrendingUp, WifiOff, From 3cfb2f2222b1731f337f38cadf838e60e6b4f1ec Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 00:13:07 +0800 Subject: [PATCH 14/35] chore(time-tracker): fixing errors - import, incorrect types, mismatch, and undefined errors --- .../calendar/components/tasks-sidebar-content.tsx | 1 - .../(dashboard)/[wsId]/time-tracker/page.tsx | 3 ++- .../[wsId]/time-tracker/time-tracker-content.tsx | 13 ++++++++----- .../(dashboard)/[wsId]/time-tracker/types.ts | 7 +++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx index a3f231156..29992e02a 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx @@ -8,7 +8,6 @@ import { TaskListForm } from './task-list-form'; import TimeTracker from './time-tracker'; import type { AIChat, - WorkspaceTask, WorkspaceTaskBoard, } from '@tuturuuu/types/db'; import type { ExtendedWorkspaceTask } from '../../time-tracker/types'; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/page.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/page.tsx index 030da7f4c..714b02dd4 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/page.tsx @@ -1,4 +1,5 @@ -import TimeTrackerContent, { TimeTrackerData } from './time-tracker-content'; +import TimeTrackerContent from './time-tracker-content'; +import type { TimeTrackerData } from './types'; import { getTimeTrackingData } from '@/lib/time-tracking-helper'; import { getWorkspace, verifySecret } from '@/lib/workspace-helper'; import { getCurrentUser } from '@tuturuuu/utils/user-helper'; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index 32e8c680a..eec1bb114 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -252,9 +252,9 @@ export default function TimeTrackerContent({ // Combine and sort by priority within each group (lower number = higher priority) prioritizedTasks = [ - ...myUrgentTasks.sort((a, b) => (a.priority || 99) - (b.priority || 99)), - ...urgentUnassigned.sort((a, b) => (a.priority || 99) - (b.priority || 99)), - ...myOtherTasks.sort((a, b) => (a.priority || 99) - (b.priority || 99)) + ...myUrgentTasks.sort((a: ExtendedWorkspaceTask, b: ExtendedWorkspaceTask) => (a.priority || 99) - (b.priority || 99)), + ...urgentUnassigned.sort((a: ExtendedWorkspaceTask, b: ExtendedWorkspaceTask) => (a.priority || 99) - (b.priority || 99)), + ...myOtherTasks.sort((a: ExtendedWorkspaceTask, b: ExtendedWorkspaceTask) => (a.priority || 99) - (b.priority || 99)) ]; setAvailableTasks(prioritizedTasks); @@ -864,10 +864,11 @@ export default function TimeTrackerContent({ if (availableTasks.length === 1) { // Single task - auto-start const task = availableTasks[0]; - const isUnassigned = !task.assignees || task.assignees.length === 0; + const isUnassigned = !task || !task.assignees || task.assignees.length === 0; try { // If task is unassigned, assign to current user first + if (!task) return; if (isUnassigned) { const { createClient } = await import('@tuturuuu/supabase/next/client'); const supabase = createClient(); @@ -2131,6 +2132,7 @@ export default function TimeTrackerContent({ + + +
+
{month.month.format('MMMM YYYY')}
+
+ {formatDuration(month.duration)} • {month.sessions} sessions +
+
+
+ + ))} +
+
+ ); + }; + + // Render monthly calendar view + const renderMonthlyCalendar = () => { + const monthStart = currentMonth.startOf('month'); + const monthEnd = currentMonth.endOf('month'); + const calendarStart = monthStart.startOf('week'); + const calendarEnd = monthEnd.endOf('week'); + + const days = []; + let currentDay = calendarStart; + + while (currentDay.isBefore(calendarEnd) || currentDay.isSame(calendarEnd, 'day')) { + const dayActivity = dailyActivity?.find(activity => { + const activityDate = dayjs.utc(activity.date).tz(userTimezone); + return activityDate.isSame(currentDay, 'day'); + }); + + days.push({ + date: currentDay, + activity: dayActivity || null, + isCurrentMonth: currentDay.isSame(currentMonth, 'month'), + isToday: currentDay.isSame(today, 'day') + }); + + currentDay = currentDay.add(1, 'day'); + } + + const monthlyStats = { + activeDays: days.filter(day => day.activity && day.isCurrentMonth).length, + totalDuration: days + .filter(day => day.isCurrentMonth && day.activity) + .reduce((sum, day) => sum + (day.activity?.duration || 0), 0), + totalSessions: days + .filter(day => day.isCurrentMonth && day.activity) + .reduce((sum, day) => sum + (day.activity?.sessions || 0), 0) + }; + + return ( +
+ {/* Month Header */} +
+
+

+ {currentMonth.format('MMMM YYYY')} +

+
+ {monthlyStats.activeDays} active days + {formatDuration(monthlyStats.totalDuration)} tracked +
+
+
+ + +
+
+ + {/* Calendar Grid */} +
+ {/* Day Headers */} +
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => ( +
{day}
+ ))} +
+ + {/* Calendar Days */} +
+ {days.map((day, index) => { + const intensity = getIntensity(day.activity?.duration || 0); + + return ( + + + + + +
+
+ {day.date.format('dddd, MMMM D, YYYY')} + {settings.timeReference === 'smart' && ( +
+ {day.date.fromNow()} +
+ )} +
+ {day.activity ? ( +
+ {formatDuration(day.activity.duration)} • {day.activity.sessions} sessions +
+ ) : ( +
+ No activity recorded +
+ )} +
+
+
+ ); + })} +
+
+
+ ); + }; + return ( -
+
{/* Header */}
@@ -365,104 +613,230 @@ export function ActivityHeatmap({ : 'Start tracking to see your activity pattern'}

-
- Less -
- {[0, 1, 2, 3, 4].map((intensity) => ( -
- ))} +
+ {/* View Mode Settings */} + + + + + + Display Mode + {externalSettings && ( +
+ Controlled by Timer Settings +
+ )} + !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'original' }))} + disabled={!!externalSettings} + > + + Original Grid + {settings.viewMode === 'original' && } + + !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'hybrid' }))} + disabled={!!externalSettings} + > + + Hybrid View + {settings.viewMode === 'hybrid' && } + + !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'calendar-only' }))} + disabled={!!externalSettings} + > + + Calendar Only + {settings.viewMode === 'calendar-only' && } + + + Options + + !externalSettings && setInternalSettings(prev => ({ + ...prev, + timeReference: checked ? 'smart' : 'relative' + })) + } + disabled={!!externalSettings} + > + Show smart time references + + + !externalSettings && setInternalSettings(prev => ({ ...prev, showOnboardingTips: checked })) + } + disabled={!!externalSettings} + > + Show helpful tips + +
+
+ + {/* Legend */} +
+ Less +
+ {[0, 1, 2, 3, 4].map((intensity) => ( +
+ ))} +
+ More
- More
- {/* Mobile: Three-row layout */} -
-
- {/* Day labels for mobile */} -
-
- M - T - W - T - F - S - S -
-
- {renderHeatmapSection(mobileFirstRow, 'mobile-first', true)} + {/* Onboarding Tips */} + {settings.showOnboardingTips && ( +
+
+ +
+

+ 💡 Heatmap Guide +

+

+ {settings.viewMode === 'original' && "Darker colors = more activity. Use the View menu to try different layouts!"} + {settings.viewMode === 'hybrid' && "Click on any month bar above to view that month's calendar below."} + {settings.viewMode === 'calendar-only' && "Navigate between months using the arrow buttons. Each day shows your activity level."} +

+
- -
-
- M - T - W - T - F - S - S -
-
- {renderHeatmapSection(mobileSecondRow, 'mobile-second', true)} +
+ )} + + {/* Render Different Views Based on Settings */} + {settings.viewMode === 'original' && ( + <> + {/* Mobile: Three-row layout */} +
+
+ {/* Day labels for mobile */} +
+
+ M + T + W + T + F + S + S +
+
+ {renderHeatmapSection(mobileFirstRow, 'mobile-first', true)} +
+
+ +
+
+ M + T + W + T + F + S + S +
+
+ {renderHeatmapSection(mobileSecondRow, 'mobile-second', true)} +
+
+ + {/* Third row for mobile */} +
+
+ M + T + W + T + F + S + S +
+
+ {renderHeatmapSection(mobileThirdRow, 'mobile-third', true)} +
+
- {/* Third row for mobile */} -
-
- M - T - W - T - F - S - S -
-
- {renderHeatmapSection(mobileThirdRow, 'mobile-third', true)} + {/* Desktop: Two-row layout */} +
+
+ {/* Row 1: First 6 months */} +
+
+ Mon + Wed + Fri +
+
+ {renderHeatmapSection(desktopFirstRow, 'desktop-first', false)} +
+
+ + {/* Row 2: Last 6 months */} +
+
+ Mon + Wed + Fri +
+
+ {renderHeatmapSection(desktopSecondRow, 'desktop-second', false)} +
+
-
-
- - {/* Desktop: Two-row layout */} -
-
- {/* Row 1: First 6 months */} -
-
- Mon - Wed - Fri -
-
- {renderHeatmapSection(desktopFirstRow, 'desktop-first', false)} -
+ + )} + + {settings.viewMode === 'hybrid' && ( +
+ {/* Year Overview */} +
+ {renderYearOverview()}
- - {/* Row 2: Last 6 months */} -
-
- Mon - Wed - Fri -
-
- {renderHeatmapSection(desktopSecondRow, 'desktop-second', false)} -
+ + {/* Monthly Calendar */} +
+ {renderMonthlyCalendar()}
-
+ )} + + {settings.viewMode === 'calendar-only' && ( +
+ {renderMonthlyCalendar()} +
+ )}
); } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index c429246a5..2db052ab4 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -49,6 +49,7 @@ import { Zap, } from '@tuturuuu/ui/icons'; import { Input } from '@tuturuuu/ui/input'; +import { Label } from '@tuturuuu/ui/label'; import { Select, SelectContent, @@ -56,6 +57,7 @@ import { SelectTrigger, SelectValue, } from '@tuturuuu/ui/select'; +import { Switch } from '@tuturuuu/ui/switch'; import { toast } from '@tuturuuu/ui/sonner'; import { Tabs, TabsContent } from '@tuturuuu/ui/tabs'; import { cn } from '@tuturuuu/utils/format'; @@ -158,6 +160,25 @@ export default function TimeTrackerContent({ const [lastRefresh, setLastRefresh] = useState(new Date()); const [retryCount, setRetryCount] = useState(0); + // Heatmap settings state + const [heatmapSettings, setHeatmapSettings] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('heatmap-settings'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + // Fall through to default + } + } + } + return { + viewMode: 'original' as 'original' | 'hybrid' | 'calendar-only', + timeReference: 'smart' as 'relative' | 'absolute' | 'smart', + showOnboardingTips: true, + }; + }); + // Refs for cleanup const timerIntervalRef = useRef | null>(null); const refreshIntervalRef = useRef | null>( @@ -1481,8 +1502,8 @@ export default function TimeTrackerContent({ {/* Activity Heatmap - Enhanced with better header */} {timerStats.dailyActivity && ( -
-
+
+
@@ -1506,11 +1527,12 @@ export default function TimeTrackerContent({
- {/* Remove the original header from ActivityHeatmap component */} -
+ {/* Remove the original header from ActivityHeatmap component and provide overflow space */} +
@@ -2065,12 +2087,104 @@ export default function TimeTrackerContent({
-
- -

- Timer settings and preferences will be available here. - Configure notifications, default categories, and more. -

+
+ {/* Activity Heatmap Settings */} +
+
+ +

Activity Heatmap Display

+
+ +
+
+ + +

+ {heatmapSettings.viewMode === 'original' && 'GitHub-style grid view with day labels'} + {heatmapSettings.viewMode === 'hybrid' && 'Year overview plus monthly calendar details'} + {heatmapSettings.viewMode === 'calendar-only' && 'Traditional calendar interface'} +

+
+ +
+ + +
+ +
+ { + const newSettings = { ...heatmapSettings, showOnboardingTips: checked }; + setHeatmapSettings(newSettings); + localStorage.setItem('heatmap-settings', JSON.stringify(newSettings)); + }} + /> + +
+
+
+ + {/* Coming Soon Section */} +
+
+ +

More Settings Coming Soon

+
+

+ Notifications, default categories, productivity goals, and more customization options. +

+
From 982a10c1666f15945cbba6577484a8ed4e1b9800 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 04:00:33 +0800 Subject: [PATCH 17/35] fix(time-tracker): enhance onboarding tips with smart dismissal - Remove disabled state from dismiss button when external settings provided - Add intelligent display logic based on user engagement and timing - Implement localStorage persistence for dismiss preferences - Add auto-hide functionality after 45 seconds for experienced users - Include contextual tips that adapt to current heatmap view mode - Add cross-component event system for settings synchronization - Enhance UX with proper hover states, tooltips, and accessibility - Fix TypeScript compilation errors in compact cards component - Ensure tips reappear strategically (every 7 days or view mode changes) - Prevent annoying repetition while maintaining helpful onboarding flow --- .../components/activity-heatmap.tsx | 701 +++++++++++++++++- .../time-tracker/time-tracker-content.tsx | 22 + 2 files changed, 705 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx index 5c479478c..ad50061f1 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx @@ -11,7 +11,7 @@ import { DropdownMenuTrigger, DropdownMenuCheckboxItem } from '@tuturuuu/ui/dropdown-menu'; -import { Settings, Calendar, Grid3X3, Info, ChevronLeft, ChevronRight } from '@tuturuuu/ui/icons'; +import { Settings, Calendar, Grid3X3, Info, ChevronLeft, ChevronRight, LayoutDashboard } from '@tuturuuu/ui/icons'; import { cn } from '@tuturuuu/utils/format'; import dayjs from 'dayjs'; import isoWeek from 'dayjs/plugin/isoWeek'; @@ -19,7 +19,7 @@ import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import relativeTime from 'dayjs/plugin/relativeTime'; import isBetween from 'dayjs/plugin/isBetween'; -import { useState } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; dayjs.extend(utc); dayjs.extend(timezone); @@ -38,7 +38,7 @@ interface ActivityHeatmapProps { settings?: HeatmapSettings; } -type HeatmapViewMode = 'original' | 'hybrid' | 'calendar-only'; +type HeatmapViewMode = 'original' | 'hybrid' | 'calendar-only' | 'compact-cards'; interface HeatmapSettings { viewMode: HeatmapViewMode; @@ -57,8 +57,155 @@ export function ActivityHeatmap({ showOnboardingTips: true, }); + // Smart onboarding tips state - separate from general settings for better control + const [onboardingState, setOnboardingState] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('time-tracker-onboarding'); + if (saved) { + try { + const parsed = JSON.parse(saved); + // Check if user dismissed tips recently (within 7 days) + if (parsed.dismissedAt) { + const dismissedDate = new Date(parsed.dismissedAt); + const daysSinceDismissed = Math.floor((Date.now() - dismissedDate.getTime()) / (1000 * 60 * 60 * 24)); + + // If dismissed recently, don't show tips + if (daysSinceDismissed < 7) { + return { + showTips: false, + dismissedAt: parsed.dismissedAt, + viewCount: parsed.viewCount || 0, + lastViewMode: parsed.lastViewMode || 'original' + }; + } + } + return { + showTips: true, + dismissedAt: null, + viewCount: parsed.viewCount || 0, + lastViewMode: parsed.lastViewMode || 'original' + }; + } catch { + // Fall through to default + } + } + } + return { + showTips: true, + dismissedAt: null, + viewCount: 0, + lastViewMode: 'original' + }; + }); + // Use external settings if provided, otherwise use internal settings const settings = externalSettings || internalSettings; + + // Auto-hide tips after 30 seconds of inactivity (optional enhancement) + const [tipAutoHideTimer, setTipAutoHideTimer] = useState | null>(null); + + // Smart logic: Only show onboarding tips when appropriate + const shouldShowOnboardingTips = useMemo(() => { + // Don't show if user explicitly disabled in settings + if (!settings.showOnboardingTips) return false; + + // Don't show if recently dismissed + if (!onboardingState.showTips) return false; + + // Show for new users (< 3 views) or when switching view modes + const isNewUser = onboardingState.viewCount < 3; + const changedViewMode = onboardingState.lastViewMode !== settings.viewMode; + + // Show for experienced users occasionally (every 14 days after 10+ views) + const isPeriodicReminder = onboardingState.viewCount >= 10 && + (!onboardingState.dismissedAt || + Math.floor((Date.now() - new Date(onboardingState.dismissedAt).getTime()) / (1000 * 60 * 60 * 24)) >= 14); + + return isNewUser || changedViewMode || isPeriodicReminder; + }, [settings.showOnboardingTips, onboardingState, settings.viewMode]); + + // Track view mode changes and update count + useEffect(() => { + if (settings.viewMode !== onboardingState.lastViewMode) { + const newState = { + ...onboardingState, + viewCount: onboardingState.viewCount + 1, + lastViewMode: settings.viewMode, + showTips: true, // Reset to show tips when view mode changes + dismissedAt: null + }; + setOnboardingState(newState); + + if (typeof window !== 'undefined') { + localStorage.setItem('time-tracker-onboarding', JSON.stringify(newState)); + } + } + }, [settings.viewMode, onboardingState]); + + // Handle tip dismissal + const handleDismissTips = useCallback(() => { + const newState = { + ...onboardingState, + showTips: false, + dismissedAt: new Date().toISOString() + }; + setOnboardingState(newState); + + if (typeof window !== 'undefined') { + localStorage.setItem('time-tracker-onboarding', JSON.stringify(newState)); + } + + // Also update parent settings if external settings provided + if (externalSettings && typeof window !== 'undefined') { + const currentHeatmapSettings = localStorage.getItem('heatmap-settings'); + if (currentHeatmapSettings) { + try { + const parsed = JSON.parse(currentHeatmapSettings); + const updated = { ...parsed, showOnboardingTips: false }; + localStorage.setItem('heatmap-settings', JSON.stringify(updated)); + + // Dispatch a custom event to notify parent component + window.dispatchEvent(new CustomEvent('heatmap-settings-changed', { + detail: updated + })); + } catch { + // Silently fail + } + } + } + + // Clear auto-hide timer if active + if (tipAutoHideTimer) { + clearTimeout(tipAutoHideTimer); + setTipAutoHideTimer(null); + } + }, [onboardingState, externalSettings, tipAutoHideTimer]); + + // Set up auto-hide timer when tips are shown (optional - can be disabled) + useEffect(() => { + if (shouldShowOnboardingTips && onboardingState.viewCount >= 5) { + // Only auto-hide for users who've seen tips multiple times + const timer = setTimeout(() => { + handleDismissTips(); + }, 45000); // Auto-hide after 45 seconds for experienced users + + setTipAutoHideTimer(timer); + + return () => { + clearTimeout(timer); + }; + } + }, [shouldShowOnboardingTips, onboardingState.viewCount, handleDismissTips]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (tipAutoHideTimer) { + clearTimeout(tipAutoHideTimer); + } + }; + }, [tipAutoHideTimer]); + const [currentMonth, setCurrentMonth] = useState(dayjs().tz(dayjs.tz.guess())); const userTimezone = dayjs.tz.guess(); @@ -317,7 +464,7 @@ export function ActivityHeatmap({
{day.date.format('dddd, MMMM D, YYYY')} - {settings.showTimeReference === 'both' && ( + {settings.timeReference === 'smart' && (
{day.date.fromNow()} • {day.date.format('MMM D')}
@@ -599,6 +746,496 @@ export function ActivityHeatmap({ ); }; + const renderCompactCards = () => { + // Group data by month for compact card display + const monthlyData = weeks.reduce((acc, week) => { + week.forEach(day => { + // Add null check for day and day.activity + if (day && day.activity) { + const monthKey = day.date.format('YYYY-MM'); + const monthName = day.date.format('MMM YYYY'); + + if (!acc[monthKey]) { + acc[monthKey] = { + name: monthName, + totalDuration: 0, + activeDays: 0, + totalSessions: 0, + dates: [] as typeof day[], + weekdays: 0, + weekends: 0, + bestDay: { duration: 0, date: '' }, + longestStreak: 0, + currentStreak: 0, + }; + } + + acc[monthKey].totalDuration += day.activity.duration; + acc[monthKey].totalSessions += day.activity.sessions; + acc[monthKey].activeDays += 1; + acc[monthKey].dates.push(day); + + // Track weekday vs weekend activity + if (day.date.day() === 0 || day.date.day() === 6) { + acc[monthKey].weekends += 1; + } else { + acc[monthKey].weekdays += 1; + } + + // Track best day + if (day.activity.duration > acc[monthKey].bestDay.duration) { + acc[monthKey].bestDay = { + duration: day.activity.duration, + date: day.date.format('MMM D'), + }; + } + } + }); + return acc; + }, {} as Record); + + // Calculate streaks for each month + Object.values(monthlyData).forEach(monthData => { + let currentStreak = 0; + let longestStreak = 0; + + // Sort dates chronologically + const sortedDates = monthData.dates.sort((a, b) => a.date.valueOf() - b.date.valueOf()); + + for (let i = 0; i < sortedDates.length; i++) { + if (sortedDates[i] && sortedDates[i].activity && sortedDates[i].activity.duration > 0) { + currentStreak += 1; + longestStreak = Math.max(longestStreak, currentStreak); + } else { + currentStreak = 0; + } + } + + monthData.longestStreak = longestStreak; + monthData.currentStreak = currentStreak; + }); + + // Sort months chronologically (most recent first) + const sortedMonths = Object.entries(monthlyData) + .sort(([a], [b]) => b.localeCompare(a)) + .slice(0, 12); // Show last 12 months + + // Calculate trends (compare with previous month) + const monthsWithTrends = sortedMonths.map(([monthKey, data], index) => { + const previousMonth = sortedMonths[index + 1]; + let trend = 'neutral' as 'up' | 'down' | 'neutral'; + let trendValue = 0; + + if (previousMonth) { + const prevData = previousMonth[1]; + const currentAvg = data.totalDuration / Math.max(data.activeDays, 1); + const prevAvg = prevData.totalDuration / Math.max(prevData.activeDays, 1); + + if (currentAvg > prevAvg * 1.1) trend = 'up'; + else if (currentAvg < prevAvg * 0.9) trend = 'down'; + + trendValue = ((currentAvg - prevAvg) / Math.max(prevAvg, 1)) * 100; + } + + return { monthKey, data, trend, trendValue }; + }); + + // Calculate overall statistics for summary card + const totalOverallDuration = sortedMonths.reduce((sum, [, data]) => sum + data.totalDuration, 0); + const totalOverallSessions = sortedMonths.reduce((sum, [, data]) => sum + data.totalSessions, 0); + const totalActiveDays = sortedMonths.reduce((sum, [, data]) => sum + data.activeDays, 0); + const avgDailyOverall = totalActiveDays > 0 ? totalOverallDuration / totalActiveDays : 0; + const avgSessionLength = totalOverallSessions > 0 ? totalOverallDuration / totalOverallSessions : 0; + const overallFocusScore = avgSessionLength > 0 ? Math.min(100, Math.round((avgSessionLength / 3600) * 25)) : 0; + + // Determine if user is "established" enough to show upcoming month suggestions + const isEstablishedUser = totalActiveDays >= 7 && totalOverallSessions >= 10 && sortedMonths.length >= 1; + const hasRecentActivity = sortedMonths.length > 0 && dayjs().diff(dayjs().startOf('month'), 'day') < 15; // Active this month + const shouldShowUpcoming = isEstablishedUser && hasRecentActivity; + + // Create all cards + const allCards = []; + + // Add summary card if we have meaningful data (more than just a few sessions) + if (sortedMonths.length > 0 && totalActiveDays >= 3) { + allCards.push({ + type: 'summary', + data: { + totalDuration: totalOverallDuration, + totalSessions: totalOverallSessions, + activeDays: totalActiveDays, + avgDaily: avgDailyOverall, + avgSession: avgSessionLength, + focusScore: overallFocusScore, + monthCount: sortedMonths.length + } + }); + } + + // Add monthly data cards + monthsWithTrends.forEach(({ monthKey, data, trend, trendValue }) => { + allCards.push({ + type: 'monthly', + monthKey, + data, + trend, + trendValue + }); + }); + + // Only add upcoming months if user is established and we have space + if (shouldShowUpcoming && allCards.length < 4) { + const currentMonth = dayjs(); + const nextMonth = currentMonth.add(1, 'month'); + + // Only add 1 upcoming month as a subtle suggestion + allCards.push({ + type: 'upcoming', + monthKey: nextMonth.format('YYYY-MM'), + name: nextMonth.format('MMM YYYY'), + isSubtle: true + }); + } + + // Add getting started card if no meaningful data + if (sortedMonths.length === 0 || totalActiveDays < 3) { + allCards.unshift({ + type: 'getting-started' + }); + } + + const [currentIndex, setCurrentIndex] = useState(0); + const maxVisibleCards = 4; + const totalCards = allCards.length; + const canScrollLeft = currentIndex > 0; + const canScrollRight = currentIndex < totalCards - maxVisibleCards; + + const scrollLeft = () => { + if (canScrollLeft) { + setCurrentIndex(prev => Math.max(0, prev - 1)); + } + }; + + const scrollRight = () => { + if (canScrollRight) { + setCurrentIndex(prev => Math.min(totalCards - maxVisibleCards, prev + 1)); + } + }; + + const visibleCards = allCards.slice(currentIndex, currentIndex + maxVisibleCards); + + return ( +
+ {/* Navigation Arrows - Only show if needed */} + {totalCards > maxVisibleCards && ( + <> + + + + + )} + + {/* Cards Container */} +
maxVisibleCards ? "mx-8" : "mx-0")}> +
+ {visibleCards.map((card) => { + if (card.type === 'summary' && card.data) { + return ( +
+
+
+

Overall

+ {card.data.monthCount} month{card.data.monthCount > 1 ? 's' : ''} +
+
+ {card.data.focusScore}% +
+
+ +
+
+
Total
+
{formatDuration(card.data.totalDuration)}
+
+
+
Daily
+
{formatDuration(Math.round(card.data.avgDaily))}
+
+
+
Sessions
+
{card.data.totalSessions}
+
+
+
Days
+
{card.data.activeDays}
+
+
+ +
+

+ {card.data.activeDays < 7 ? + `Great start! Building momentum.` : + `Avg session: ${formatDuration(Math.round(card.data.avgSession))}` + } +

+
+
+ ); + } + + if (card.type === 'monthly' && card.data) { + const avgDailyDuration = card.data.activeDays > 0 ? card.data.totalDuration / card.data.activeDays : 0; + const avgSessionLength = card.data.totalSessions > 0 ? card.data.totalDuration / card.data.totalSessions : 0; + const focusScore = avgSessionLength > 0 ? Math.min(100, Math.round((avgSessionLength / 3600) * 25)) : 0; + const consistencyScore = card.data.activeDays > 0 ? Math.round((card.data.activeDays / 31) * 100) : 0; + + return ( +
+
+
+

{(card.data as any).name}

+
+
+ {(card as any).trend !== 'neutral' && ( + + {(card as any).trend === 'up' ? '↗' : '↘'}{Math.abs((card as any).trendValue).toFixed(0)}% + + )} +
+
+
+ {focusScore}% +
+
+ +
+
+
Total
+
{formatDuration(card.data.totalDuration)}
+
+
+
Daily
+
{formatDuration(Math.round(avgDailyDuration))}
+
+
+
Sessions
+
{card.data.totalSessions}
+
+
+
Days
+
{card.data.activeDays}
+
+
+ + {/* Mini Heatmap */} +
+
+ {Array.from({ length: 7 * 4 }, (_, i) => { + const monthStart = dayjs((card as any).monthKey + '-01'); + const dayOffset = i - monthStart.day(); + const currentDay = monthStart.add(dayOffset, 'day'); + + const dayActivity = (card.data as any).dates.find((d: any) => + d && d.date && d.date.format('YYYY-MM-DD') === currentDay.format('YYYY-MM-DD') + ); + + const isCurrentMonth = currentDay.month() === monthStart.month(); + const dayIntensity = dayActivity && dayActivity.activity ? getIntensity(dayActivity.activity.duration) : 0; + + return ( +
+ ); + })} +
+
+ +
+

+ {consistencyScore >= 80 ? 'Excellent consistency!' : + consistencyScore >= 50 ? 'Good habits forming' : + 'Building momentum'} +

+
+
+ ); + } + + if (card.type === 'upcoming') { + return ( +
+
+
+

{(card as any).name}

+ Next month +
+
+ Plan +
+
+ +
+
+
Target
+
Set goal
+
+
+
Focus
+
Stay consistent
+
+
+
Sessions
+
Plan ahead
+
+
+
Growth
+
Keep going
+
+
+ +
+
+ {Array.from({ length: 7 * 4 }, (_, i) => ( +
+ ))} +
+
+ +
+

+ Keep the momentum going! 🚀 +

+
+
+ ); + } + + if (card.type === 'getting-started') { + return ( +
+
+
+

Get Started

+ Begin journey +
+
+ New +
+
+ +
+
+
+ Start timer session +
+
+
+ Build daily habits +
+
+
+ Track progress +
+
+ +
+

+ 💡 Try 25-min Pomodoro sessions +

+
+
+ ); + } + + return null; + })} +
+
+ + {/* Pagination Dots - Only show if needed */} + {totalCards > maxVisibleCards && ( +
+ {Array.from({ length: Math.ceil(totalCards / maxVisibleCards) }, (_, i) => ( +
+ )} +
+ ); + }; + return (
{/* Header */} @@ -656,6 +1293,15 @@ export function ActivityHeatmap({ Calendar Only {settings.viewMode === 'calendar-only' && } + !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'compact-cards' }))} + disabled={!!externalSettings} + > + + Compact Cards + {settings.viewMode === 'compact-cards' && } + Options
- {/* Onboarding Tips */} - {settings.showOnboardingTips && ( -
+ {/* Smart Onboarding Tips */} + {shouldShowOnboardingTips && ( +
-
-

- 💡 Heatmap Guide -

-

- {settings.viewMode === 'original' && "Darker colors = more activity. Use the View menu to try different layouts!"} - {settings.viewMode === 'hybrid' && "Click on any month bar above to view that month's calendar below."} - {settings.viewMode === 'calendar-only' && "Navigate between months using the arrow buttons. Each day shows your activity level."} +

+
+

+ 💡 {settings.viewMode === 'original' && 'GitHub-style Heatmap'} + {settings.viewMode === 'hybrid' && 'Interactive Hybrid View'} + {settings.viewMode === 'calendar-only' && 'Monthly Calendar View'} + {settings.viewMode === 'compact-cards' && 'Compact Card Overview'} +

+ {onboardingState.viewCount > 0 && ( + + View #{onboardingState.viewCount + 1} + + )} +
+

+ {settings.viewMode === 'original' && "Track your productivity with GitHub-style visualization. Darker squares = more active days. Hover over any day for details, and use the View menu above to explore other layouts!"} + {settings.viewMode === 'hybrid' && "Best of both worlds! Click any month bar in the year overview to jump to that month's detailed calendar below. Perfect for spotting patterns across the year."} + {settings.viewMode === 'calendar-only' && "Navigate months with arrow buttons to see your activity patterns. Each colored square represents your activity level that day. Great for detailed daily analysis."} + {settings.viewMode === 'compact-cards' && "Monthly summaries at a glance! Each card shows key stats with a mini heatmap preview. Scroll horizontally to see different months and track your progress over time."}

+ {onboardingState.viewCount >= 3 && ( +

+ 💭 Tip: You can always toggle these tips on/off in the View menu or Settings panel. +

+ )}
@@ -837,6 +1500,8 @@ export function ActivityHeatmap({ {renderMonthlyCalendar()}
)} + + {settings.viewMode === 'compact-cards' && renderCompactCards()}
); } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx index 2db052ab4..7e03acf1f 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/time-tracker-content.tsx @@ -179,6 +179,21 @@ export default function TimeTrackerContent({ }; }); + // Listen for heatmap settings changes from child components + useEffect(() => { + const handleSettingsChange = (event: CustomEvent) => { + setHeatmapSettings(event.detail); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('heatmap-settings-changed', handleSettingsChange as EventListener); + + return () => { + window.removeEventListener('heatmap-settings-changed', handleSettingsChange as EventListener); + }; + } + }, []); + // Refs for cleanup const timerIntervalRef = useRef | null>(null); const refreshIntervalRef = useRef | null>( @@ -2128,12 +2143,19 @@ export default function TimeTrackerContent({ Calendar Only
+ +
+
+ Compact Cards +
+

{heatmapSettings.viewMode === 'original' && 'GitHub-style grid view with day labels'} {heatmapSettings.viewMode === 'hybrid' && 'Year overview plus monthly calendar details'} {heatmapSettings.viewMode === 'calendar-only' && 'Traditional calendar interface'} + {heatmapSettings.viewMode === 'compact-cards' && 'Monthly summary cards with key metrics and mini previews'}

From 4ff7b732d53f992935be0ce34fc8c2131a87fbc3 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 04:58:08 +0800 Subject: [PATCH 18/35] feat(time-tracker): new time tracking features --- .../components/timer-controls.tsx | 1034 ++++++++++++++++- 1 file changed, 1018 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index b643b0ea0..f0c0b62da 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -102,6 +102,57 @@ interface TimerControlsProps { currentUserId?: string; } +// Pomodoro timer types and interfaces +interface PomodoroSettings { + focusTime: number; // in minutes + shortBreakTime: number; // in minutes + longBreakTime: number; // in minutes + sessionsUntilLongBreak: number; + autoStartBreaks: boolean; + autoStartFocus: boolean; + enableNotifications: boolean; + enable2020Rule: boolean; // 20-20-20 eye rest rule + enableMovementReminder: boolean; +} + +type TimerMode = 'stopwatch' | 'pomodoro' | 'custom'; +type SessionType = 'focus' | 'short-break' | 'long-break'; +type CustomTimerType = 'stopwatch' | 'countdown' | 'pomodoro-custom'; + +interface CountdownState { + targetTime: number; // in seconds + remainingTime: number; // in seconds + sessionType: SessionType; + pomodoroSession: number; // current session number (1-4) + cycleCount: number; // number of completed pomodoro cycles +} + +interface CustomTimerSettings { + type: CustomTimerType; + duration: number; // in minutes (for countdown/pomodoro-custom) + autoRestart: boolean; + enableBreakReminders: boolean; + playCompletionSound: boolean; + showNotifications: boolean; + // For custom pomodoro + focusTime?: number; + breakTime?: number; + cycles?: number; +} + +// Default Pomodoro settings +const DEFAULT_POMODORO_SETTINGS: PomodoroSettings = { + focusTime: 25, + shortBreakTime: 5, + longBreakTime: 15, + sessionsUntilLongBreak: 4, + autoStartBreaks: false, + autoStartFocus: false, + enableNotifications: true, + enable2020Rule: true, + enableMovementReminder: true, +}; + export function TimerControls({ wsId, currentSession, @@ -136,6 +187,36 @@ export function TimerControls({ const [pausedElapsedTime, setPausedElapsedTime] = useState(0); const [pauseStartTime, setPauseStartTime] = useState(null); + // Pomodoro and timer mode state + const [timerMode, setTimerMode] = useState('stopwatch'); + const [pomodoroSettings, setPomodoroSettings] = useState(DEFAULT_POMODORO_SETTINGS); + const [countdownState, setCountdownState] = useState({ + targetTime: 25 * 60, // 25 minutes in seconds + remainingTime: 25 * 60, + sessionType: 'focus', + pomodoroSession: 1, + cycleCount: 0, + }); + + const [customTimerSettings, setCustomTimerSettings] = useState({ + type: 'countdown', + duration: 25, + autoRestart: false, + enableBreakReminders: true, + playCompletionSound: true, + showNotifications: true, + focusTime: 25, + breakTime: 5, + cycles: 4, + }); + const [lastNotificationTime, setLastNotificationTime] = useState(0); + const [showPomodoroSettings, setShowPomodoroSettings] = useState(false); + const [showCustomSettings, setShowCustomSettings] = useState(false); + + // Break reminders state + const [lastEyeBreakTime, setLastEyeBreakTime] = useState(Date.now()); + const [lastMovementBreakTime, setLastMovementBreakTime] = useState(Date.now()); + // localStorage keys for persistence const PAUSED_SESSION_KEY = `paused-session-${wsId}-${currentUserId || 'user'}`; const PAUSED_ELAPSED_KEY = `paused-elapsed-${wsId}-${currentUserId || 'user'}`; @@ -262,6 +343,302 @@ export function TimerControls({ // Use memoized task counts const { myTasksCount, unassignedCount } = useTaskCounts(tasks); + // Notification and sound functions + const playNotificationSound = useCallback(() => { + if ('Audio' in window) { + try { + // Create a simple notification beep using Web Audio API + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); + oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1); + + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.5); + } catch (error) { + console.warn('Could not play notification sound:', error); + } + } + }, []); + + const showNotification = useCallback((title: string, body: string, actions?: { title: string; action: () => void }[]) => { + // Check if notifications are enabled and supported + if (!pomodoroSettings.enableNotifications || !('Notification' in window)) { + return; + } + + // Request permission if needed + if (Notification.permission === 'default') { + Notification.requestPermission(); + return; + } + + if (Notification.permission === 'granted') { + const notification = new Notification(title, { + body, + icon: '/favicon.ico', + tag: 'pomodoro-timer', + requireInteraction: true, + }); + + notification.onclick = () => { + window.focus(); + notification.close(); + }; + + // Auto-close after 10 seconds + setTimeout(() => notification.close(), 10000); + } + + // Also show a toast notification + toast.info(title, { + description: body, + duration: 5000, + action: actions?.[0] ? { + label: actions[0].title, + onClick: actions[0].action, + } : undefined, + }); + + playNotificationSound(); + }, [pomodoroSettings.enableNotifications, playNotificationSound]); + + // Pomodoro timer logic + const startPomodoroSession = useCallback((sessionType: SessionType) => { + let duration: number; + + switch (sessionType) { + case 'focus': + duration = pomodoroSettings.focusTime * 60; + break; + case 'short-break': + duration = pomodoroSettings.shortBreakTime * 60; + break; + case 'long-break': + duration = pomodoroSettings.longBreakTime * 60; + break; + } + + setCountdownState(prev => ({ + ...prev, + targetTime: duration, + remainingTime: duration, + sessionType, + })); + + const sessionName = sessionType === 'focus' ? 'Focus Session' : + sessionType === 'short-break' ? 'Short Break' : 'Long Break'; + + showNotification( + `${sessionName} Started!`, + `${Math.floor(duration / 60)} minutes of ${sessionType === 'focus' ? 'focused work' : 'break time'}` + ); + }, [pomodoroSettings, showNotification]); + + const handlePomodoroComplete = useCallback(() => { + const { sessionType, pomodoroSession } = countdownState; + + if (sessionType === 'focus') { + // Focus session completed + const nextSession = pomodoroSession + 1; + const isTimeForLongBreak = nextSession > pomodoroSettings.sessionsUntilLongBreak; + + setCountdownState(prev => ({ + ...prev, + pomodoroSession: isTimeForLongBreak ? 1 : nextSession, + cycleCount: isTimeForLongBreak ? prev.cycleCount + 1 : prev.cycleCount, + })); + + showNotification( + 'Focus Session Complete! 🎉', + `Great work! Time for a ${isTimeForLongBreak ? 'long' : 'short'} break.`, + [{ + title: 'Start Break', + action: () => startPomodoroSession(isTimeForLongBreak ? 'long-break' : 'short-break') + }] + ); + + if (!pomodoroSettings.autoStartBreaks) { + // Pause timer and wait for user action + setIsRunning(false); + } else { + startPomodoroSession(isTimeForLongBreak ? 'long-break' : 'short-break'); + } + } else { + // Break completed + showNotification( + 'Break Complete! ⚡', + 'Ready to focus again?', + [{ + title: 'Start Focus', + action: () => startPomodoroSession('focus') + }] + ); + + if (!pomodoroSettings.autoStartFocus) { + setIsRunning(false); + } else { + startPomodoroSession('focus'); + } + } + }, [countdownState, pomodoroSettings, showNotification, startPomodoroSession, setIsRunning]); + + // Break reminder logic + const checkBreakReminders = useCallback(() => { + const now = Date.now(); + + // 20-20-20 rule: Every 20 minutes, look at something 20 feet away for 20 seconds + if (pomodoroSettings.enable2020Rule && + now - lastEyeBreakTime > 20 * 60 * 1000 && // 20 minutes + isRunning && + countdownState.sessionType === 'focus') { + + if (now - lastNotificationTime > 5 * 60 * 1000) { // Don't spam notifications + showNotification( + 'Eye Break Time! 👁️', + 'Look at something 20 feet away for 20 seconds' + ); + setLastEyeBreakTime(now); + setLastNotificationTime(now); + } + } + + // Movement reminder: Every 60 minutes + if (pomodoroSettings.enableMovementReminder && + now - lastMovementBreakTime > 60 * 60 * 1000 && // 60 minutes + isRunning && + countdownState.sessionType === 'focus') { + + if (now - lastNotificationTime > 5 * 60 * 1000) { + showNotification( + 'Movement Break! 🚶', + 'Time to stand up and stretch for a few minutes' + ); + setLastMovementBreakTime(now); + setLastNotificationTime(now); + } + } + }, [pomodoroSettings, lastEyeBreakTime, lastMovementBreakTime, lastNotificationTime, isRunning, countdownState.sessionType, showNotification]); + + + + // Update countdown timer + useEffect(() => { + if ((timerMode === 'pomodoro' || (timerMode === 'custom' && customTimerSettings.type !== 'stopwatch')) && isRunning && countdownState.remainingTime > 0) { + const interval = setInterval(() => { + setCountdownState(prev => { + const newRemainingTime = prev.remainingTime - 1; + + if (newRemainingTime <= 0) { + if (timerMode === 'pomodoro') { + handlePomodoroComplete(); + } else if (timerMode === 'custom') { + // Handle custom timer completion + if (customTimerSettings.type === 'countdown') { + showNotification( + 'Countdown Complete! ⏰', + 'Your custom countdown has finished - great work!', + customTimerSettings.autoRestart ? [{ + title: 'Restarting...', + action: () => {} + }] : undefined + ); + + if (customTimerSettings.playCompletionSound) { + // Play completion sound + const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBm/G8O3LtykFInGx8l2VmQQOXqw='); + audio.play().catch(() => {}); + } + + if (customTimerSettings.autoRestart) { + // Restart the countdown + const restartDuration = customTimerSettings.duration * 60; + return { + ...prev, + targetTime: restartDuration, + remainingTime: restartDuration + }; + } else { + setIsRunning(false); + } + } else if (customTimerSettings.type === 'pomodoro-custom') { + // Handle custom pomodoro completion inline + const currentSession = countdownState.pomodoroSession; + const maxSessions = customTimerSettings.cycles || 4; + + if (countdownState.sessionType === 'focus') { + // Focus session complete, start break + const isLongBreak = currentSession === maxSessions; + const nextSessionType: SessionType = isLongBreak ? 'long-break' : 'short-break'; + const breakDuration = (customTimerSettings.breakTime || 5) * 60; + + showNotification( + `🍅 Focus session ${currentSession} complete!`, + `Time for a ${isLongBreak ? 'long' : 'short'} break` + ); + + setCountdownState(prev => ({ + ...prev, + targetTime: breakDuration, + remainingTime: breakDuration, + sessionType: nextSessionType, + })); + return { ...prev, remainingTime: breakDuration }; + } else { + // Break complete + if (countdownState.pomodoroSession >= maxSessions) { + // All cycles complete + showNotification( + '🎉 Custom Pomodoro Complete!', + `Finished ${maxSessions} focus sessions - excellent work!` + ); + setIsRunning(false); + } else { + // Start next focus session + const focusDuration = (customTimerSettings.focusTime || 25) * 60; + const nextSession = countdownState.pomodoroSession + 1; + + showNotification( + '☕ Break complete!', + `Starting focus session ${nextSession}` + ); + + setCountdownState(prev => ({ + ...prev, + targetTime: focusDuration, + remainingTime: focusDuration, + sessionType: 'focus', + pomodoroSession: nextSession, + })); + return { ...prev, remainingTime: focusDuration }; + } + } + } + } + return { ...prev, remainingTime: 0 }; + } + + return { ...prev, remainingTime: newRemainingTime }; + }); + + // Check for break reminders (only during focus sessions and if enabled) + if (countdownState.sessionType === 'focus' && customTimerSettings.enableBreakReminders) { + checkBreakReminders(); + } + }, 1000); + + return () => clearInterval(interval); + } + }, [timerMode, isRunning, countdownState.remainingTime, countdownState.sessionType, customTimerSettings, handlePomodoroComplete, checkBreakReminders, showNotification]); + // Fetch boards with lists const fetchBoards = useCallback(async () => { try { @@ -524,13 +901,43 @@ export function TimerControls({ setCurrentSession(response.session); setIsRunning(true); setElapsedTime(0); + + // Initialize timer mode specific settings + if (timerMode === 'pomodoro') { + // Start first Pomodoro focus session + startPomodoroSession('focus'); + } else if (timerMode === 'custom') { + // Initialize custom timer based on type + if (customTimerSettings.type === 'countdown') { + const customDuration = customTimerSettings.duration * 60; + setCountdownState(prev => ({ + ...prev, + targetTime: customDuration, + remainingTime: customDuration, + sessionType: 'focus', + })); + } else if (customTimerSettings.type === 'pomodoro-custom') { + // Initialize custom pomodoro session + const focusDuration = (customTimerSettings.focusTime || 25) * 60; + setCountdownState(prev => ({ + ...prev, + targetTime: focusDuration, + remainingTime: focusDuration, + sessionType: 'focus', + pomodoroSession: 1, + cycleCount: 0, + })); + } + // For stopwatch mode, no special initialization needed + } + setNewSessionTitle(''); setNewSessionDescription(''); setSelectedCategoryId('none'); setSelectedTaskId('none'); onSessionUpdate(); - toast.success('Timer started!'); + toast.success(`Timer started${timerMode === 'pomodoro' ? ' - Focus time!' : ''}`); } catch (error) { console.error('Error starting timer:', error); toast.error('Failed to start timer'); @@ -677,6 +1084,8 @@ export function TimerControls({ } }; + + // Start from template const startFromTemplate = async (template: SessionTemplate) => { setNewSessionTitle(template.title); @@ -1023,8 +1432,257 @@ export function TimerControls({ sessionMode, ]); + + return ( <> + {/* Custom Timer Advanced Settings Dialog */} + + + + ⚙️ Advanced Custom Timer Settings + + Fine-tune your custom timer experience with advanced options + + +
+
+

Break Reminders

+
+ + setCustomTimerSettings(prev => ({ + ...prev, + enableBreakReminders: e.target.checked + }))} + /> +
+

+ Get reminded to take eye breaks (20-20-20 rule) and movement breaks during long sessions +

+
+ +
+

Audio & Notifications

+
+ + setCustomTimerSettings(prev => ({ + ...prev, + playCompletionSound: e.target.checked + }))} + /> +
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + showNotifications: e.target.checked + }))} + /> +
+
+ +
+

Automation

+
+ + setCustomTimerSettings(prev => ({ + ...prev, + autoRestart: e.target.checked + }))} + /> +
+

+ Automatically restart the timer when it completes (great for repetitive tasks) +

+
+ +
+ + +
+
+
+
+ + {/* Pomodoro Settings Dialog */} + + + + 🍅 Pomodoro Settings + + Customize your focus and break durations + + +
+
+
+ + setPomodoroSettings(prev => ({ + ...prev, + focusTime: parseInt(e.target.value) || 25 + }))} + /> +
+
+ + setPomodoroSettings(prev => ({ + ...prev, + shortBreakTime: parseInt(e.target.value) || 5 + }))} + /> +
+
+ + setPomodoroSettings(prev => ({ + ...prev, + longBreakTime: parseInt(e.target.value) || 15 + }))} + /> +
+
+ +
+ + setPomodoroSettings(prev => ({ + ...prev, + sessionsUntilLongBreak: parseInt(e.target.value) || 4 + }))} + /> +
+ +
+
+ + setPomodoroSettings(prev => ({ + ...prev, + autoStartBreaks: e.target.checked + }))} + /> +
+ +
+ + setPomodoroSettings(prev => ({ + ...prev, + autoStartFocus: e.target.checked + }))} + /> +
+ +
+ + setPomodoroSettings(prev => ({ + ...prev, + enableNotifications: e.target.checked + }))} + /> +
+ +
+ + setPomodoroSettings(prev => ({ + ...prev, + enable2020Rule: e.target.checked + }))} + /> +
+ +
+ + setPomodoroSettings(prev => ({ + ...prev, + enableMovementReminder: e.target.checked + }))} + /> +
+
+ +
+ + +
+
+
+
- - - Time Tracker + +
+ + Time Tracker +
+ {/* Timer Mode Selector */} +
+ + {timerMode === 'pomodoro' && ( + + )} + {timerMode === 'custom' && ( + + )} +
- Track your time with detailed analytics + + {timerMode === 'stopwatch' && 'Track your time with detailed analytics'} + {timerMode === 'pomodoro' && `Focus for ${pomodoroSettings.focusTime}min, break for ${pomodoroSettings.shortBreakTime}min`} + {timerMode === 'custom' && `Custom timer`} +
⌘/Ctrl + Enter @@ -1061,20 +1760,133 @@ export function TimerControls({ {currentSession ? (
{/* Enhanced Active Session Display */} -
-
+
+
-
- {formatTime(elapsedTime)} +
+ {(timerMode === 'pomodoro' || (timerMode === 'custom' && customTimerSettings.type !== 'stopwatch')) ? formatTime(countdownState.remainingTime) : formatTime(elapsedTime)}
-
-
- Started at{' '} - {new Date(currentSession.start_time).toLocaleTimeString()} - {elapsedTime > 1800 && ( - - {elapsedTime > 3600 ? 'Long session!' : 'Deep work'} + + {/* Pomodoro Progress Indicator */} + {timerMode === 'pomodoro' && ( +
+
+ + {countdownState.sessionType === 'focus' ? `🍅 Focus ${countdownState.pomodoroSession}` : + countdownState.sessionType === 'short-break' ? '☕ Short Break' : + '🌟 Long Break'} + +
+ + {/* Progress bar for current session */} +
+
+
+ + {/* Pomodoro sessions indicator */} + {countdownState.sessionType === 'focus' && ( +
+ {Array.from({ length: pomodoroSettings.sessionsUntilLongBreak }, (_, i) => ( +
+ ))} +
+ )} +
+ )} + +
+
+ {timerMode === 'pomodoro' ? ( + + {countdownState.remainingTime > 0 ? + `${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')} remaining` : + 'Session complete!' + } + + ) : timerMode === 'custom' ? ( + + {customTimerSettings.type === 'countdown' ? ( + countdownState.remainingTime > 0 ? + `⏲️ ${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')} remaining` : + 'Countdown complete!' + ) : customTimerSettings.type === 'pomodoro-custom' ? ( + countdownState.remainingTime > 0 ? + `🍅 ${countdownState.sessionType === 'focus' ? 'Focus' : 'Break'} ${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')}` : + 'Session complete!' + ) : ( + customTimerSettings.duration > 0 ? + `⏱️ Stopwatch (target: ${customTimerSettings.duration}min)` : + '⏱️ Open-ended timing' + )} + ) : ( + <> + Started at{' '} + {new Date(currentSession.start_time).toLocaleTimeString()} + {elapsedTime > 1800 && ( + + {elapsedTime > 3600 ? 'Long session!' : 'Deep work'} + + )} + )}
@@ -2222,6 +3034,196 @@ export function TimerControls({ />
+ {/* Enhanced Custom Timer Configuration */} + {timerMode === 'custom' && ( +
+
+ + +
+ + {/* Timer Type Selection */} +
+ + +

+ {customTimerSettings.type === 'countdown' && 'Count down from a set duration'} + {customTimerSettings.type === 'stopwatch' && 'Count up with optional target time'} + {customTimerSettings.type === 'pomodoro-custom' && 'Custom focus/break intervals'} +

+
+ + {/* Duration Input for Countdown */} + {customTimerSettings.type === 'countdown' && ( +
+ + setCustomTimerSettings(prev => ({ + ...prev, + duration: parseInt(e.target.value) || 25 + }))} + className="mt-1" + placeholder="25" + /> +

+ Timer will count down from {customTimerSettings.duration} minute{customTimerSettings.duration !== 1 ? 's' : ''} +

+
+ )} + + {/* Target Time for Stopwatch */} + {customTimerSettings.type === 'stopwatch' && ( +
+ + setCustomTimerSettings(prev => ({ + ...prev, + duration: parseInt(e.target.value) || 0 + }))} + className="mt-1" + placeholder="0 = no target" + /> +

+ {customTimerSettings.duration > 0 + ? `Get notified when you reach ${customTimerSettings.duration} minutes` + : 'Open-ended timing (no target time)' + } +

+
+ )} + + {/* Custom Pomodoro Settings */} + {customTimerSettings.type === 'pomodoro-custom' && ( +
+
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + focusTime: parseInt(e.target.value) || 25 + }))} + className="mt-1" + /> +
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + breakTime: parseInt(e.target.value) || 5 + }))} + className="mt-1" + /> +
+
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + cycles: parseInt(e.target.value) || 4 + }))} + className="mt-1" + /> +

+ {customTimerSettings.focusTime}min focus + {customTimerSettings.breakTime}min break × {customTimerSettings.cycles} cycles +

+
+
+ )} + + {/* Quick Options */} +
+
+ setCustomTimerSettings(prev => ({ + ...prev, + autoRestart: e.target.checked + }))} + className="rounded" + /> + +
+ +
+ setCustomTimerSettings(prev => ({ + ...prev, + showNotifications: e.target.checked + }))} + className="rounded" + /> + +
+ +
+ setCustomTimerSettings(prev => ({ + ...prev, + playCompletionSound: e.target.checked + }))} + className="rounded" + /> + +
+
+
+ )} +
setCustomTimerSettings(prev => ({ + ...prev, + targetDuration: parseInt(e.target.value) || 60 + }))} + /> +
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + intervalFrequency: parseInt(e.target.value) || 25 + }))} + /> +
+
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + intervalBreakDuration: parseInt(e.target.value) || 5 + }))} + /> +
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + autoStopAtTarget: e.target.checked + }))} + /> +
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + enableTargetNotification: e.target.checked + }))} + /> +
+
+ )} + + {customTimerSettings.type === 'traditional-countdown' && ( +
+

Traditional Countdown Settings

+
+ + setCustomTimerSettings(prev => ({ + ...prev, + countdownDuration: parseInt(e.target.value) || 25 + }))} + /> +
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + autoRestart: e.target.checked + }))} + /> +
+
+ + setCustomTimerSettings(prev => ({ + ...prev, + showTimeRemaining: e.target.checked + }))} + /> +
+
+ )} +

Break Reminders

@@ -1491,20 +1670,20 @@ export function TimerControls({
-

Automation

+

Motivation & Feedback

- + setCustomTimerSettings(prev => ({ ...prev, - autoRestart: e.target.checked + enableMotivationalMessages: e.target.checked }))} />

- Automatically restart the timer when it completes (great for repetitive tasks) + Receive encouraging messages and productivity tips during your sessions

@@ -1512,15 +1691,27 @@ export function TimerControls({
+ + {/* Stopwatch Settings Dialog */} + + + + ⏱️ Stopwatch Settings + + Customize your stopwatch experience and productivity features + + +
+
+
+ + setStopwatchSettings(prev => ({ + ...prev, + enableBreakReminders: e.target.checked + }))} + /> +
+ +
+ + setStopwatchSettings(prev => ({ + ...prev, + enable2020Rule: e.target.checked + }))} + /> +
+ +
+ + setStopwatchSettings(prev => ({ + ...prev, + enableMovementReminder: e.target.checked + }))} + /> +
+ +
+ + setStopwatchSettings(prev => ({ + ...prev, + showProductivityInsights: e.target.checked + }))} + /> +
+ +
+ + setStopwatchSettings(prev => ({ + ...prev, + enableNotifications: e.target.checked + }))} + /> +
+ +
+ + setStopwatchSettings(prev => ({ + ...prev, + enableSessionMilestones: e.target.checked + }))} + /> +
+ +
+ + setStopwatchSettings(prev => ({ + ...prev, + playCompletionSound: e.target.checked + }))} + /> +
+
+ +
+ + +
+
+
+
⏲️ Custom + {timerMode === 'stopwatch' && ( + + )} {timerMode === 'pomodoro' && (
+ + {/* Custom Timer Configuration - Prominently Displayed */} + {timerMode === 'custom' && ( +
+
+
+
+ {customTimerSettings.type === 'enhanced-stopwatch' ? '⏱️' : '⏲️'} +
+
+

+ {customTimerSettings.type === 'enhanced-stopwatch' ? 'Enhanced Stopwatch' : 'Traditional Countdown'} +

+

+ {customTimerSettings.type === 'enhanced-stopwatch' + ? 'Target-based with interval breaks' + : 'Simple countdown timer' + } +

+
+
+
+ + {/* Quick Timer Type Switcher */} +
+ + +
+ + {/* Essential Settings Only - Interval Breaks for Enhanced Stopwatch */} + {customTimerSettings.type === 'enhanced-stopwatch' && ( +
+
+ Interval Breaks: +
+ setCustomTimerSettings(prev => ({ + ...prev, + enableIntervalBreaks: e.target.checked + }))} + className="h-3 w-3 rounded" + /> + {customTimerSettings.enableIntervalBreaks && ( + + every {customTimerSettings.intervalFrequency}min + + )} +
+
+
+ )} +
+ )} + {currentSession ? (
@@ -1791,7 +2183,7 @@ export function TimerControls({ ? "text-purple-600 dark:text-purple-400" : "text-red-600 dark:text-red-400" )}> - {(timerMode === 'pomodoro' || (timerMode === 'custom' && customTimerSettings.type !== 'stopwatch')) ? formatTime(countdownState.remainingTime) : formatTime(elapsedTime)} + {(timerMode === 'pomodoro' || (timerMode === 'custom' && customTimerSettings.type === 'traditional-countdown')) ? formatTime(countdownState.remainingTime) : formatTime(elapsedTime)}
{/* Pomodoro Progress Indicator */} @@ -1863,18 +2255,16 @@ export function TimerControls({ ) : timerMode === 'custom' ? ( - {customTimerSettings.type === 'countdown' ? ( + {customTimerSettings.type === 'traditional-countdown' ? ( countdownState.remainingTime > 0 ? `⏲️ ${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')} remaining` : 'Countdown complete!' - ) : customTimerSettings.type === 'pomodoro-custom' ? ( - countdownState.remainingTime > 0 ? - `🍅 ${countdownState.sessionType === 'focus' ? 'Focus' : 'Break'} ${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')}` : - 'Session complete!' + ) : customTimerSettings.type === 'enhanced-stopwatch' ? ( + hasReachedTarget ? + `🎯 Target achieved! (${customTimerSettings.targetDuration || 60}min)` : + `⏱️ Enhanced Stopwatch ${customTimerSettings.targetDuration ? `(target: ${customTimerSettings.targetDuration}min)` : ''}` ) : ( - customTimerSettings.duration > 0 ? - `⏱️ Stopwatch (target: ${customTimerSettings.duration}min)` : - '⏱️ Open-ended timing' + '⏱️ Custom Timer' )} ) : ( @@ -3034,195 +3424,7 @@ export function TimerControls({ />
- {/* Enhanced Custom Timer Configuration */} - {timerMode === 'custom' && ( -
-
- - -
- - {/* Timer Type Selection */} -
- - -

- {customTimerSettings.type === 'countdown' && 'Count down from a set duration'} - {customTimerSettings.type === 'stopwatch' && 'Count up with optional target time'} - {customTimerSettings.type === 'pomodoro-custom' && 'Custom focus/break intervals'} -

-
- - {/* Duration Input for Countdown */} - {customTimerSettings.type === 'countdown' && ( -
- - setCustomTimerSettings(prev => ({ - ...prev, - duration: parseInt(e.target.value) || 25 - }))} - className="mt-1" - placeholder="25" - /> -

- Timer will count down from {customTimerSettings.duration} minute{customTimerSettings.duration !== 1 ? 's' : ''} -

-
- )} - - {/* Target Time for Stopwatch */} - {customTimerSettings.type === 'stopwatch' && ( -
- - setCustomTimerSettings(prev => ({ - ...prev, - duration: parseInt(e.target.value) || 0 - }))} - className="mt-1" - placeholder="0 = no target" - /> -

- {customTimerSettings.duration > 0 - ? `Get notified when you reach ${customTimerSettings.duration} minutes` - : 'Open-ended timing (no target time)' - } -

-
- )} - {/* Custom Pomodoro Settings */} - {customTimerSettings.type === 'pomodoro-custom' && ( -
-
-
- - setCustomTimerSettings(prev => ({ - ...prev, - focusTime: parseInt(e.target.value) || 25 - }))} - className="mt-1" - /> -
-
- - setCustomTimerSettings(prev => ({ - ...prev, - breakTime: parseInt(e.target.value) || 5 - }))} - className="mt-1" - /> -
-
-
- - setCustomTimerSettings(prev => ({ - ...prev, - cycles: parseInt(e.target.value) || 4 - }))} - className="mt-1" - /> -

- {customTimerSettings.focusTime}min focus + {customTimerSettings.breakTime}min break × {customTimerSettings.cycles} cycles -

-
-
- )} - - {/* Quick Options */} -
-
- setCustomTimerSettings(prev => ({ - ...prev, - autoRestart: e.target.checked - }))} - className="rounded" - /> - -
- -
- setCustomTimerSettings(prev => ({ - ...prev, - showNotifications: e.target.checked - }))} - className="rounded" - /> - -
- -
- setCustomTimerSettings(prev => ({ - ...prev, - playCompletionSound: e.target.checked - }))} - className="rounded" - /> - -
-
-
- )}
From b86e3533c12ff7503104f8d54428f4f7d03d70f5 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 10:34:25 +0800 Subject: [PATCH 20/35] feat(time-tracker): implement session protection - Add comprehensive session protection to prevent mode switching during active timers - Implement separate break time tracking for each timer mode (stopwatch, Pomodoro, custom) - Add visual lock indicators and error messages when attempting restricted actions - Create mode-specific break state persistence with localStorage support - Enhance session restoration to maintain state when switching between timer modes - Add session protection for timer mode selector, settings access, and custom type switching - Implement mode-aware break reminders with separate counters per timer type - Prevent data corruption and integrity errors through isolated state management - Add comprehensive validation across all user interaction points - Maintain backward compatibility while enhancing data safety and user experience --- .../components/timer-controls.tsx | 510 +++++++++++++++--- 1 file changed, 449 insertions(+), 61 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index ce9366402..0a8cf4489 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -161,6 +161,37 @@ interface StopwatchSettings { playCompletionSound?: boolean; } +// Separate break time tracking for each timer mode +interface BreakTimeState { + lastEyeBreakTime: number; + lastMovementBreakTime: number; + lastIntervalBreakTime: number; + intervalBreaksCount: number; +} + +// Session state for each timer mode to prevent data corruption +interface TimerModeSession { + mode: TimerMode; + sessionId: string | null; + startTime: Date | null; + elapsedTime: number; + breakTimeState: BreakTimeState; + // Mode-specific data + pomodoroState?: CountdownState; + customTimerState?: { + hasReachedTarget: boolean; + targetProgress: number; + }; +} + +// Active session protection +interface SessionProtection { + isActive: boolean; + currentMode: TimerMode; + canSwitchModes: boolean; + canModifySettings: boolean; +} + // Default Pomodoro settings const DEFAULT_POMODORO_SETTINGS: PomodoroSettings = { focusTime: 25, @@ -253,27 +284,63 @@ export function TimerControls({ showNotifications: true, enableMotivationalMessages: true, }); - const [lastNotificationTime, setLastNotificationTime] = useState(0); const [showPomodoroSettings, setShowPomodoroSettings] = useState(false); const [showCustomSettings, setShowCustomSettings] = useState(false); const [showStopwatchSettings, setShowStopwatchSettings] = useState(false); - // Enhanced stopwatch state - const [lastIntervalBreakTime, setLastIntervalBreakTime] = useState(Date.now()); - const [intervalBreaksCount, setIntervalBreaksCount] = useState(0); + // Enhanced stopwatch state (legacy - kept for hasReachedTarget) const [hasReachedTarget, setHasReachedTarget] = useState(false); // Stopwatch settings state const [stopwatchSettings, setStopwatchSettings] = useState(DEFAULT_STOPWATCH_SETTINGS); - // Break reminders state - const [lastEyeBreakTime, setLastEyeBreakTime] = useState(Date.now()); - const [lastMovementBreakTime, setLastMovementBreakTime] = useState(Date.now()); + // Session protection state + const [sessionProtection, setSessionProtection] = useState({ + isActive: false, + currentMode: 'stopwatch', + canSwitchModes: true, + canModifySettings: true, + }); + + // Separate break time tracking for each timer mode + const [stopwatchBreakState, setStopwatchBreakState] = useState({ + lastEyeBreakTime: Date.now(), + lastMovementBreakTime: Date.now(), + lastIntervalBreakTime: Date.now(), + intervalBreaksCount: 0, + }); + + const [pomodoroBreakState, setPomodoroBreakState] = useState({ + lastEyeBreakTime: Date.now(), + lastMovementBreakTime: Date.now(), + lastIntervalBreakTime: Date.now(), + intervalBreaksCount: 0, + }); + + const [customBreakState, setCustomBreakState] = useState({ + lastEyeBreakTime: Date.now(), + lastMovementBreakTime: Date.now(), + lastIntervalBreakTime: Date.now(), + intervalBreaksCount: 0, + }); + + // Timer mode sessions for persistence + const [timerModeSessions, setTimerModeSessions] = useState<{ + [key in TimerMode]: TimerModeSession | null; + }>({ + stopwatch: null, + pomodoro: null, + custom: null, + }); + + // Legacy break reminders state (kept for lastNotificationTime only) + const [lastNotificationTime, setLastNotificationTime] = useState(0); // localStorage keys for persistence const PAUSED_SESSION_KEY = `paused-session-${wsId}-${currentUserId || 'user'}`; const PAUSED_ELAPSED_KEY = `paused-elapsed-${wsId}-${currentUserId || 'user'}`; const PAUSE_TIME_KEY = `pause-time-${wsId}-${currentUserId || 'user'}`; + const TIMER_MODE_SESSIONS_KEY = `timer-mode-sessions-${wsId}-${currentUserId || 'user'}`; // Helper functions for localStorage persistence const savePausedSessionToStorage = useCallback((session: SessionWithRelations, elapsed: number, pauseTime: Date) => { @@ -326,6 +393,155 @@ export function TimerControls({ } }, [PAUSED_SESSION_KEY, PAUSED_ELAPSED_KEY, PAUSE_TIME_KEY]); + // Session protection utilities + const updateSessionProtection = useCallback((isActive: boolean, mode: TimerMode) => { + setSessionProtection({ + isActive, + currentMode: mode, + canSwitchModes: !isActive, + canModifySettings: !isActive, + }); + }, []); + + const getCurrentBreakState = useCallback(() => { + switch (timerMode) { + case 'stopwatch': + return stopwatchBreakState; + case 'pomodoro': + return pomodoroBreakState; + case 'custom': + return customBreakState; + default: + return stopwatchBreakState; + } + }, [timerMode, stopwatchBreakState, pomodoroBreakState, customBreakState]); + + const updateCurrentBreakState = useCallback((updates: Partial) => { + switch (timerMode) { + case 'stopwatch': + setStopwatchBreakState(prev => ({ ...prev, ...updates })); + break; + case 'pomodoro': + setPomodoroBreakState(prev => ({ ...prev, ...updates })); + break; + case 'custom': + setCustomBreakState(prev => ({ ...prev, ...updates })); + break; + } + }, [timerMode]); + + // Safe timer mode switching with validation + const handleTimerModeChange = useCallback((newMode: TimerMode) => { + // Prevent mode switching if session is active + if (sessionProtection.isActive) { + toast.error('Cannot switch timer modes during an active session', { + description: 'Please stop or pause your current timer first.', + duration: 4000, + }); + return; + } + + // Save current mode session state if exists + if (currentSession) { + const currentBreakState = getCurrentBreakState(); + const sessionData: TimerModeSession = { + mode: timerMode, + sessionId: currentSession.id, + startTime: currentSession.start_time ? new Date(currentSession.start_time) : null, + elapsedTime: elapsedTime, + breakTimeState: currentBreakState, + pomodoroState: timerMode === 'pomodoro' ? countdownState : undefined, + customTimerState: timerMode === 'custom' ? { + hasReachedTarget, + targetProgress: elapsedTime / ((customTimerSettings.targetDuration || 60) * 60), + } : undefined, + }; + + setTimerModeSessions(prev => ({ + ...prev, + [timerMode]: sessionData, + })); + } + + // Switch to new mode + setTimerMode(newMode); + + // Restore previous session for new mode if exists + const previousSession = timerModeSessions[newMode]; + if (previousSession && previousSession.sessionId) { + // Restore the session state + setElapsedTime(previousSession.elapsedTime); + + // Restore break state for new mode + switch (newMode) { + case 'stopwatch': + setStopwatchBreakState(previousSession.breakTimeState); + break; + case 'pomodoro': + setPomodoroBreakState(previousSession.breakTimeState); + if (previousSession.pomodoroState) { + setCountdownState(previousSession.pomodoroState); + } + break; + case 'custom': + setCustomBreakState(previousSession.breakTimeState); + if (previousSession.customTimerState) { + setHasReachedTarget(previousSession.customTimerState.hasReachedTarget); + } + break; + } + + toast.success(`Switched to ${newMode} mode`, { + description: `Restored previous session with ${formatDuration(previousSession.elapsedTime)} tracked`, + duration: 3000, + }); + } else { + toast.success(`Switched to ${newMode} mode`, { + description: 'Ready to start a new session', + duration: 2000, + }); + } + }, [ + sessionProtection.isActive, + currentSession, + timerMode, + elapsedTime, + getCurrentBreakState, + countdownState, + hasReachedTarget, + customTimerSettings.targetDuration, + timerModeSessions, + setElapsedTime, + formatDuration, + ]); + + // Persistence for timer mode sessions + const saveTimerModeSessionsToStorage = useCallback(() => { + if (typeof window !== 'undefined') { + try { + localStorage.setItem(TIMER_MODE_SESSIONS_KEY, JSON.stringify(timerModeSessions)); + } catch (error) { + console.warn('Failed to save timer mode sessions to localStorage:', error); + } + } + }, [TIMER_MODE_SESSIONS_KEY, timerModeSessions]); + + const loadTimerModeSessionsFromStorage = useCallback(() => { + if (typeof window !== 'undefined') { + try { + const sessionsData = localStorage.getItem(TIMER_MODE_SESSIONS_KEY); + if (sessionsData) { + const sessions = JSON.parse(sessionsData); + setTimerModeSessions(sessions); + return sessions; + } + } catch (error) { + console.warn('Failed to load timer mode sessions from localStorage:', error); + } + } + return null; + }, [TIMER_MODE_SESSIONS_KEY]); + // Load paused session on component mount useEffect(() => { const pausedData = loadPausedSessionFromStorage(); @@ -338,7 +554,21 @@ export function TimerControls({ duration: 5000, }); } - }, [loadPausedSessionFromStorage, formatDuration]); + + // Load timer mode sessions + loadTimerModeSessionsFromStorage(); + }, [loadPausedSessionFromStorage, loadTimerModeSessionsFromStorage, formatDuration]); + + // Update session protection when timer state changes + useEffect(() => { + const isActive = isRunning || !!currentSession || !!pausedSession; + updateSessionProtection(isActive, timerMode); + }, [isRunning, currentSession, pausedSession, timerMode, updateSessionProtection]); + + // Save timer mode sessions when they change + useEffect(() => { + saveTimerModeSessionsToStorage(); + }, [timerModeSessions, saveTimerModeSessionsToStorage]); // Cleanup paused session if user changes or component unmounts useEffect(() => { @@ -544,42 +774,85 @@ export function TimerControls({ } }, [countdownState, pomodoroSettings, showNotification, startPomodoroSession, setIsRunning]); - // Break reminder logic + // Break reminder logic - mode-aware const checkBreakReminders = useCallback(() => { const now = Date.now(); + const currentBreakState = getCurrentBreakState(); + + // Get settings based on current timer mode + let enableEyeBreaks = false; + let enableMovementBreaks = false; + + switch (timerMode) { + case 'stopwatch': + enableEyeBreaks = stopwatchSettings.enable2020Rule || false; + enableMovementBreaks = stopwatchSettings.enableMovementReminder || false; + break; + case 'pomodoro': + enableEyeBreaks = pomodoroSettings.enable2020Rule; + enableMovementBreaks = pomodoroSettings.enableMovementReminder; + break; + case 'custom': + enableEyeBreaks = customTimerSettings.enableBreakReminders || false; + enableMovementBreaks = customTimerSettings.enableBreakReminders || false; + break; + } // 20-20-20 rule: Every 20 minutes, look at something 20 feet away for 20 seconds - if (pomodoroSettings.enable2020Rule && - now - lastEyeBreakTime > 20 * 60 * 1000 && // 20 minutes + if (enableEyeBreaks && + now - currentBreakState.lastEyeBreakTime > 20 * 60 * 1000 && // 20 minutes isRunning && - countdownState.sessionType === 'focus') { + (timerMode === 'stopwatch' || countdownState.sessionType === 'focus')) { if (now - lastNotificationTime > 5 * 60 * 1000) { // Don't spam notifications showNotification( 'Eye Break Time! 👁️', 'Look at something 20 feet away for 20 seconds' ); - setLastEyeBreakTime(now); + updateCurrentBreakState({ lastEyeBreakTime: now }); setLastNotificationTime(now); } } // Movement reminder: Every 60 minutes - if (pomodoroSettings.enableMovementReminder && - now - lastMovementBreakTime > 60 * 60 * 1000 && // 60 minutes + if (enableMovementBreaks && + now - currentBreakState.lastMovementBreakTime > 60 * 60 * 1000 && // 60 minutes isRunning && - countdownState.sessionType === 'focus') { + (timerMode === 'stopwatch' || countdownState.sessionType === 'focus')) { if (now - lastNotificationTime > 5 * 60 * 1000) { showNotification( 'Movement Break! 🚶', 'Time to stand up and stretch for a few minutes' ); - setLastMovementBreakTime(now); + updateCurrentBreakState({ lastMovementBreakTime: now }); setLastNotificationTime(now); } } - }, [pomodoroSettings, lastEyeBreakTime, lastMovementBreakTime, lastNotificationTime, isRunning, countdownState.sessionType, showNotification]); + + // Session milestones for stopwatch mode + if (timerMode === 'stopwatch' && stopwatchSettings.enableSessionMilestones && isRunning) { + const elapsedMinutes = Math.floor(elapsedTime / 60); + const milestones = [30, 60, 120, 180, 240]; // 30min, 1hr, 2hr, 3hr, 4hr + + for (const milestone of milestones) { + if (elapsedMinutes === milestone && now - lastNotificationTime > 5 * 60 * 1000) { + const hours = Math.floor(milestone / 60); + const mins = milestone % 60; + const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; + + showNotification( + `🎯 Session Milestone! (${timeStr})`, + stopwatchSettings.showProductivityInsights + ? `Great focus! You've been working for ${timeStr}. Consider taking a break soon.` + : `You've reached ${timeStr} of focused work.` + ); + setLastNotificationTime(now); + break; + } + } + } + }, [timerMode, getCurrentBreakState, updateCurrentBreakState, stopwatchSettings, pomodoroSettings, customTimerSettings, lastNotificationTime, isRunning, countdownState.sessionType, elapsedTime, showNotification]); @@ -646,15 +919,20 @@ export function TimerControls({ // Check for interval breaks if (customTimerSettings.enableIntervalBreaks) { const intervalFreq = customTimerSettings.intervalFrequency || 25; - const timeSinceLastBreak = Math.floor((currentTime - lastIntervalBreakTime) / (1000 * 60)); + const currentBreakState = getCurrentBreakState(); + const timeSinceLastBreak = Math.floor((currentTime - currentBreakState.lastIntervalBreakTime) / (1000 * 60)); if (timeSinceLastBreak >= intervalFreq) { const breakDuration = customTimerSettings.intervalBreakDuration || 5; - setIntervalBreaksCount(prev => prev + 1); - setLastIntervalBreakTime(currentTime); + const newBreaksCount = currentBreakState.intervalBreaksCount + 1; + + updateCurrentBreakState({ + intervalBreaksCount: newBreaksCount, + lastIntervalBreakTime: currentTime + }); showNotification( - `🕒 Interval Break Time! (${intervalBreaksCount + 1})`, + `🕒 Interval Break Time! (${newBreaksCount})`, `Take a ${breakDuration}-minute break - you've been working for ${intervalFreq} minutes`, [{ title: 'Got it!', @@ -704,7 +982,7 @@ export function TimerControls({ return () => clearInterval(interval); } - }, [timerMode, customTimerSettings, isRunning, elapsedTime, lastIntervalBreakTime, intervalBreaksCount, hasReachedTarget, showNotification, playNotificationSound, checkBreakReminders]); + }, [timerMode, customTimerSettings, isRunning, elapsedTime, getCurrentBreakState, updateCurrentBreakState, hasReachedTarget, showNotification, playNotificationSound, checkBreakReminders]); // Fetch boards with lists const fetchBoards = useCallback(async () => { @@ -969,6 +1247,9 @@ export function TimerControls({ setIsRunning(true); setElapsedTime(0); + // Update session protection - timer is now active + updateSessionProtection(true, timerMode); + // Initialize timer mode specific settings if (timerMode === 'pomodoro') { // Start first Pomodoro focus session @@ -985,8 +1266,10 @@ export function TimerControls({ })); } else if (customTimerSettings.type === 'enhanced-stopwatch') { // Reset enhanced stopwatch tracking - setLastIntervalBreakTime(Date.now()); - setIntervalBreaksCount(0); + updateCurrentBreakState({ + lastIntervalBreakTime: Date.now(), + intervalBreaksCount: 0, + }); setHasReachedTarget(false); // No countdown for enhanced stopwatch - it counts up @@ -1041,6 +1324,9 @@ export function TimerControls({ setPausedElapsedTime(0); setPauseStartTime(null); + // Clear session protection - timer is no longer active + updateSessionProtection(false, timerMode); + // Clear from localStorage since session is completed clearPausedSessionFromStorage(); @@ -1125,6 +1411,9 @@ export function TimerControls({ setElapsedTime(pausedElapsedTime); setIsRunning(true); + // Restore session protection - timer is active again + updateSessionProtection(true, timerMode); + // Clear paused state setPausedSession(null); setPausedElapsedTime(0); @@ -2004,23 +2293,54 @@ export function TimerControls({
{/* Timer Mode Selector */}
- handleTimerModeChange(value)} + disabled={sessionProtection.isActive} + > + - ⏱️ Stopwatch - 🍅 Pomodoro - ⏲️ Custom + + ⏱️ Stopwatch + + + 🍅 Pomodoro + + + ⏲️ Custom + + {sessionProtection.isActive && ( +
+ 🔒 Active Session +
+ )} {timerMode === 'stopwatch' && ( @@ -2029,9 +2349,22 @@ export function TimerControls({ @@ -2040,9 +2373,22 @@ export function TimerControls({ @@ -2106,16 +2452,44 @@ export function TimerControls({ @@ -2124,25 +2498,39 @@ export function TimerControls({ {/* Essential Settings Only - Interval Breaks for Enhanced Stopwatch */} {customTimerSettings.type === 'enhanced-stopwatch' && (
-
- Interval Breaks: -
- setCustomTimerSettings(prev => ({ - ...prev, - enableIntervalBreaks: e.target.checked - }))} - className="h-3 w-3 rounded" - /> - {customTimerSettings.enableIntervalBreaks && ( - - every {customTimerSettings.intervalFrequency}min - - )} +
+ Interval Breaks: +
+ { + if (sessionProtection.isActive) { + toast.error('Cannot modify break settings during active session', { + description: 'Please stop or pause your timer first.', + duration: 3000, + }); + return; + } + setCustomTimerSettings(prev => ({ + ...prev, + enableIntervalBreaks: e.target.checked + })); + }} + className={cn( + "h-3 w-3 rounded", + sessionProtection.isActive && "opacity-50 cursor-not-allowed" + )} + disabled={sessionProtection.isActive} + title={sessionProtection.isActive ? "Settings locked during active session" : "Enable interval breaks"} + /> + {customTimerSettings.enableIntervalBreaks && ( + + every {customTimerSettings.intervalFrequency}min + + )} +
-
)}
From c30b9f36e5f90a17a285efc478680075746ef488 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 10:53:13 +0800 Subject: [PATCH 21/35] fix(time-tracker): implemented limited list for session history --- .../components/session-history.tsx | 385 ++++++++++-------- 1 file changed, 212 insertions(+), 173 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 2e725b9cc..65b1c8fa2 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -243,6 +243,7 @@ const StackedSessionItem: FC<{ getSessionProductivityType, }) => { const [isExpanded, setIsExpanded] = useState(false); + const [showAllSessions, setShowAllSessions] = useState(false); const userTimezone = dayjs.tz.guess(); const firstStartTime = dayjs .utc(stackedSession.firstStartTime) @@ -261,6 +262,13 @@ const StackedSessionItem: FC<{ const productivityType = getSessionProductivityType(latestSession); + // Limit how many sessions to show initially + const INITIAL_SESSION_LIMIT = 3; + const hasMoreSessions = stackedSession.sessions.length > INITIAL_SESSION_LIMIT; + const visibleSessions = showAllSessions + ? stackedSession.sessions + : stackedSession.sessions.slice(0, INITIAL_SESSION_LIMIT); + const getProductivityIcon = (type: string) => { switch (type) { case 'deep-work': return '🧠'; @@ -562,195 +570,226 @@ const StackedSessionItem: FC<{ running
-
- {stackedSession.sessions.map((session, index) => { - const sessionStart = dayjs - .utc(session.start_time) - .tz(userTimezone); - const sessionEnd = session.end_time - ? dayjs.utc(session.end_time).tz(userTimezone) - : null; - - // Calculate gap from previous session - const prevSession = - index > 0 ? stackedSession.sessions[index - 1] : null; - const gapInSeconds = - prevSession && prevSession.end_time - ? sessionStart.diff( - dayjs.utc(prevSession.end_time).tz(userTimezone), - 'seconds' - ) +
+ {/* Sessions container with scroll for many sessions */} +
6 && showAllSessions && "max-h-96 overflow-y-auto pr-2" + )}> + {visibleSessions.map((session, index) => { + const sessionStart = dayjs + .utc(session.start_time) + .tz(userTimezone); + const sessionEnd = session.end_time + ? dayjs.utc(session.end_time).tz(userTimezone) : null; - // Format gap duration based on length - const formatGap = (seconds: number) => { - if (seconds < 60) return `${seconds}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; - }; - - // Determine gap type for styling - const getGapType = (seconds: number) => { - if (seconds < 60) return 'minimal'; // Less than 1 minute - if (seconds < 900) return 'short'; // Less than 15 minutes - return 'long'; // 15+ minutes - }; - - // Handle edge cases for gap display - const shouldShowGap = - gapInSeconds !== null && - gapInSeconds > 30 && - gapInSeconds < 86400; // Only show gaps between 30 seconds and 24 hours - const gapType = - gapInSeconds && shouldShowGap - ? getGapType(gapInSeconds) - : null; - - // Detect overlapping sessions - const isOverlapping = - gapInSeconds !== null && gapInSeconds < 0; - - return ( -
- {/* Show overlap warning */} - {isOverlapping && ( -
-
-
- - Overlapping session - -
-
-
- )} - - {/* Show gap indicator based on duration */} - {shouldShowGap && gapInSeconds && ( -
- {gapType === 'minimal' ? ( - // Minimal gap - just small dots -
-
-
-
-
- ) : gapType === 'short' ? ( - // Short break - simple line with time -
-
- - {formatGap(gapInSeconds)} - -
-
- ) : ( - // Long break - prominent break indicator -
-
+ // Calculate gap from previous session (considering the full session list for gaps) + const actualIndex = showAllSessions ? index : index; + const prevSession = actualIndex > 0 ? + (showAllSessions ? stackedSession.sessions[index - 1] : stackedSession.sessions[index - 1]) : null; + const gapInSeconds = + prevSession && prevSession.end_time + ? sessionStart.diff( + dayjs.utc(prevSession.end_time).tz(userTimezone), + 'seconds' + ) + : null; + + // Format gap duration based on length + const formatGap = (seconds: number) => { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + }; + + // Determine gap type for styling + const getGapType = (seconds: number) => { + if (seconds < 60) return 'minimal'; // Less than 1 minute + if (seconds < 900) return 'short'; // Less than 15 minutes + return 'long'; // 15+ minutes + }; + + // Handle edge cases for gap display + const shouldShowGap = + gapInSeconds !== null && + gapInSeconds > 30 && + gapInSeconds < 86400; // Only show gaps between 30 seconds and 24 hours + const gapType = + gapInSeconds && shouldShowGap + ? getGapType(gapInSeconds) + : null; + + // Detect overlapping sessions + const isOverlapping = + gapInSeconds !== null && gapInSeconds < 0; + + return ( +
+ {/* Show overlap warning */} + {isOverlapping && ( +
+
+
- {formatGap(gapInSeconds)} break + Overlapping session -
+
- )} -
- )} +
+ )} -
-
-
- {session.is_running ? ( -
+ {/* Show gap indicator based on duration */} + {shouldShowGap && gapInSeconds && ( +
+ {gapType === 'minimal' ? ( + // Minimal gap - just small dots +
+
+
+
+
+ ) : gapType === 'short' ? ( + // Short break - simple line with time +
+
+ + {formatGap(gapInSeconds)} + +
+
) : ( - index + 1 + // Long break - prominent break indicator +
+
+ + {formatGap(gapInSeconds)} break + +
+
)}
-
-
- - {sessionStart.format('h:mm A')} - {sessionEnd && - ` - ${sessionEnd.format('h:mm A')}`} - {session.is_running && ( - - {' '} - - ongoing - - )} - - - {sessionStart.format('MMM D')} - -
- {session.description && - session.description !== - stackedSession.description && ( -

- {session.description} -

+ )} + +
+
+
-
-
-
- - {session.duration_seconds - ? formatDuration(session.duration_seconds) - : '-'} - - {session.is_running && ( -
- -
- Running + > + {session.is_running ? ( +
+ ) : ( + (showAllSessions ? index : index) + 1 + )} +
+
+
+ + {sessionStart.format('h:mm A')} + {sessionEnd && + ` - ${sessionEnd.format('h:mm A')}`} + {session.is_running && ( + + {' '} + - ongoing + + )} + + + {sessionStart.format('MMM D')}
+ {session.description && + session.description !== + stackedSession.description && ( +

+ {session.description} +

+ )} +
+
+
+
+ + {session.duration_seconds + ? formatDuration(session.duration_seconds) + : '-'} + + {session.is_running && ( +
+ +
+ Running + +
+ )} +
+ {!readOnly && ( + + + + + + onEdit(session)} + > + + Edit + + + onDelete(session)} + className="text-destructive focus:text-destructive" + > + + Delete + + + )}
- {!readOnly && ( - - - - - - onEdit(session)} - > - - Edit - - - onDelete(session)} - className="text-destructive focus:text-destructive" - > - - Delete - - - - )}
-
- ); - })} + ); + })} +
+ + {/* Show more/less control for stacked sessions */} + {hasMoreSessions && ( +
+ +
+ )}
From b9e712e7f1a803e3db2437a35eb366fa84275f1a Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:08:25 +0800 Subject: [PATCH 22/35] fix(api): improve type safety in tasks API route - Replace 'any' types with proper TypeScript interfaces in task data transformation - Add TaskAssigneeData, TaskListData, and RawTaskData interfaces for better type safety - Improve type checking in assignee filtering and mapping logic Addresses code review feedback about type safety in API data handling. --- .../api/v1/workspaces/[wsId]/tasks/route.ts | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts index a5c57643c..851603879 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts @@ -1,6 +1,40 @@ import { createClient } from '@tuturuuu/supabase/next/server'; import { NextRequest, NextResponse } from 'next/server'; +// Type interfaces for better type safety +interface TaskAssigneeData { + user: { + id: string; + display_name?: string; + avatar_url?: string; + email?: string; + } | null; +} + +interface TaskListData { + id: string; + name: string; + workspace_boards: { + id: string; + name: string; + ws_id: string; + } | null; +} + +interface RawTaskData { + id: string; + name: string; + description?: string; + priority?: number; + completed: boolean; + start_date?: string; + end_date?: string; + created_at: string; + list_id: string; + task_lists: TaskListData | null; + assignees?: TaskAssigneeData[]; +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ wsId: string }> } @@ -103,11 +137,11 @@ export async function GET( // Transform the data to match the expected WorkspaceTask format const tasks = data - ?.filter((task: any) => { + ?.filter((task: RawTaskData) => { // Filter out tasks that don't belong to this workspace return task.task_lists?.workspace_boards?.ws_id === wsId; }) - ?.map((task: any) => ({ + ?.map((task: RawTaskData) => ({ id: task.id, name: task.name, description: task.description, @@ -123,16 +157,16 @@ export async function GET( // Add assignee information assignees: task.assignees - ?.map((a: any) => a.user) + ?.map((a: TaskAssigneeData) => a.user) .filter( - (user: any, index: number, self: any[]) => + (user, index: number, self) => user && user.id && - self.findIndex((u: any) => u.id === user.id) === index + self.findIndex((u) => u?.id === user.id) === index ) || [], // Add helper field to identify if current user is assigned is_assigned_to_current_user: - task.assignees?.some((a: any) => a.user?.id === user.id) || false, + task.assignees?.some((a: TaskAssigneeData) => a.user?.id === user.id) || false, })) || []; return NextResponse.json({ tasks }); From de43646172a646f91541ddb2cb8b568c75bad08e Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:13:03 +0800 Subject: [PATCH 23/35] fix(time-tracker): fix interval recreation causing heavy CPU churn - Use refs to access elapsedTime and hasReachedTarget in interval callbacks to prevent recreation every second - Remove frequently changing state from useEffect dependencies in timer intervals - Update checkBreakReminders to use elapsedTimeRef instead of direct elapsedTime dependency - Reduces CPU usage from ~60 effect recreations per minute to only when settings change Fixes interval being recreated every tick which was defeating the purpose of setInterval and causing unnecessary renders. --- .../components/timer-controls.tsx | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 0a8cf4489..3988d6652 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -614,6 +614,29 @@ export function TimerControls({ const dropdownContainerRef = useRef(null); const dropdownContentRef = useRef(null); + // Refs for timer state to avoid interval recreation + const elapsedTimeRef = useRef(elapsedTime); + const hasReachedTargetRef = useRef(hasReachedTarget); + const getCurrentBreakStateRef = useRef(getCurrentBreakState); + const updateCurrentBreakStateRef = useRef(updateCurrentBreakState); + + // Update refs when values change + useEffect(() => { + elapsedTimeRef.current = elapsedTime; + }, [elapsedTime]); + + useEffect(() => { + hasReachedTargetRef.current = hasReachedTarget; + }, [hasReachedTarget]); + + useEffect(() => { + getCurrentBreakStateRef.current = getCurrentBreakState; + }, [getCurrentBreakState]); + + useEffect(() => { + updateCurrentBreakStateRef.current = updateCurrentBreakState; + }, [updateCurrentBreakState]); + // Task creation state const [boards, setBoards] = useState([]); const [showTaskCreation, setShowTaskCreation] = useState(false); @@ -832,7 +855,7 @@ export function TimerControls({ // Session milestones for stopwatch mode if (timerMode === 'stopwatch' && stopwatchSettings.enableSessionMilestones && isRunning) { - const elapsedMinutes = Math.floor(elapsedTime / 60); + const elapsedMinutes = Math.floor(elapsedTimeRef.current / 60); const milestones = [30, 60, 120, 180, 240]; // 30min, 1hr, 2hr, 3hr, 4hr for (const milestone of milestones) { @@ -852,7 +875,7 @@ export function TimerControls({ } } } - }, [timerMode, getCurrentBreakState, updateCurrentBreakState, stopwatchSettings, pomodoroSettings, customTimerSettings, lastNotificationTime, isRunning, countdownState.sessionType, elapsedTime, showNotification]); + }, [timerMode, getCurrentBreakState, updateCurrentBreakState, stopwatchSettings, pomodoroSettings, customTimerSettings, lastNotificationTime, isRunning, countdownState.sessionType, showNotification]); @@ -913,20 +936,20 @@ export function TimerControls({ if (timerMode === 'custom' && customTimerSettings.type === 'enhanced-stopwatch' && isRunning) { const interval = setInterval(() => { const currentTime = Date.now(); - const elapsedMinutes = Math.floor(elapsedTime / 60); + const elapsedMinutes = Math.floor(elapsedTimeRef.current / 60); const targetMinutes = customTimerSettings.targetDuration || 60; // Check for interval breaks if (customTimerSettings.enableIntervalBreaks) { const intervalFreq = customTimerSettings.intervalFrequency || 25; - const currentBreakState = getCurrentBreakState(); + const currentBreakState = getCurrentBreakStateRef.current(); const timeSinceLastBreak = Math.floor((currentTime - currentBreakState.lastIntervalBreakTime) / (1000 * 60)); if (timeSinceLastBreak >= intervalFreq) { const breakDuration = customTimerSettings.intervalBreakDuration || 5; const newBreaksCount = currentBreakState.intervalBreaksCount + 1; - updateCurrentBreakState({ + updateCurrentBreakStateRef.current({ intervalBreaksCount: newBreaksCount, lastIntervalBreakTime: currentTime }); @@ -947,7 +970,7 @@ export function TimerControls({ } // Check for target achievement - if (!hasReachedTarget && elapsedMinutes >= targetMinutes) { + if (!hasReachedTargetRef.current && elapsedMinutes >= targetMinutes) { setHasReachedTarget(true); if (customTimerSettings.enableTargetNotification) { @@ -982,7 +1005,7 @@ export function TimerControls({ return () => clearInterval(interval); } - }, [timerMode, customTimerSettings, isRunning, elapsedTime, getCurrentBreakState, updateCurrentBreakState, hasReachedTarget, showNotification, playNotificationSound, checkBreakReminders]); + }, [timerMode, customTimerSettings, isRunning, showNotification, playNotificationSound, checkBreakReminders, setIsRunning]); // Fetch boards with lists const fetchBoards = useCallback(async () => { From 0fa84a56bc83d5a4f3c7a5ef660af6889b25abe0 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:14:57 +0800 Subject: [PATCH 24/35] fix(time-tracker): prevent NaN in progress bar width calculation - Add guard to check targetTime > 0 before division to prevent NaN - Set progress bar width to 0% when targetTime is 0 (enhanced-stopwatch mode) - Prevents CSS rendering issues when timer mode doesn't use countdown Fixes progress bar breaking in enhanced-stopwatch mode where targetTime can be 0. --- .../[wsId]/time-tracker/components/timer-controls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 3988d6652..d6cd1636b 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -2616,7 +2616,7 @@ export function TimerControls({ countdownState.sessionType === 'focus' ? "bg-green-500" : "bg-blue-500" )} style={{ - width: `${((countdownState.targetTime - countdownState.remainingTime) / countdownState.targetTime) * 100}%` + width: `${countdownState.targetTime > 0 ? ((countdownState.targetTime - countdownState.remainingTime) / countdownState.targetTime) * 100 : 0}%` }} />
From 64bfd17c38bb5b1f31d19fe0eda0b568385d7d59 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:17:21 +0800 Subject: [PATCH 25/35] fix(time-tracker): prevent AudioContext resource leak notification sound - Use singleton AudioContext with useRef instead of creating new instance per sound - Add lazy initialization to only create AudioContext when needed - Resume suspended context automatically for browser compatibility - Add proper cleanup on component unmount to prevent memory leaks Fixes "too many AudioContexts" browser error during long timer sessions. --- .../components/timer-controls.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index d6cd1636b..c5481972c 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -589,6 +589,15 @@ export function TimerControls({ }; }, [wsId, currentUserId]); + // Cleanup AudioContext on component unmount + useEffect(() => { + return () => { + if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.close(); + } + }; + }, []); + // Drag and drop state const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); @@ -620,6 +629,9 @@ export function TimerControls({ const getCurrentBreakStateRef = useRef(getCurrentBreakState); const updateCurrentBreakStateRef = useRef(updateCurrentBreakState); + // Ref for singleton AudioContext to prevent resource leaks + const audioContextRef = useRef(null); + // Update refs when values change useEffect(() => { elapsedTimeRef.current = elapsedTime; @@ -653,8 +665,18 @@ export function TimerControls({ const playNotificationSound = useCallback(() => { if ('Audio' in window) { try { - // Create a simple notification beep using Web Audio API - const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + // Lazily create a singleton AudioContext to prevent resource leaks + if (!audioContextRef.current) { + audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + + const audioContext = audioContextRef.current; + + // Resume context if suspended (required for some browsers) + if (audioContext.state === 'suspended') { + audioContext.resume(); + } + const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); From ca1e2107fd0b7d00bb66c094c611cbebf5531a94 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:20:44 +0800 Subject: [PATCH 26/35] fix(time-tracker): prevent PII exposure in localStorage for paused sessions - Store only minimal session identifiers instead of full SessionWithRelations objects - Add fetchSessionById function to re-fetch session details from server when needed - Implement PausedSessionData interface with only non-sensitive data (sessionId, elapsed, pauseTime, timerMode) - Add automatic cleanup for invalid/deleted sessions - Maintain legacy key cleanup for smooth migration Prevents PII exposure and localStorage quota issues while ensuring data freshness. --- .../components/timer-controls.tsx | 107 ++++++++++++------ 1 file changed, 71 insertions(+), 36 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index c5481972c..6128a3446 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -192,6 +192,13 @@ interface SessionProtection { canModifySettings: boolean; } +interface PausedSessionData { + sessionId: string; + elapsed: number; + pauseTime: string; // ISO string + timerMode: TimerMode; +} + // Default Pomodoro settings const DEFAULT_POMODORO_SETTINGS: PomodoroSettings = { focusTime: 25, @@ -338,40 +345,65 @@ export function TimerControls({ // localStorage keys for persistence const PAUSED_SESSION_KEY = `paused-session-${wsId}-${currentUserId || 'user'}`; - const PAUSED_ELAPSED_KEY = `paused-elapsed-${wsId}-${currentUserId || 'user'}`; - const PAUSE_TIME_KEY = `pause-time-${wsId}-${currentUserId || 'user'}`; const TIMER_MODE_SESSIONS_KEY = `timer-mode-sessions-${wsId}-${currentUserId || 'user'}`; - // Helper functions for localStorage persistence + // Helper function to re-fetch session details by ID + const fetchSessionById = useCallback(async (sessionId: string): Promise => { + try { + const response = await apiCall(`/api/v1/workspaces/${wsId}/time-tracking/sessions/${sessionId}`); + return response.session || null; + } catch (error) { + console.warn('Failed to fetch session details:', error); + return null; + } + }, [apiCall, wsId]); + + // Helper functions for localStorage persistence (storing only minimal data) const savePausedSessionToStorage = useCallback((session: SessionWithRelations, elapsed: number, pauseTime: Date) => { if (typeof window !== 'undefined') { try { - localStorage.setItem(PAUSED_SESSION_KEY, JSON.stringify(session)); - localStorage.setItem(PAUSED_ELAPSED_KEY, elapsed.toString()); - localStorage.setItem(PAUSE_TIME_KEY, pauseTime.toISOString()); + const pausedData: PausedSessionData = { + sessionId: session.id, + elapsed, + pauseTime: pauseTime.toISOString(), + timerMode, + }; + localStorage.setItem(PAUSED_SESSION_KEY, JSON.stringify(pausedData)); } catch (error) { console.warn('Failed to save paused session to localStorage:', error); } } - }, [PAUSED_SESSION_KEY, PAUSED_ELAPSED_KEY, PAUSE_TIME_KEY]); + }, [PAUSED_SESSION_KEY, timerMode]); - const loadPausedSessionFromStorage = useCallback(() => { + const loadPausedSessionFromStorage = useCallback(async () => { if (typeof window !== 'undefined') { try { - const sessionData = localStorage.getItem(PAUSED_SESSION_KEY); - const elapsedData = localStorage.getItem(PAUSED_ELAPSED_KEY); - const pauseTimeData = localStorage.getItem(PAUSE_TIME_KEY); - - if (sessionData && elapsedData && pauseTimeData) { - const session = JSON.parse(sessionData); - const elapsed = parseInt(elapsedData); - const pauseTime = new Date(pauseTimeData); + const pausedDataStr = localStorage.getItem(PAUSED_SESSION_KEY); + + if (pausedDataStr) { + const pausedData: PausedSessionData = JSON.parse(pausedDataStr); + + // Re-fetch the full session details by ID + const session = await fetchSessionById(pausedData.sessionId); + + if (session) { + const elapsed = pausedData.elapsed; + const pauseTime = new Date(pausedData.pauseTime); - setPausedSession(session); - setPausedElapsedTime(elapsed); - setPauseStartTime(pauseTime); + setPausedSession(session); + setPausedElapsedTime(elapsed); + setPauseStartTime(pauseTime); + + // Restore timer mode if different + if (pausedData.timerMode !== timerMode) { + setTimerMode(pausedData.timerMode); + } - return { session, elapsed, pauseTime }; + return { session, elapsed, pauseTime }; + } else { + // Session not found, clear invalid data + clearPausedSessionFromStorage(); + } } } catch (error) { console.warn('Failed to load paused session from localStorage:', error); @@ -379,19 +411,17 @@ export function TimerControls({ } } return null; - }, [PAUSED_SESSION_KEY, PAUSED_ELAPSED_KEY, PAUSE_TIME_KEY]); + }, [PAUSED_SESSION_KEY, fetchSessionById, timerMode]); const clearPausedSessionFromStorage = useCallback(() => { if (typeof window !== 'undefined') { try { localStorage.removeItem(PAUSED_SESSION_KEY); - localStorage.removeItem(PAUSED_ELAPSED_KEY); - localStorage.removeItem(PAUSE_TIME_KEY); } catch (error) { console.warn('Failed to clear paused session from localStorage:', error); } } - }, [PAUSED_SESSION_KEY, PAUSED_ELAPSED_KEY, PAUSE_TIME_KEY]); + }, [PAUSED_SESSION_KEY]); // Session protection utilities const updateSessionProtection = useCallback((isActive: boolean, mode: TimerMode) => { @@ -544,19 +574,23 @@ export function TimerControls({ // Load paused session on component mount useEffect(() => { - const pausedData = loadPausedSessionFromStorage(); - if (pausedData) { - console.log('Restored paused session from localStorage:', pausedData.session.title); - - // Show a toast to let user know their paused session was restored - toast.success('Paused session restored!', { - description: `${pausedData.session.title} - ${formatDuration(pausedData.elapsed)} tracked`, - duration: 5000, - }); - } + const loadData = async () => { + const pausedData = await loadPausedSessionFromStorage(); + if (pausedData) { + console.log('Restored paused session from localStorage:', pausedData.session.title); + + // Show a toast to let user know their paused session was restored + toast.success('Paused session restored!', { + description: `${pausedData.session.title} - ${formatDuration(pausedData.elapsed)} tracked`, + duration: 5000, + }); + } + + // Load timer mode sessions + loadTimerModeSessionsFromStorage(); + }; - // Load timer mode sessions - loadTimerModeSessionsFromStorage(); + loadData(); }, [loadPausedSessionFromStorage, loadTimerModeSessionsFromStorage, formatDuration]); // Update session protection when timer state changes @@ -579,6 +613,7 @@ export function TimerControls({ !key.includes(`-${wsId}-${currentUserId}`) ); keys.forEach(key => { + // Also clean up legacy keys (paused-elapsed and pause-time) const relatedKeys = [ key, key.replace('paused-session-', 'paused-elapsed-'), From 0f5e39dbb1dc8be020b98812c4b9606635a91341 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:27:42 +0800 Subject: [PATCH 27/35] refactor(time-tracker): replace magic string - was_resumed boolean field - Add was_resumed field to time_tracking_sessions table - Update API to set field when resuming sessions - Replace description parsing with reliable boolean check - Add database index for analytics performance Fixes fragile magic string dependency in focus score calculation --- .../20250119000000_add_was_resumed_field.sql | 34 +++++++++++++++++++ .../components/session-history.tsx | 2 +- .../sessions/[sessionId]/route.ts | 3 +- packages/types/src/supabase.ts | 4 +++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql diff --git a/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql b/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql new file mode 100644 index 000000000..3171ff6f3 --- /dev/null +++ b/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql @@ -0,0 +1,34 @@ +-- Add was_resumed field to time_tracking_sessions table +-- This field tracks whether a session was created by resuming a previous session + +ALTER TABLE public.time_tracking_sessions +ADD COLUMN was_resumed boolean DEFAULT false; + +-- Add index for analytics queries that filter by was_resumed +CREATE INDEX idx_time_tracking_sessions_was_resumed +ON public.time_tracking_sessions USING btree (was_resumed) +WHERE was_resumed = true; + +-- Update the time_tracking_session_analytics view to include was_resumed +DROP VIEW IF EXISTS time_tracking_session_analytics; + +CREATE OR REPLACE VIEW time_tracking_session_analytics AS +SELECT + tts.*, + ttc.name as category_name, + ttc.color as category_color, + t.name as task_name, + EXTRACT(HOUR FROM tts.start_time) as start_hour, + EXTRACT(DOW FROM tts.start_time) as day_of_week, + DATE_TRUNC('day', tts.start_time) as session_date, + DATE_TRUNC('week', tts.start_time) as session_week, + DATE_TRUNC('month', tts.start_time) as session_month, + CASE + WHEN tts.duration_seconds >= 7200 THEN 'long' -- 2+ hours + WHEN tts.duration_seconds >= 1800 THEN 'medium' -- 30min - 2 hours + WHEN tts.duration_seconds >= 300 THEN 'short' -- 5-30 minutes + ELSE 'micro' -- < 5 minutes + END as session_length_category +FROM time_tracking_sessions tts +LEFT JOIN time_tracking_categories ttc ON tts.category_id = ttc.id +LEFT JOIN tasks t ON tts.task_id = t.id; \ No newline at end of file diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 65b1c8fa2..f5fb2f824 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -840,7 +840,7 @@ export function SessionHistory({ const durationScore = Math.min(session.duration_seconds / 7200, 1) * 40; // Max 40 points for 2+ hours // Bonus for consistency (sessions without interruptions) - const consistencyBonus = session.description?.includes('resumed') ? 0 : 20; + const consistencyBonus = session.was_resumed ? 0 : 20; // Time of day bonus (peak hours get bonus) const sessionHour = dayjs.utc(session.start_time).tz(userTimezone).hour(); diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/time-tracking/sessions/[sessionId]/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/time-tracking/sessions/[sessionId]/route.ts index 76f203673..ae1c6640b 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/time-tracking/sessions/[sessionId]/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/time-tracking/sessions/[sessionId]/route.ts @@ -115,7 +115,7 @@ export async function PATCH( } if (action === 'resume') { - // Create a new session with the same details + // Create a new session with the same details, marking it as resumed const { data, error } = await adminSupabase .from('time_tracking_sessions') .insert({ @@ -127,6 +127,7 @@ export async function PATCH( task_id: session.task_id, start_time: new Date().toISOString(), is_running: true, + was_resumed: true, // Mark this session as resumed created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index 9887f2419..b1c8446e0 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -3795,6 +3795,7 @@ export type Database = { title: string; updated_at: string | null; user_id: string; + was_resumed: boolean | null; ws_id: string; }; Insert: { @@ -3812,6 +3813,7 @@ export type Database = { title: string; updated_at?: string | null; user_id: string; + was_resumed?: boolean | null; ws_id: string; }; Update: { @@ -3829,6 +3831,7 @@ export type Database = { title?: string; updated_at?: string | null; user_id?: string; + was_resumed?: boolean | null; ws_id?: string; }; Relationships: [ @@ -7124,6 +7127,7 @@ export type Database = { title: string | null; updated_at: string | null; user_id: string | null; + was_resumed: boolean | null; ws_id: string | null; }; Relationships: [ From 6366c76a1a3453226d9c898159a6cf345af2239b Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:38:57 +0800 Subject: [PATCH 28/35] refactor(time-tracker): session filtering guard and heatmap proper fix - Implemented filtering logic for session data to enhance heatmap accuracy. - Updated heatmap component to visualize filtered session data effectively. --- .../components/activity-heatmap.tsx | 628 ++++++++++-------- .../components/session-history.tsx | 9 +- 2 files changed, 356 insertions(+), 281 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx index ad50061f1..30307c8f6 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx @@ -746,12 +746,11 @@ export function ActivityHeatmap({ ); }; - const renderCompactCards = () => { - // Group data by month for compact card display - const monthlyData = weeks.reduce((acc, week) => { + // Data processing functions + const processMonthlyData = useCallback(() => { + return weeks.reduce((acc, week) => { week.forEach(day => { - // Add null check for day and day.activity - if (day && day.activity) { + if (day?.activity) { const monthKey = day.date.format('YYYY-MM'); const monthName = day.date.format('MMM YYYY'); @@ -804,7 +803,9 @@ export function ActivityHeatmap({ longestStreak: number; currentStreak: number; }>); + }, [weeks]); + const calculateMonthlyStats = useCallback((monthlyData: ReturnType) => { // Calculate streaks for each month Object.values(monthlyData).forEach(monthData => { let currentStreak = 0; @@ -814,7 +815,7 @@ export function ActivityHeatmap({ const sortedDates = monthData.dates.sort((a, b) => a.date.valueOf() - b.date.valueOf()); for (let i = 0; i < sortedDates.length; i++) { - if (sortedDates[i] && sortedDates[i].activity && sortedDates[i].activity.duration > 0) { + if (sortedDates[i]?.activity?.duration > 0) { currentStreak += 1; longestStreak = Math.max(longestStreak, currentStreak); } else { @@ -859,81 +860,267 @@ export function ActivityHeatmap({ const avgSessionLength = totalOverallSessions > 0 ? totalOverallDuration / totalOverallSessions : 0; const overallFocusScore = avgSessionLength > 0 ? Math.min(100, Math.round((avgSessionLength / 3600) * 25)) : 0; - // Determine if user is "established" enough to show upcoming month suggestions - const isEstablishedUser = totalActiveDays >= 7 && totalOverallSessions >= 10 && sortedMonths.length >= 1; - const hasRecentActivity = sortedMonths.length > 0 && dayjs().diff(dayjs().startOf('month'), 'day') < 15; // Active this month - const shouldShowUpcoming = isEstablishedUser && hasRecentActivity; + return { + sortedMonths, + monthsWithTrends, + overallStats: { + totalDuration: totalOverallDuration, + totalSessions: totalOverallSessions, + activeDays: totalActiveDays, + avgDaily: avgDailyOverall, + avgSession: avgSessionLength, + focusScore: overallFocusScore, + monthCount: sortedMonths.length + } + }; + }, []); + + // Card components + const SummaryCard = ({ data }: { data: any }) => ( +
+
+
+

Overall

+ {data.monthCount} month{data.monthCount > 1 ? 's' : ''} +
+
+ {data.focusScore}% +
+
+ +
+
+
Total
+
{formatDuration(data.totalDuration)}
+
+
+
Daily
+
{formatDuration(Math.round(data.avgDaily))}
+
+
+
Sessions
+
{data.totalSessions}
+
+
+
Days
+
{data.activeDays}
+
+
+ +
+

+ {data.activeDays < 7 ? + `Great start! Building momentum.` : + `Avg session: ${formatDuration(Math.round(data.avgSession))}` + } +

+
+
+ ); - // Create all cards - const allCards = []; + const MonthlyCard = ({ monthKey, data, trend, trendValue }: { monthKey: string; data: any; trend: 'up' | 'down' | 'neutral'; trendValue: number }) => { + const avgDailyDuration = data.activeDays > 0 ? data.totalDuration / data.activeDays : 0; + const avgSessionLength = data.totalSessions > 0 ? data.totalDuration / data.totalSessions : 0; + const focusScore = avgSessionLength > 0 ? Math.min(100, Math.round((avgSessionLength / 3600) * 25)) : 0; + const consistencyScore = data.activeDays > 0 ? Math.round((data.activeDays / 31) * 100) : 0; - // Add summary card if we have meaningful data (more than just a few sessions) - if (sortedMonths.length > 0 && totalActiveDays >= 3) { - allCards.push({ - type: 'summary', - data: { - totalDuration: totalOverallDuration, - totalSessions: totalOverallSessions, - activeDays: totalActiveDays, - avgDaily: avgDailyOverall, - avgSession: avgSessionLength, - focusScore: overallFocusScore, - monthCount: sortedMonths.length - } - }); - } + return ( +
+
+
+

{data.name}

+
+
+ {trend !== 'neutral' && ( + + {trend === 'up' ? '↗' : '↘'}{Math.abs(trendValue).toFixed(0)}% + + )} +
+
+
+ {focusScore}% +
+
+ +
+
+
Total
+
{formatDuration(data.totalDuration)}
+
+
+
Daily
+
{formatDuration(Math.round(avgDailyDuration))}
+
+
+
Sessions
+
{data.totalSessions}
+
+
+
Days
+
{data.activeDays}
+
+
- // Add monthly data cards - monthsWithTrends.forEach(({ monthKey, data, trend, trendValue }) => { - allCards.push({ - type: 'monthly', - monthKey, - data, - trend, - trendValue - }); - }); + {/* Mini Heatmap */} +
+
+ {Array.from({ length: 7 * 4 }, (_, i) => { + const monthStart = dayjs(monthKey + '-01'); + const dayOffset = i - monthStart.day(); + const currentDay = monthStart.add(dayOffset, 'day'); + + const dayActivity = data.dates.find((d: any) => + d?.date?.format('YYYY-MM-DD') === currentDay.format('YYYY-MM-DD') + ); + + const isCurrentMonth = currentDay.month() === monthStart.month(); + const dayIntensity = dayActivity?.activity ? getIntensity(dayActivity.activity.duration) : 0; + + return ( +
+ ); + })} +
+
+ +
+

+ {consistencyScore >= 80 ? 'Excellent consistency!' : + consistencyScore >= 50 ? 'Good habits forming' : + 'Building momentum'} +

+
+
+ ); + }; - // Only add upcoming months if user is established and we have space - if (shouldShowUpcoming && allCards.length < 4) { - const currentMonth = dayjs(); - const nextMonth = currentMonth.add(1, 'month'); + const UpcomingCard = ({ monthKey, name }: { monthKey: string; name: string }) => ( +
+
+
+

{name}

+ Next month +
+
+ Plan +
+
- // Only add 1 upcoming month as a subtle suggestion - allCards.push({ - type: 'upcoming', - monthKey: nextMonth.format('YYYY-MM'), - name: nextMonth.format('MMM YYYY'), - isSubtle: true - }); - } +
+
+
Target
+
Set goal
+
+
+
Focus
+
Stay consistent
+
+
+
Sessions
+
Plan ahead
+
+
+
Growth
+
Keep going
+
+
- // Add getting started card if no meaningful data - if (sortedMonths.length === 0 || totalActiveDays < 3) { - allCards.unshift({ - type: 'getting-started' - }); - } +
+
+ {Array.from({ length: 7 * 4 }, (_, i) => ( +
+ ))} +
+
+ +
+

+ Keep the momentum going! 🚀 +

+
+
+ ); - const [currentIndex, setCurrentIndex] = useState(0); - const maxVisibleCards = 4; - const totalCards = allCards.length; + const GettingStartedCard = () => ( +
+
+
+

Get Started

+ Begin journey +
+
+ New +
+
+ +
+
+
+ Start timer session +
+
+
+ Build daily habits +
+
+
+ Track progress +
+
+ +
+

+ 💡 Try 25-min Pomodoro sessions +

+
+
+ ); + + const CompactCardsContainer = ({ + cards, + currentIndex, + setCurrentIndex, + maxVisibleCards + }: { + cards: any[]; + currentIndex: number; + setCurrentIndex: (index: number) => void; + maxVisibleCards: number; + }) => { + const totalCards = cards.length; const canScrollLeft = currentIndex > 0; const canScrollRight = currentIndex < totalCards - maxVisibleCards; const scrollLeft = () => { if (canScrollLeft) { - setCurrentIndex(prev => Math.max(0, prev - 1)); + setCurrentIndex(Math.max(0, currentIndex - 1)); } }; const scrollRight = () => { if (canScrollRight) { - setCurrentIndex(prev => Math.min(totalCards - maxVisibleCards, prev + 1)); + setCurrentIndex(Math.min(totalCards - maxVisibleCards, currentIndex + 1)); } }; - const visibleCards = allCards.slice(currentIndex, currentIndex + maxVisibleCards); + const visibleCards = cards.slice(currentIndex, currentIndex + maxVisibleCards); return (
@@ -975,238 +1162,33 @@ export function ActivityHeatmap({
{visibleCards.map((card) => { if (card.type === 'summary' && card.data) { - return ( -
-
-
-

Overall

- {card.data.monthCount} month{card.data.monthCount > 1 ? 's' : ''} -
-
- {card.data.focusScore}% -
-
- -
-
-
Total
-
{formatDuration(card.data.totalDuration)}
-
-
-
Daily
-
{formatDuration(Math.round(card.data.avgDaily))}
-
-
-
Sessions
-
{card.data.totalSessions}
-
-
-
Days
-
{card.data.activeDays}
-
-
- -
-

- {card.data.activeDays < 7 ? - `Great start! Building momentum.` : - `Avg session: ${formatDuration(Math.round(card.data.avgSession))}` - } -

-
-
- ); + return ; } if (card.type === 'monthly' && card.data) { - const avgDailyDuration = card.data.activeDays > 0 ? card.data.totalDuration / card.data.activeDays : 0; - const avgSessionLength = card.data.totalSessions > 0 ? card.data.totalDuration / card.data.totalSessions : 0; - const focusScore = avgSessionLength > 0 ? Math.min(100, Math.round((avgSessionLength / 3600) * 25)) : 0; - const consistencyScore = card.data.activeDays > 0 ? Math.round((card.data.activeDays / 31) * 100) : 0; - return ( -
-
-
-

{(card.data as any).name}

-
-
- {(card as any).trend !== 'neutral' && ( - - {(card as any).trend === 'up' ? '↗' : '↘'}{Math.abs((card as any).trendValue).toFixed(0)}% - - )} -
-
-
- {focusScore}% -
-
- -
-
-
Total
-
{formatDuration(card.data.totalDuration)}
-
-
-
Daily
-
{formatDuration(Math.round(avgDailyDuration))}
-
-
-
Sessions
-
{card.data.totalSessions}
-
-
-
Days
-
{card.data.activeDays}
-
-
- - {/* Mini Heatmap */} -
-
- {Array.from({ length: 7 * 4 }, (_, i) => { - const monthStart = dayjs((card as any).monthKey + '-01'); - const dayOffset = i - monthStart.day(); - const currentDay = monthStart.add(dayOffset, 'day'); - - const dayActivity = (card.data as any).dates.find((d: any) => - d && d.date && d.date.format('YYYY-MM-DD') === currentDay.format('YYYY-MM-DD') - ); - - const isCurrentMonth = currentDay.month() === monthStart.month(); - const dayIntensity = dayActivity && dayActivity.activity ? getIntensity(dayActivity.activity.duration) : 0; - - return ( -
- ); - })} -
-
- -
-

- {consistencyScore >= 80 ? 'Excellent consistency!' : - consistencyScore >= 50 ? 'Good habits forming' : - 'Building momentum'} -

-
-
+ ); } if (card.type === 'upcoming') { return ( -
-
-
-

{(card as any).name}

- Next month -
-
- Plan -
-
- -
-
-
Target
-
Set goal
-
-
-
Focus
-
Stay consistent
-
-
-
Sessions
-
Plan ahead
-
-
-
Growth
-
Keep going
-
-
- -
-
- {Array.from({ length: 7 * 4 }, (_, i) => ( -
- ))} -
-
- -
-

- Keep the momentum going! 🚀 -

-
-
+ ); } if (card.type === 'getting-started') { - return ( -
-
-
-

Get Started

- Begin journey -
-
- New -
-
- -
-
-
- Start timer session -
-
-
- Build daily habits -
-
-
- Track progress -
-
- -
-

- 💡 Try 25-min Pomodoro sessions -

-
-
- ); + return ; } return null; @@ -1236,6 +1218,92 @@ export function ActivityHeatmap({ ); }; + const buildCardsList = useCallback((monthlyStats: ReturnType) => { + const { sortedMonths, monthsWithTrends, overallStats } = monthlyStats; + const allCards = []; + + // Determine if user is "established" enough to show upcoming month suggestions + const isEstablishedUser = overallStats.activeDays >= 7 && overallStats.totalSessions >= 10 && sortedMonths.length >= 1; + const hasRecentActivity = sortedMonths.length > 0 && dayjs().diff(dayjs().startOf('month'), 'day') < 15; + const shouldShowUpcoming = isEstablishedUser && hasRecentActivity; + + // Add summary card if we have meaningful data + if (sortedMonths.length > 0 && overallStats.activeDays >= 3) { + allCards.push({ + type: 'summary', + data: overallStats + }); + } + + // Add monthly data cards + monthsWithTrends.forEach(({ monthKey, data, trend, trendValue }) => { + allCards.push({ + type: 'monthly', + monthKey, + data, + trend, + trendValue + }); + }); + + // Only add upcoming months if user is established and we have space + if (shouldShowUpcoming && allCards.length < 4) { + const currentMonth = dayjs(); + const nextMonth = currentMonth.add(1, 'month'); + + allCards.push({ + type: 'upcoming', + monthKey: nextMonth.format('YYYY-MM'), + name: nextMonth.format('MMM YYYY'), + isSubtle: true + }); + } + + // Add getting started card if no meaningful data + if (sortedMonths.length === 0 || overallStats.activeDays < 3) { + allCards.unshift({ + type: 'getting-started' + }); + } + + return allCards; + }, [formatDuration, getIntensity, getColorClass]); + + const renderCompactCards = () => { + const monthlyData = processMonthlyData(); + const monthlyStats = calculateMonthlyStats(monthlyData); + const allCards = buildCardsList(monthlyStats); + + const [currentIndex, setCurrentIndex] = useState(0); + const maxVisibleCards = 4; + const totalCards = allCards.length; + const canScrollLeft = currentIndex > 0; + const canScrollRight = currentIndex < totalCards - maxVisibleCards; + + const scrollLeft = () => { + if (canScrollLeft) { + setCurrentIndex(prev => Math.max(0, prev - 1)); + } + }; + + const scrollRight = () => { + if (canScrollRight) { + setCurrentIndex(prev => Math.min(totalCards - maxVisibleCards, prev + 1)); + } + }; + + const visibleCards = allCards.slice(currentIndex, currentIndex + maxVisibleCards); + + return ( + + ); + }; + return (
{/* Header */} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index f5fb2f824..880bc39ac 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -1484,7 +1484,14 @@ export function SessionHistory({
- {Math.round(filteredSessions.reduce((sum, s) => sum + calculateFocusScore(s), 0) / filteredSessions.length)} + {filteredSessions.length > 0 + ? Math.round( + filteredSessions.reduce( + (sum, s) => sum + calculateFocusScore(s), + 0 + ) / filteredSessions.length + ) + : 0}
Avg Focus
From 562746a984b802c185e620d1b0536540cf9fb059 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 11:50:02 +0800 Subject: [PATCH 29/35] refactor(time-tracker): improve code quality - added optional chaining, constants extraction, and data preservation - **heatmap**: Replace explicit null checks with optional chaining operators - Simplify property access patterns in activity filtering logic - Enhance code readability and maintain runtime safety - Fix static analysis warnings for complexity/useOptionalChain - **quick-timer**: Extract focus score magic numbers and simplify calculation - Add FOCUS_SCORE_CONSTANTS and SESSION_THRESHOLDS constants - Extract calculateFocusScore helper function for better maintainability - Replace complex inline IIFE with clean function call in JSX - Update all hardcoded time comparisons to use named constants - **tasks-sidebar**: preserve assignee metadata when transforming tasks - Prevent overwriting existing assignee information with undefined values - Keep assignee_name, assignee_avatar, is_assigned_to_current_user, and assignees fields - Transform tasks to ExtendedWorkspaceTask while preserving existing metadata - Fix potential data loss when tasks already contain enriched assignee information These changes improve maintainability, readability, and data integrity across the time tracking and task management components. --- .../components/tasks-sidebar-content.tsx | 22 +++-- .../components/activity-heatmap.tsx | 6 +- .../components/command/quick-time-tracker.tsx | 92 +++++++++++++------ 3 files changed, 79 insertions(+), 41 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx index 29992e02a..461786a54 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx @@ -191,15 +191,19 @@ export default function TasksSidebarContent({ board.lists?.forEach((list) => { if (list.tasks) { // Transform Partial to ExtendedWorkspaceTask - const extendedTasks = list.tasks.map((task) => ({ - ...task, - board_name: board.name, - list_name: list.name, - assignee_name: undefined, - assignee_avatar: undefined, - is_assigned_to_current_user: undefined, - assignees: undefined, - } as ExtendedWorkspaceTask)); + const extendedTasks = list.tasks.map((task): ExtendedWorkspaceTask => { + const taskAny = task as any; + return { + ...task, + board_name: board.name, + list_name: list.name, + // Keep existing assignee metadata if present, properly handling null/undefined + assignee_name: taskAny.assignee_name ? taskAny.assignee_name : undefined, + assignee_avatar: taskAny.assignee_avatar ? taskAny.assignee_avatar : undefined, + is_assigned_to_current_user: taskAny.is_assigned_to_current_user, + assignees: taskAny.assignees, + }; + }); tasks.push(...extendedTasks); } }); diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx index 30307c8f6..bb871f883 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx @@ -640,12 +640,12 @@ export function ActivityHeatmap({ } const monthlyStats = { - activeDays: days.filter(day => day.activity && day.isCurrentMonth).length, + activeDays: days.filter(day => day?.activity && day.isCurrentMonth).length, totalDuration: days - .filter(day => day.isCurrentMonth && day.activity) + .filter(day => day.isCurrentMonth && day?.activity) .reduce((sum, day) => sum + (day.activity?.duration || 0), 0), totalSessions: days - .filter(day => day.isCurrentMonth && day.activity) + .filter(day => day.isCurrentMonth && day?.activity) .reduce((sum, day) => sum + (day.activity?.sessions || 0), 0) }; diff --git a/apps/web/src/components/command/quick-time-tracker.tsx b/apps/web/src/components/command/quick-time-tracker.tsx index 9217f9335..fc15fad53 100644 --- a/apps/web/src/components/command/quick-time-tracker.tsx +++ b/apps/web/src/components/command/quick-time-tracker.tsx @@ -12,6 +12,46 @@ import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { cn } from '@tuturuuu/utils/format'; +// Focus score calculation constants +const FOCUS_SCORE_CONSTANTS = { + MAX_DURATION_SECONDS: 7200, // 2 hours + DURATION_WEIGHT: 40, + CONSISTENCY_BONUS: 20, + TIME_BONUS: 20, + CATEGORY_BONUS: 10, + TASK_BONUS: 10, + PEAK_HOURS: { morning: [9, 11], afternoon: [14, 16] } +} as const; + +// Session duration thresholds (in seconds) +const SESSION_THRESHOLDS = { + DEEP_WORK: 7200, // 2 hours + FOCUSED: 3600, // 1 hour + STANDARD: 1800, // 30 minutes + QUICK_START: 900 // 15 minutes +} as const; + +// Helper function to calculate focus score +const calculateFocusScore = ( + elapsedTime: number, + category: any, + taskId: string | undefined, + currentHour: number +): number => { + const durationScore = Math.min(elapsedTime / FOCUS_SCORE_CONSTANTS.MAX_DURATION_SECONDS, 1) * FOCUS_SCORE_CONSTANTS.DURATION_WEIGHT; + const consistencyBonus = FOCUS_SCORE_CONSTANTS.CONSISTENCY_BONUS; + const timeBonus = ( + (currentHour >= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.morning[0] && currentHour <= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.morning[1]) || + (currentHour >= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.afternoon[0] && currentHour <= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.afternoon[1]) + ) ? FOCUS_SCORE_CONSTANTS.TIME_BONUS : 0; + const categoryBonus = category?.name?.toLowerCase().includes('work') ? FOCUS_SCORE_CONSTANTS.CATEGORY_BONUS : 0; + const taskBonus = taskId ? FOCUS_SCORE_CONSTANTS.TASK_BONUS : 0; + + return Math.min(Math.round( + durationScore + consistencyBonus + timeBonus + categoryBonus + taskBonus + ), 100); +}; + interface QuickTimeTrackerProps { wsId: string; // eslint-disable-next-line no-unused-vars @@ -379,43 +419,37 @@ export function QuickTimeTracker({
= 7200 ? "bg-green-500 dark:bg-green-600" : - elapsedTime >= 3600 ? "bg-blue-500 dark:bg-blue-600" : - elapsedTime >= 1800 ? "bg-yellow-500 dark:bg-yellow-600" : "bg-gray-500 dark:bg-gray-600" + elapsedTime >= SESSION_THRESHOLDS.DEEP_WORK ? "bg-green-500 dark:bg-green-600" : + elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? "bg-blue-500 dark:bg-blue-600" : + elapsedTime >= SESSION_THRESHOLDS.STANDARD ? "bg-yellow-500 dark:bg-yellow-600" : "bg-gray-500 dark:bg-gray-600" )}>
- {(() => { - // Real-time focus score calculation - const durationScore = Math.min(elapsedTime / 7200, 1) * 40; - const consistencyBonus = 20; // Assume consistent for live session - const timeBonus = (() => { - const hour = new Date().getHours(); - return (hour >= 9 && hour <= 11) || (hour >= 14 && hour <= 16) ? 20 : 0; - })(); - const categoryBonus = runningSession.category?.name?.toLowerCase().includes('work') ? 10 : 0; - const taskBonus = runningSession.task_id ? 10 : 0; - return Math.min(Math.round(durationScore + consistencyBonus + timeBonus + categoryBonus + taskBonus), 100); - })()} + {calculateFocusScore( + elapsedTime, + runningSession.category, + runningSession.task_id, + new Date().getHours() + )}
{/* Productivity Tips */}
- {elapsedTime >= 7200 ? ( + {elapsedTime >= SESSION_THRESHOLDS.DEEP_WORK ? ( 🧠 Deep work mode! Excellent focus. - ) : elapsedTime >= 3600 ? ( + ) : elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? ( 🎯 Great focus! Consider a break soon. - ) : elapsedTime >= 1800 ? ( + ) : elapsedTime >= SESSION_THRESHOLDS.STANDARD ? ( 📈 Building momentum! Keep going. - ) : elapsedTime >= 900 ? ( + ) : elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? ( ⏰ Good start! Focus is building. ) : ( 🚀 Just started! Focus will improve. @@ -428,20 +462,20 @@ export function QuickTimeTracker({ Session Type:
= 3600 ? "text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950/30" : - elapsedTime >= 1800 ? "text-blue-700 bg-blue-100 dark:text-blue-300 dark:bg-blue-950/30" : - elapsedTime >= 900 ? "text-yellow-700 bg-yellow-100 dark:text-yellow-300 dark:bg-yellow-950/30" : + elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? "text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950/30" : + elapsedTime >= SESSION_THRESHOLDS.STANDARD ? "text-blue-700 bg-blue-100 dark:text-blue-300 dark:bg-blue-950/30" : + elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? "text-yellow-700 bg-yellow-100 dark:text-yellow-300 dark:bg-yellow-950/30" : "text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-950/30" )}> - {elapsedTime >= 3600 ? '🧠' : - elapsedTime >= 1800 ? '🎯' : - elapsedTime >= 900 ? '📋' : '⚡'} + {elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? '🧠' : + elapsedTime >= SESSION_THRESHOLDS.STANDARD ? '🎯' : + elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? '📋' : '⚡'} - {elapsedTime >= 3600 ? 'Deep Work' : - elapsedTime >= 1800 ? 'Focused' : - elapsedTime >= 900 ? 'Standard' : 'Quick Task'} + {elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? 'Deep Work' : + elapsedTime >= SESSION_THRESHOLDS.STANDARD ? 'Focused' : + elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? 'Standard' : 'Quick Task'}
From 7b39922896086132ff30a3a6d0664e8ee77aade6 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 12:07:12 +0800 Subject: [PATCH 30/35] refactor: improve code quality - added with type safety, performance, and maintainability enhancements - **types**: Add JSDoc comments to clarify time units in TimerStats interface - Document duration fields as seconds to prevent unit conversion bugs - Add time unit documentation to dailyActivity duration field - **types**: Replace empty string defaults with null for better type safety - Update TaskFilters and TaskSidebarFilters to use string | null - Improve filter comparisons by avoiding truthy/falsy traps - **time-tracker**: Simplify data mapping with spread operator - Replace manual property mapping with cleaner spread syntax - Reduce code duplication in TimeTrackerData initialization - **quick-actions**: Add memoization to peak hour calculation - Extract peak hours configuration as constants - Use useMemo to prevent unnecessary recalculation on each render - Improve testability by separating config from logic - **api**: Remove redundant null check in tasks route - Supabase returns empty array, not null, when no rows match - Simplify control flow by removing unreachable branch - **utils**: Enhance task filtering and sorting utilities - Add proper null safety for priority values using Number() conversion - Consolidate filter logic to reduce duplication between timer and sidebar - Extract common filter interface and shared filtering function - Improve initials generation for multi-word names and edge cases - **timer-controls**: Add memoization to expensive task operations - Memoize filtered tasks calculation to avoid O(N) work on every render - Memoize unique boards and lists extraction for better performance - Replace function calls with memoized values in task navigation - **heatmap**: Replace explicit null checks with optional chaining operators - Simplify property access patterns in activity filtering logic - Enhance code readability and maintain runtime safety - Fix static analysis warnings for complexity/useOptionalChain - **quick-timer**: Extract focus score magic numbers and simplify calculation - Add FOCUS_SCORE_CONSTANTS and SESSION_THRESHOLDS constants - Extract calculateFocusScore helper function for better maintainability - Replace complex inline calculation with clean function call - Update all hardcoded time comparisons to use named constants Performance improvements: Memoized calculations reduce O(N) operations on every render Type safety: Added null safety guards and proper type documentation Code quality: Extracted constants, consolidated utilities, improved readability Static analysis: Fixed optional chaining warnings and removed unreachable code - Replace spread operator with explicit type-safe field mapping - Convert undefined values to null for database field compatibility - Add non-null assertions for required fields (id, name, list_id) - Preserve existing assignee metadata from partial task objects - Eliminate TypeScript errors in tasks sidebar content component --- .../components/tasks-sidebar-content.tsx | 42 +++-- .../components/timer-controls.tsx | 27 ++-- .../(dashboard)/[wsId]/time-tracker/page.tsx | 9 +- .../(dashboard)/[wsId]/time-tracker/types.ts | 20 ++- .../(dashboard)/[wsId]/time-tracker/utils.ts | 143 +++++++++--------- .../api/v1/workspaces/[wsId]/tasks/route.ts | 5 - .../src/components/command/quick-actions.tsx | 18 ++- 7 files changed, 147 insertions(+), 117 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx index 461786a54..a098fbf71 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx @@ -193,16 +193,40 @@ export default function TasksSidebarContent({ // Transform Partial to ExtendedWorkspaceTask const extendedTasks = list.tasks.map((task): ExtendedWorkspaceTask => { const taskAny = task as any; - return { - ...task, - board_name: board.name, - list_name: list.name, - // Keep existing assignee metadata if present, properly handling null/undefined - assignee_name: taskAny.assignee_name ? taskAny.assignee_name : undefined, - assignee_avatar: taskAny.assignee_avatar ? taskAny.assignee_avatar : undefined, - is_assigned_to_current_user: taskAny.is_assigned_to_current_user, - assignees: taskAny.assignees, + + // Type-safe conversion from Partial to ExtendedWorkspaceTask + // Convert undefined values to null to match the expected type constraints + const extendedTask: ExtendedWorkspaceTask = { + // Required fields (these should always be present) + id: task.id!, + name: task.name!, + list_id: task.list_id!, + + // Optional fields with proper null conversion + description: task.description ?? null, + priority: task.priority ?? null, + start_date: task.start_date ?? null, + end_date: task.end_date ?? null, + created_at: task.created_at ?? null, + creator_id: task.creator_id ?? null, + + // Boolean fields that should be boolean | null (not undefined) + archived: task.archived ?? null, + completed: task.completed ?? null, + deleted: task.deleted ?? null, + + // Extended fields for context + board_name: board.name ?? undefined, + list_name: list.name ?? undefined, + + // Keep existing assignee metadata if present + assignee_name: taskAny.assignee_name || undefined, + assignee_avatar: taskAny.assignee_avatar || undefined, + is_assigned_to_current_user: taskAny.is_assigned_to_current_user || undefined, + assignees: taskAny.assignees || undefined, }; + + return extendedTask; }); tasks.push(...extendedTasks); } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index 6128a3446..f9d67623a 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -47,7 +47,7 @@ import { toast } from '@tuturuuu/ui/sonner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs'; import { Textarea } from '@tuturuuu/ui/textarea'; import { cn } from '@tuturuuu/utils/format'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -1607,26 +1607,27 @@ export function TimerControls({ const selectedBoard = boards.find((board) => board.id === selectedBoardId); const availableLists = selectedBoard?.task_lists || []; - // Use shared task filtering and sorting utility - const getFilteredTasks = () => { + // Memoized filtered tasks to avoid O(N) work on every render + const filteredTasks = useMemo(() => { return getFilteredAndSortedTasks(tasks, taskSearchQuery, taskFilters); - }; + }, [tasks, taskSearchQuery, taskFilters]); - // Get unique boards and lists for filter options - const uniqueBoards = [ + // Get unique boards and lists for filter options (memoized) + const uniqueBoards = useMemo(() => [ ...new Set( tasks .map((task) => task.board_name) .filter((name): name is string => Boolean(name)) ), - ]; - const uniqueLists = [ + ], [tasks]); + + const uniqueLists = useMemo(() => [ ...new Set( tasks .map((task) => task.list_name) .filter((name): name is string => Boolean(name)) ), - ]; + ], [tasks]); // Calculate dropdown position const calculateDropdownPosition = useCallback(() => { @@ -1812,7 +1813,7 @@ export function TimerControls({ (event.key === 'ArrowDown' || event.key === 'ArrowUp') ) { event.preventDefault(); - const filteredTasks = getFilteredTasks(); + // filteredTasks is already memoized if (filteredTasks.length === 0) return; const currentIndex = filteredTasks.findIndex( @@ -3481,7 +3482,7 @@ export function TimerControls({ taskFilters.assignee !== 'all') && (
- {getFilteredTasks().length} of{' '} + {filteredTasks.length} of{' '} {tasks.length} tasks - + )} {/* Cards Container */} -
maxVisibleCards ? "mx-8" : "mx-0")}> -
+
maxVisibleCards ? 'mx-8' : 'mx-0' + )} + > +
{visibleCards.map((card) => { if (card.type === 'summary' && card.data) { return ; @@ -1167,7 +1318,7 @@ export function ActivityHeatmap({ if (card.type === 'monthly' && card.data) { return ( - maxVisibleCards && ( -
- {Array.from({ length: Math.ceil(totalCards / maxVisibleCards) }, (_, i) => ( -
)}
); }; - const buildCardsList = useCallback((monthlyStats: ReturnType) => { - const { sortedMonths, monthsWithTrends, overallStats } = monthlyStats; - const allCards = []; - - // Determine if user is "established" enough to show upcoming month suggestions - const isEstablishedUser = overallStats.activeDays >= 7 && overallStats.totalSessions >= 10 && sortedMonths.length >= 1; - const hasRecentActivity = sortedMonths.length > 0 && dayjs().diff(dayjs().startOf('month'), 'day') < 15; - const shouldShowUpcoming = isEstablishedUser && hasRecentActivity; - - // Add summary card if we have meaningful data - if (sortedMonths.length > 0 && overallStats.activeDays >= 3) { - allCards.push({ - type: 'summary', - data: overallStats - }); - } + const buildCardsList = useCallback( + (monthlyStats: ReturnType) => { + const { sortedMonths, monthsWithTrends, overallStats } = monthlyStats; + const allCards = []; + + // Determine if user is "established" enough to show upcoming month suggestions + const isEstablishedUser = + overallStats.activeDays >= 7 && + overallStats.totalSessions >= 10 && + sortedMonths.length >= 1; + const hasRecentActivity = + sortedMonths.length > 0 && + dayjs().diff(dayjs().startOf('month'), 'day') < 15; + const shouldShowUpcoming = isEstablishedUser && hasRecentActivity; + + // Add summary card if we have meaningful data + if (sortedMonths.length > 0 && overallStats.activeDays >= 3) { + allCards.push({ + type: 'summary', + data: overallStats, + }); + } - // Add monthly data cards - monthsWithTrends.forEach(({ monthKey, data, trend, trendValue }) => { - allCards.push({ - type: 'monthly', - monthKey, - data, - trend, - trendValue + // Add monthly data cards + monthsWithTrends.forEach(({ monthKey, data, trend, trendValue }) => { + allCards.push({ + type: 'monthly', + monthKey, + data, + trend, + trendValue, + }); }); - }); - // Only add upcoming months if user is established and we have space - if (shouldShowUpcoming && allCards.length < 4) { - const currentMonth = dayjs(); - const nextMonth = currentMonth.add(1, 'month'); - - allCards.push({ - type: 'upcoming', - monthKey: nextMonth.format('YYYY-MM'), - name: nextMonth.format('MMM YYYY'), - isSubtle: true - }); - } + // Only add upcoming months if user is established and we have space + if (shouldShowUpcoming && allCards.length < 4) { + const currentMonth = dayjs(); + const nextMonth = currentMonth.add(1, 'month'); - // Add getting started card if no meaningful data - if (sortedMonths.length === 0 || overallStats.activeDays < 3) { - allCards.unshift({ - type: 'getting-started' - }); - } + allCards.push({ + type: 'upcoming', + monthKey: nextMonth.format('YYYY-MM'), + name: nextMonth.format('MMM YYYY'), + isSubtle: true, + }); + } - return allCards; - }, [formatDuration, getIntensity, getColorClass]); + // Add getting started card if no meaningful data + if (sortedMonths.length === 0 || overallStats.activeDays < 3) { + allCards.unshift({ + type: 'getting-started', + }); + } + + return allCards; + }, + [formatDuration, getIntensity, getColorClass] + ); const renderCompactCards = () => { const monthlyData = processMonthlyData(); @@ -1282,20 +1444,25 @@ export function ActivityHeatmap({ const scrollLeft = () => { if (canScrollLeft) { - setCurrentIndex(prev => Math.max(0, prev - 1)); + setCurrentIndex((prev) => Math.max(0, prev - 1)); } }; const scrollRight = () => { if (canScrollRight) { - setCurrentIndex(prev => Math.min(totalCards - maxVisibleCards, prev + 1)); + setCurrentIndex((prev) => + Math.min(totalCards - maxVisibleCards, prev + 1) + ); } }; - const visibleCards = allCards.slice(currentIndex, currentIndex + maxVisibleCards); + const visibleCards = allCards.slice( + currentIndex, + currentIndex + maxVisibleCards + ); return ( - - - Display Mode + + Display Mode + {externalSettings && (
Controlled by Timer Settings
)} - !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'original' }))} + onClick={() => + !externalSettings && + setInternalSettings((prev) => ({ + ...prev, + viewMode: 'original', + })) + } disabled={!!externalSettings} > Original Grid - {settings.viewMode === 'original' && } + {settings.viewMode === 'original' && ( + + )} - !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'hybrid' }))} + onClick={() => + !externalSettings && + setInternalSettings((prev) => ({ + ...prev, + viewMode: 'hybrid', + })) + } disabled={!!externalSettings} > Hybrid View - {settings.viewMode === 'hybrid' && } + {settings.viewMode === 'hybrid' && ( + + )} - !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'calendar-only' }))} + onClick={() => + !externalSettings && + setInternalSettings((prev) => ({ + ...prev, + viewMode: 'calendar-only', + })) + } disabled={!!externalSettings} > Calendar Only - {settings.viewMode === 'calendar-only' && } + {settings.viewMode === 'calendar-only' && ( + + )} - !externalSettings && setInternalSettings(prev => ({ ...prev, viewMode: 'compact-cards' }))} + onClick={() => + !externalSettings && + setInternalSettings((prev) => ({ + ...prev, + viewMode: 'compact-cards', + })) + } disabled={!!externalSettings} > Compact Cards - {settings.viewMode === 'compact-cards' && } + {settings.viewMode === 'compact-cards' && ( + + )} Options - !externalSettings && setInternalSettings(prev => ({ - ...prev, - timeReference: checked ? 'smart' : 'relative' + onCheckedChange={(checked) => + !externalSettings && + setInternalSettings((prev) => ({ + ...prev, + timeReference: checked ? 'smart' : 'relative', })) } disabled={!!externalSettings} @@ -1388,8 +1590,12 @@ export function ActivityHeatmap({ - !externalSettings && setInternalSettings(prev => ({ ...prev, showOnboardingTips: checked })) + onCheckedChange={(checked) => + !externalSettings && + setInternalSettings((prev) => ({ + ...prev, + showOnboardingTips: checked, + })) } disabled={!!externalSettings} > @@ -1397,7 +1603,7 @@ export function ActivityHeatmap({
- + {/* Legend */}
Less @@ -1422,37 +1628,45 @@ export function ActivityHeatmap({ {shouldShowOnboardingTips && (
- -
+ +

- 💡 {settings.viewMode === 'original' && 'GitHub-style Heatmap'} + 💡{' '} + {settings.viewMode === 'original' && 'GitHub-style Heatmap'} {settings.viewMode === 'hybrid' && 'Interactive Hybrid View'} - {settings.viewMode === 'calendar-only' && 'Monthly Calendar View'} - {settings.viewMode === 'compact-cards' && 'Compact Card Overview'} + {settings.viewMode === 'calendar-only' && + 'Monthly Calendar View'} + {settings.viewMode === 'compact-cards' && + 'Compact Card Overview'}

{onboardingState.viewCount > 0 && ( - + View #{onboardingState.viewCount + 1} )}
-

- {settings.viewMode === 'original' && "Track your productivity with GitHub-style visualization. Darker squares = more active days. Hover over any day for details, and use the View menu above to explore other layouts!"} - {settings.viewMode === 'hybrid' && "Best of both worlds! Click any month bar in the year overview to jump to that month's detailed calendar below. Perfect for spotting patterns across the year."} - {settings.viewMode === 'calendar-only' && "Navigate months with arrow buttons to see your activity patterns. Each colored square represents your activity level that day. Great for detailed daily analysis."} - {settings.viewMode === 'compact-cards' && "Monthly summaries at a glance! Each card shows key stats with a mini heatmap preview. Scroll horizontally to see different months and track your progress over time."} +

+ {settings.viewMode === 'original' && + 'Track your productivity with GitHub-style visualization. Darker squares = more active days. Hover over any day for details, and use the View menu above to explore other layouts!'} + {settings.viewMode === 'hybrid' && + "Best of both worlds! Click any month bar in the year overview to jump to that month's detailed calendar below. Perfect for spotting patterns across the year."} + {settings.viewMode === 'calendar-only' && + 'Navigate months with arrow buttons to see your activity patterns. Each colored square represents your activity level that day. Great for detailed daily analysis.'} + {settings.viewMode === 'compact-cards' && + 'Monthly summaries at a glance! Each card shows key stats with a mini heatmap preview. Scroll horizontally to see different months and track your progress over time.'}

{onboardingState.viewCount >= 3 && ( -

- 💭 Tip: You can always toggle these tips on/off in the View menu or Settings panel. +

+ 💭 Tip: You can always toggle these tips on/off in the View + menu or Settings panel.

)}
- {renderHeatmapSection(desktopFirstRow, 'desktop-first', false)} + {renderHeatmapSection( + desktopFirstRow, + 'desktop-first', + false + )}
@@ -1541,7 +1759,11 @@ export function ActivityHeatmap({ Fri
- {renderHeatmapSection(desktopSecondRow, 'desktop-second', false)} + {renderHeatmapSection( + desktopSecondRow, + 'desktop-second', + false + )}
@@ -1555,7 +1777,7 @@ export function ActivityHeatmap({
{renderYearOverview()}
- + {/* Monthly Calendar */}
{renderMonthlyCalendar()} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx index 2ee303699..64c0fa500 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/goal-manager.tsx @@ -56,8 +56,6 @@ import { Switch } from '@tuturuuu/ui/switch'; import { cn } from '@tuturuuu/utils/format'; import { useState } from 'react'; - - interface GoalManagerProps { wsId: string; goals: TimeTrackingGoal[] | null; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 880bc39ac..2c8996dda 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -31,6 +31,8 @@ import { } from '@tuturuuu/ui/dropdown-menu'; import { BarChart2, + Brain, + Briefcase, CheckCircle, ChevronDown, ChevronLeft, @@ -47,13 +49,11 @@ import { RefreshCw, RotateCcw, Search, + Star, + Sun, Tag, Trash2, TrendingUp, - Sun, - Briefcase, - Star, - Brain, } from '@tuturuuu/ui/icons'; import { Input } from '@tuturuuu/ui/input'; import { Label } from '@tuturuuu/ui/label'; @@ -256,36 +256,52 @@ const StackedSessionItem: FC<{ stackedSession.sessions[stackedSession.sessions.length - 1]!; // Calculate average focus score for stacked sessions - const avgFocusScore = stackedSession.isStacked - ? Math.round(stackedSession.sessions.reduce((sum, s) => sum + calculateFocusScore(s), 0) / stackedSession.sessions.length) + const avgFocusScore = stackedSession.isStacked + ? Math.round( + stackedSession.sessions.reduce( + (sum, s) => sum + calculateFocusScore(s), + 0 + ) / stackedSession.sessions.length + ) : calculateFocusScore(latestSession); const productivityType = getSessionProductivityType(latestSession); // Limit how many sessions to show initially const INITIAL_SESSION_LIMIT = 3; - const hasMoreSessions = stackedSession.sessions.length > INITIAL_SESSION_LIMIT; - const visibleSessions = showAllSessions - ? stackedSession.sessions + const hasMoreSessions = + stackedSession.sessions.length > INITIAL_SESSION_LIMIT; + const visibleSessions = showAllSessions + ? stackedSession.sessions : stackedSession.sessions.slice(0, INITIAL_SESSION_LIMIT); const getProductivityIcon = (type: string) => { switch (type) { - case 'deep-work': return '🧠'; - case 'focused': return '🎯'; - case 'scattered': return '🔀'; - case 'interrupted': return '⚡'; - default: return '📋'; + case 'deep-work': + return '🧠'; + case 'focused': + return '🎯'; + case 'scattered': + return '🔀'; + case 'interrupted': + return '⚡'; + default: + return '📋'; } }; const getProductivityColor = (type: string) => { switch (type) { - case 'deep-work': return 'text-green-700 bg-green-100 border-green-300 dark:text-green-300 dark:bg-green-950/30 dark:border-green-800'; - case 'focused': return 'text-blue-700 bg-blue-100 border-blue-300 dark:text-blue-300 dark:bg-blue-950/30 dark:border-blue-800'; - case 'scattered': return 'text-yellow-700 bg-yellow-100 border-yellow-300 dark:text-yellow-300 dark:bg-yellow-950/30 dark:border-yellow-800'; - case 'interrupted': return 'text-red-700 bg-red-100 border-red-300 dark:text-red-300 dark:bg-red-950/30 dark:border-red-800'; - default: return 'text-gray-700 bg-gray-100 border-gray-300 dark:text-gray-300 dark:bg-gray-950/30 dark:border-gray-800'; + case 'deep-work': + return 'text-green-700 bg-green-100 border-green-300 dark:text-green-300 dark:bg-green-950/30 dark:border-green-800'; + case 'focused': + return 'text-blue-700 bg-blue-100 border-blue-300 dark:text-blue-300 dark:bg-blue-950/30 dark:border-blue-800'; + case 'scattered': + return 'text-yellow-700 bg-yellow-100 border-yellow-300 dark:text-yellow-300 dark:bg-yellow-950/30 dark:border-yellow-800'; + case 'interrupted': + return 'text-red-700 bg-red-100 border-red-300 dark:text-red-300 dark:bg-red-950/30 dark:border-red-800'; + default: + return 'text-gray-700 bg-gray-100 border-gray-300 dark:text-gray-300 dark:bg-gray-950/30 dark:border-gray-800'; } }; @@ -338,26 +354,35 @@ const StackedSessionItem: FC<{
)} - + {/* Focus Score Badge */} -
= 80 ? "text-green-700 bg-green-100 border-green-300 dark:text-green-300 dark:bg-green-950/30 dark:border-green-800" : - avgFocusScore >= 60 ? "text-blue-700 bg-blue-100 border-blue-300 dark:text-blue-300 dark:bg-blue-950/30 dark:border-blue-800" : - avgFocusScore >= 40 ? "text-yellow-700 bg-yellow-100 border-yellow-300 dark:text-yellow-300 dark:bg-yellow-950/30 dark:border-yellow-800" : - "text-red-700 bg-red-100 border-red-300 dark:text-red-300 dark:bg-red-950/30 dark:border-red-800" - )}> +
= 80 + ? 'border-green-300 bg-green-100 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300' + : avgFocusScore >= 60 + ? 'border-blue-300 bg-blue-100 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300' + : avgFocusScore >= 40 + ? 'border-yellow-300 bg-yellow-100 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-300' + : 'border-red-300 bg-red-100 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300' + )} + > Focus {avgFocusScore}
{/* Productivity Type Badge */} -
+
{getProductivityIcon(productivityType)} - {productivityType.replace('-', ' ')} + + {productivityType.replace('-', ' ')} +
@@ -572,10 +597,14 @@ const StackedSessionItem: FC<{
{/* Sessions container with scroll for many sessions */} -
6 && showAllSessions && "max-h-96 overflow-y-auto pr-2" - )}> +
6 && + showAllSessions && + 'max-h-96 overflow-y-auto pr-2' + )} + > {visibleSessions.map((session, index) => { const sessionStart = dayjs .utc(session.start_time) @@ -586,8 +615,12 @@ const StackedSessionItem: FC<{ // Calculate gap from previous session (considering the full session list for gaps) const actualIndex = showAllSessions ? index : index; - const prevSession = actualIndex > 0 ? - (showAllSessions ? stackedSession.sessions[index - 1] : stackedSession.sessions[index - 1]) : null; + const prevSession = + actualIndex > 0 + ? showAllSessions + ? stackedSession.sessions[index - 1] + : stackedSession.sessions[index - 1] + : null; const gapInSeconds = prevSession && prevSession.end_time ? sessionStart.diff( @@ -724,7 +757,10 @@ const StackedSessionItem: FC<{ {session.is_running && (
- +
Running @@ -779,12 +815,16 @@ const StackedSessionItem: FC<{ {showAllSessions ? ( <> - Show less ({INITIAL_SESSION_LIMIT} of {stackedSession.sessions.length}) + Show less ({INITIAL_SESSION_LIMIT} of{' '} + {stackedSession.sessions.length}) ) : ( <> - Show {stackedSession.sessions.length - INITIAL_SESSION_LIMIT} more sessions + Show{' '} + {stackedSession.sessions.length - + INITIAL_SESSION_LIMIT}{' '} + more sessions )} @@ -814,8 +854,10 @@ export function SessionHistory({ const [filterDuration, setFilterDuration] = useState('all'); const [filterProductivity, setFilterProductivity] = useState('all'); const [filterTimeOfDay, setFilterTimeOfDay] = useState('all'); - const [filterProjectContext, setFilterProjectContext] = useState('all'); - const [filterSessionQuality, setFilterSessionQuality] = useState('all'); + const [filterProjectContext, setFilterProjectContext] = + useState('all'); + const [filterSessionQuality, setFilterSessionQuality] = + useState('all'); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [sessionToDelete, setSessionToDelete] = useState(null); @@ -835,30 +877,45 @@ export function SessionHistory({ // Advanced analytics functions const calculateFocusScore = (session: SessionWithRelations): number => { if (!session.duration_seconds) return 0; - + // Base score from duration (longer sessions = higher focus) const durationScore = Math.min(session.duration_seconds / 7200, 1) * 40; // Max 40 points for 2+ hours - + // Bonus for consistency (sessions without interruptions) const consistencyBonus = session.was_resumed ? 0 : 20; - + // Time of day bonus (peak hours get bonus) const sessionHour = dayjs.utc(session.start_time).tz(userTimezone).hour(); - const peakHoursBonus = (sessionHour >= 9 && sessionHour <= 11) || (sessionHour >= 14 && sessionHour <= 16) ? 20 : 0; - + const peakHoursBonus = + (sessionHour >= 9 && sessionHour <= 11) || + (sessionHour >= 14 && sessionHour <= 16) + ? 20 + : 0; + // Category bonus (work categories get slight bonus) - const categoryBonus = session.category?.name?.toLowerCase().includes('work') ? 10 : 0; - + const categoryBonus = session.category?.name?.toLowerCase().includes('work') + ? 10 + : 0; + // Task completion bonus const taskBonus = session.task_id ? 10 : 0; - - return Math.min(durationScore + consistencyBonus + peakHoursBonus + categoryBonus + taskBonus, 100); + + return Math.min( + durationScore + + consistencyBonus + + peakHoursBonus + + categoryBonus + + taskBonus, + 100 + ); }; - const getSessionProductivityType = (session: SessionWithRelations): string => { + const getSessionProductivityType = ( + session: SessionWithRelations + ): string => { const duration = session.duration_seconds || 0; const focusScore = calculateFocusScore(session); - + if (focusScore >= 80 && duration >= 3600) return 'deep-work'; if (focusScore >= 60 && duration >= 1800) return 'focused'; if (duration < 900 && focusScore < 40) return 'interrupted'; @@ -876,12 +933,15 @@ export function SessionHistory({ const getProjectContext = (session: SessionWithRelations): string => { if (session.task_id) { - const task = tasks.find(t => t.id === session.task_id); + const task = tasks.find((t) => t.id === session.task_id); return task?.board_name || 'project-work'; } - if (session.category?.name?.toLowerCase().includes('meeting')) return 'meetings'; - if (session.category?.name?.toLowerCase().includes('learn')) return 'learning'; - if (session.category?.name?.toLowerCase().includes('admin')) return 'administrative'; + if (session.category?.name?.toLowerCase().includes('meeting')) + return 'meetings'; + if (session.category?.name?.toLowerCase().includes('learn')) + return 'learning'; + if (session.category?.name?.toLowerCase().includes('admin')) + return 'administrative'; return 'general'; }; @@ -940,37 +1000,62 @@ export function SessionHistory({ ) { return false; } - + // Category filter if ( filterCategoryId !== 'all' && session.category_id !== filterCategoryId ) return false; - + // Duration filter - if (filterDuration !== 'all' && getDurationCategory(session) !== filterDuration) + if ( + filterDuration !== 'all' && + getDurationCategory(session) !== filterDuration + ) return false; - + // Productivity filter - if (filterProductivity !== 'all' && getSessionProductivityType(session) !== filterProductivity) + if ( + filterProductivity !== 'all' && + getSessionProductivityType(session) !== filterProductivity + ) return false; - + // Time of day filter - if (filterTimeOfDay !== 'all' && getTimeOfDayCategory(session) !== filterTimeOfDay) + if ( + filterTimeOfDay !== 'all' && + getTimeOfDayCategory(session) !== filterTimeOfDay + ) return false; - + // Project context filter - if (filterProjectContext !== 'all' && getProjectContext(session) !== filterProjectContext) + if ( + filterProjectContext !== 'all' && + getProjectContext(session) !== filterProjectContext + ) return false; - + // Session quality filter - if (filterSessionQuality !== 'all' && getSessionQuality(session) !== filterSessionQuality) + if ( + filterSessionQuality !== 'all' && + getSessionQuality(session) !== filterSessionQuality + ) return false; - + return true; }), - [sessions, searchQuery, filterCategoryId, filterDuration, filterProductivity, filterTimeOfDay, filterProjectContext, filterSessionQuality, tasks] + [ + sessions, + searchQuery, + filterCategoryId, + filterDuration, + filterProductivity, + filterTimeOfDay, + filterProjectContext, + filterSessionQuality, + tasks, + ] ); const { startOfPeriod, endOfPeriod } = useMemo(() => { @@ -1002,37 +1087,72 @@ export function SessionHistory({ } = {}; // Enhanced analytics - const focusScores = sessionsForPeriod.map(s => calculateFocusScore(s)); - const avgFocusScore = focusScores.length > 0 ? focusScores.reduce((sum, score) => sum + score, 0) / focusScores.length : 0; - + const focusScores = sessionsForPeriod.map((s) => calculateFocusScore(s)); + const avgFocusScore = + focusScores.length > 0 + ? focusScores.reduce((sum, score) => sum + score, 0) / + focusScores.length + : 0; + const productivityBreakdown = { - 'deep-work': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'deep-work').length, - 'focused': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'focused').length, - 'standard': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'standard').length, - 'scattered': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'scattered').length, - 'interrupted': sessionsForPeriod.filter(s => getSessionProductivityType(s) === 'interrupted').length, + 'deep-work': sessionsForPeriod.filter( + (s) => getSessionProductivityType(s) === 'deep-work' + ).length, + focused: sessionsForPeriod.filter( + (s) => getSessionProductivityType(s) === 'focused' + ).length, + standard: sessionsForPeriod.filter( + (s) => getSessionProductivityType(s) === 'standard' + ).length, + scattered: sessionsForPeriod.filter( + (s) => getSessionProductivityType(s) === 'scattered' + ).length, + interrupted: sessionsForPeriod.filter( + (s) => getSessionProductivityType(s) === 'interrupted' + ).length, }; const timeOfDayBreakdown = { - morning: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'morning').length, - afternoon: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'afternoon').length, - evening: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'evening').length, - night: sessionsForPeriod.filter(s => getTimeOfDayCategory(s) === 'night').length, + morning: sessionsForPeriod.filter( + (s) => getTimeOfDayCategory(s) === 'morning' + ).length, + afternoon: sessionsForPeriod.filter( + (s) => getTimeOfDayCategory(s) === 'afternoon' + ).length, + evening: sessionsForPeriod.filter( + (s) => getTimeOfDayCategory(s) === 'evening' + ).length, + night: sessionsForPeriod.filter( + (s) => getTimeOfDayCategory(s) === 'night' + ).length, }; - const bestTimeOfDay = Object.entries(timeOfDayBreakdown).reduce((a, b) => - timeOfDayBreakdown[a[0] as keyof typeof timeOfDayBreakdown] > timeOfDayBreakdown[b[0] as keyof typeof timeOfDayBreakdown] ? a : b + const bestTimeOfDay = Object.entries(timeOfDayBreakdown).reduce((a, b) => + timeOfDayBreakdown[a[0] as keyof typeof timeOfDayBreakdown] > + timeOfDayBreakdown[b[0] as keyof typeof timeOfDayBreakdown] + ? a + : b )[0]; - const longestSession = sessionsForPeriod.length > 0 - ? sessionsForPeriod.reduce((longest, session) => - (session.duration_seconds || 0) > (longest.duration_seconds || 0) ? session : longest - ) - : null; + const longestSession = + sessionsForPeriod.length > 0 + ? sessionsForPeriod.reduce((longest, session) => + (session.duration_seconds || 0) > (longest.duration_seconds || 0) + ? session + : longest + ) + : null; - const shortSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) < 1800).length; - const mediumSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) >= 1800 && (s.duration_seconds || 0) < 7200).length; - const longSessions = sessionsForPeriod.filter(s => (s.duration_seconds || 0) >= 7200).length; + const shortSessions = sessionsForPeriod.filter( + (s) => (s.duration_seconds || 0) < 1800 + ).length; + const mediumSessions = sessionsForPeriod.filter( + (s) => + (s.duration_seconds || 0) >= 1800 && (s.duration_seconds || 0) < 7200 + ).length; + const longSessions = sessionsForPeriod.filter( + (s) => (s.duration_seconds || 0) >= 7200 + ).length; sessionsForPeriod.forEach((s) => { const id = s.category?.id || 'uncategorized'; @@ -1049,9 +1169,9 @@ export function SessionHistory({ .filter((c) => c.duration > 0) .sort((a, b) => b.duration - a.duration); - return { - totalDuration, - breakdown, + return { + totalDuration, + breakdown, avgFocusScore, productivityBreakdown, timeOfDayBreakdown, @@ -1060,7 +1180,7 @@ export function SessionHistory({ shortSessions, mediumSessions, longSessions, - sessionCount: sessionsForPeriod.length + sessionCount: sessionsForPeriod.length, }; }, [sessionsForPeriod]); @@ -1295,76 +1415,93 @@ export function SessionHistory({
-

Advanced Analytics Filters

+

+ Advanced Analytics Filters +

- + {/* Basic Filters */}
-
-
{/* Advanced Filters */} {showAdvancedFilters && (
-
-
-
-
)} -
+
- + {/* Quick Analytics Preview */} {filteredSessions.length > 0 && ( -
-
📊 Filter Analytics
+
+
+ 📊 Filter Analytics +
-
{filteredSessions.length}
-
Sessions
+
+ {filteredSessions.length} +
+
+ Sessions +
{filteredSessions.length > 0 ? Math.round( filteredSessions.reduce( - (sum, s) => sum + calculateFocusScore(s), + (sum, s) => + sum + calculateFocusScore(s), 0 ) / filteredSessions.length ) : 0}
-
Avg Focus
+
+ Avg Focus +
@@ -1682,21 +1868,29 @@ export function SessionHistory({ Average Focus Score
-
= 80 ? "bg-green-500 dark:bg-green-600" : - periodStats.avgFocusScore >= 60 ? "bg-yellow-500 dark:bg-yellow-600" : - periodStats.avgFocusScore >= 40 ? "bg-orange-500 dark:bg-orange-600" : "bg-red-500 dark:bg-red-600" - )}> -
= 80 + ? 'bg-green-500 dark:bg-green-600' + : periodStats.avgFocusScore >= 60 + ? 'bg-yellow-500 dark:bg-yellow-600' + : periodStats.avgFocusScore >= 40 + ? 'bg-orange-500 dark:bg-orange-600' + : 'bg-red-500 dark:bg-red-600' + )} + > +
- + {Math.round(periodStats.avgFocusScore)} - -
+ +
{/* Best Time of Day */} @@ -1705,28 +1899,46 @@ export function SessionHistory({ Most Productive Time - {periodStats.bestTimeOfDay === 'morning' && '🌅 Morning'} - {periodStats.bestTimeOfDay === 'afternoon' && '☀️ Afternoon'} - {periodStats.bestTimeOfDay === 'evening' && '🌇 Evening'} - {periodStats.bestTimeOfDay === 'night' && '🌙 Night'} + {periodStats.bestTimeOfDay === 'morning' && + '🌅 Morning'} + {periodStats.bestTimeOfDay === 'afternoon' && + '☀️ Afternoon'} + {periodStats.bestTimeOfDay === 'evening' && + '🌇 Evening'} + {periodStats.bestTimeOfDay === 'night' && + '🌙 Night'}
{/* Session Types Breakdown */}
-
Session Types
+
+ Session Types +
-
{periodStats.longSessions}
-
Deep (2h+)
+
+ {periodStats.longSessions} +
+
+ Deep (2h+) +
-
{periodStats.mediumSessions}
-
Focus (30m-2h)
+
+ {periodStats.mediumSessions} +
+
+ Focus (30m-2h) +
-
{periodStats.shortSessions}
-
Quick (<30m)
+
+ {periodStats.shortSessions} +
+
+ Quick (<30m) +
@@ -1734,42 +1946,70 @@ export function SessionHistory({ {/* Longest Session Highlight */} {periodStats.longestSession && (
-
🏆 Longest Session
-
{periodStats.longestSession.title}
+
+ 🏆 Longest Session +
+
+ {periodStats.longestSession.title} +
- {formatDuration(periodStats.longestSession.duration_seconds || 0)} • - Focus: {Math.round(calculateFocusScore(periodStats.longestSession))} + {formatDuration( + periodStats.longestSession.duration_seconds || 0 + )}{' '} + • Focus:{' '} + {Math.round( + calculateFocusScore(periodStats.longestSession) + )}
)} {/* Productivity Pattern */}
-
Work Pattern
+
+ Work Pattern +
- {Object.entries(periodStats.productivityBreakdown).map(([type, count]) => { + {Object.entries( + periodStats.productivityBreakdown + ).map(([type, count]) => { const total = periodStats.sessionCount; - const percentage = total > 0 ? (count / total) * 100 : 0; + const percentage = + total > 0 ? (count / total) * 100 : 0; return percentage > 0 ? (
) : null; })} -
+
- 🧠 Deep: {periodStats.productivityBreakdown['deep-work']} - 🎯 Focus: {periodStats.productivityBreakdown['focused']} - ⚡ Quick: {periodStats.productivityBreakdown['interrupted']} + + 🧠 Deep:{' '} + {periodStats.productivityBreakdown['deep-work']} + + + 🎯 Focus:{' '} + {periodStats.productivityBreakdown['focused']} + + + ⚡ Quick:{' '} + {periodStats.productivityBreakdown['interrupted']} +
@@ -1778,89 +2018,147 @@ export function SessionHistory({ {/* AI Insights Section */}

-
+
AI Productivity Insights

- {(() => { + {(() => { const insights = []; - + // Focus Score Analysis if (periodStats.avgFocusScore >= 80) { - insights.push("🎯 Excellent focus this month! You're maintaining deep work consistently."); + insights.push( + "🎯 Excellent focus this month! You're maintaining deep work consistently." + ); } else if (periodStats.avgFocusScore >= 60) { - insights.push("👍 Good focus patterns. Consider blocking longer time chunks for deeper work."); + insights.push( + '👍 Good focus patterns. Consider blocking longer time chunks for deeper work.' + ); } else if (periodStats.avgFocusScore < 40) { - insights.push("💡 Focus opportunity: Try the 25-minute Pomodoro technique for better concentration."); + insights.push( + '💡 Focus opportunity: Try the 25-minute Pomodoro technique for better concentration.' + ); } // Session Length Analysis - const deepWorkRatio = periodStats.longSessions / Math.max(1, periodStats.sessionCount); + const deepWorkRatio = + periodStats.longSessions / + Math.max(1, periodStats.sessionCount); if (deepWorkRatio > 0.3) { - insights.push("🏔️ Great job on deep work sessions! You're building strong focus habits."); - } else if (periodStats.shortSessions > periodStats.longSessions + periodStats.mediumSessions) { - insights.push("⚡ Many short sessions detected. Consider batching similar tasks for efficiency."); + insights.push( + "🏔️ Great job on deep work sessions! You're building strong focus habits." + ); + } else if ( + periodStats.shortSessions > + periodStats.longSessions + + periodStats.mediumSessions + ) { + insights.push( + '⚡ Many short sessions detected. Consider batching similar tasks for efficiency.' + ); } // Time of Day Analysis if (periodStats.bestTimeOfDay === 'morning') { - insights.push("🌅 You're a morning person! Schedule your most important work before 11 AM."); + insights.push( + "🌅 You're a morning person! Schedule your most important work before 11 AM." + ); } else if (periodStats.bestTimeOfDay === 'night') { - insights.push("🌙 Night owl detected! Just ensure you're getting enough rest for sustained productivity."); + insights.push( + "🌙 Night owl detected! Just ensure you're getting enough rest for sustained productivity." + ); } // Productivity Type Analysis - const interruptedRatio = periodStats.productivityBreakdown['interrupted'] / Math.max(1, periodStats.sessionCount); + const interruptedRatio = + periodStats.productivityBreakdown['interrupted'] / + Math.max(1, periodStats.sessionCount); if (interruptedRatio > 0.3) { - insights.push("🔕 High interruption rate detected. Try enabling 'Do Not Disturb' mode during work blocks."); + insights.push( + "🔕 High interruption rate detected. Try enabling 'Do Not Disturb' mode during work blocks." + ); } - const deepWorkCount = periodStats.productivityBreakdown['deep-work']; - const focusedCount = periodStats.productivityBreakdown['focused']; - if (deepWorkCount + focusedCount > periodStats.sessionCount * 0.6) { - insights.push("🧠 Outstanding focused work ratio! You're in the productivity zone."); + const deepWorkCount = + periodStats.productivityBreakdown['deep-work']; + const focusedCount = + periodStats.productivityBreakdown['focused']; + if ( + deepWorkCount + focusedCount > + periodStats.sessionCount * 0.6 + ) { + insights.push( + "🧠 Outstanding focused work ratio! You're in the productivity zone." + ); } // Consistency Analysis const activeDays = new Set( sessionsForPeriod.map((s) => - dayjs.utc(s.start_time).tz(userTimezone).format('YYYY-MM-DD') + dayjs + .utc(s.start_time) + .tz(userTimezone) + .format('YYYY-MM-DD') ) ).size; const daysInPeriod = currentDate.daysInMonth(); const consistencyRatio = activeDays / daysInPeriod; - + if (consistencyRatio > 0.8) { - insights.push("🔥 Amazing consistency! You're showing up almost every day."); + insights.push( + "🔥 Amazing consistency! You're showing up almost every day." + ); } else if (consistencyRatio < 0.3) { - insights.push("📅 Opportunity for more consistency. Even 15 minutes daily builds momentum."); + insights.push( + '📅 Opportunity for more consistency. Even 15 minutes daily builds momentum.' + ); } // Duration vs Focus Correlation - const avgDurationPerSession = periodStats.totalDuration / Math.max(1, periodStats.sessionCount); - if (avgDurationPerSession > 7200 && periodStats.avgFocusScore > 70) { - insights.push("🏆 Perfect combo: Long sessions with high focus. You've mastered deep work!"); + const avgDurationPerSession = + periodStats.totalDuration / + Math.max(1, periodStats.sessionCount); + if ( + avgDurationPerSession > 7200 && + periodStats.avgFocusScore > 70 + ) { + insights.push( + "🏆 Perfect combo: Long sessions with high focus. You've mastered deep work!" + ); } return insights.slice(0, 3); // Show max 3 insights })().map((insight, index) => ( -
+
- {insight} + + {insight} +
))} - + {(() => { // Predictive suggestion based on patterns const totalHours = periodStats.totalDuration / 3600; - const avgHoursPerDay = totalHours / Math.max(1, new Set( - sessionsForPeriod.map((s) => - dayjs.utc(s.start_time).tz(userTimezone).format('YYYY-MM-DD') - ) - ).size); - + const avgHoursPerDay = + totalHours / + Math.max( + 1, + new Set( + sessionsForPeriod.map((s) => + dayjs + .utc(s.start_time) + .tz(userTimezone) + .format('YYYY-MM-DD') + ) + ).size + ); + if (avgHoursPerDay > 6) { return (
@@ -1869,14 +2167,16 @@ export function SessionHistory({ Power User Detected!

- You're averaging {avgHoursPerDay.toFixed(1)} hours/day. Consider setting up automated time tracking for even better insights! + You're averaging {avgHoursPerDay.toFixed(1)}{' '} + hours/day. Consider setting up automated time + tracking for even better insights!

); } - + return null; - })()} + })()}
@@ -2088,7 +2388,9 @@ export function SessionHistory({ actionStates={actionStates} tasks={tasks} calculateFocusScore={calculateFocusScore} - getSessionProductivityType={getSessionProductivityType} + getSessionProductivityType={ + getSessionProductivityType + } /> ))}
diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx index e4a2d3e1c..47a71e91e 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/stats-overview.tsx @@ -1,10 +1,10 @@ 'use client'; +import type { TimerStats } from '../types'; import { Card, CardContent } from '@tuturuuu/ui/card'; import { Calendar, Clock, TrendingUp, Zap } from '@tuturuuu/ui/icons'; import { cn } from '@tuturuuu/utils/format'; import { useMemo } from 'react'; -import type { TimerStats } from '../types'; interface StatsOverviewProps { timerStats: TimerStats; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx index f9d67623a..f7fb6f8f3 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/timer-controls.tsx @@ -1,15 +1,16 @@ 'use client'; -import type { ExtendedWorkspaceTask, TaskFilters, SessionWithRelations } from '../types'; +import type { + ExtendedWorkspaceTask, + SessionWithRelations, + TaskFilters, +} from '../types'; import { generateAssigneeInitials, getFilteredAndSortedTasks, useTaskCounts, } from '../utils'; -import type { - TimeTrackingCategory, - WorkspaceTask, -} from '@tuturuuu/types/db'; +import type { TimeTrackingCategory, WorkspaceTask } from '@tuturuuu/types/db'; import { Badge } from '@tuturuuu/ui/badge'; import { Button } from '@tuturuuu/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; @@ -49,8 +50,6 @@ import { Textarea } from '@tuturuuu/ui/textarea'; import { cn } from '@tuturuuu/utils/format'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - - interface SessionTemplate { title: string; description?: string; @@ -129,7 +128,7 @@ interface CountdownState { interface CustomTimerSettings { type: CustomTimerType; - + // Enhanced Stopwatch Settings targetDuration?: number; // in minutes - goal to reach enableIntervalBreaks?: boolean; @@ -138,12 +137,12 @@ interface CustomTimerSettings { showProgressToTarget?: boolean; enableTargetNotification?: boolean; autoStopAtTarget?: boolean; - + // Traditional Countdown Settings countdownDuration?: number; // in minutes autoRestart?: boolean; showTimeRemaining?: boolean; - + // Shared Settings enableBreakReminders?: boolean; playCompletionSound?: boolean; @@ -253,13 +252,16 @@ export function TimerControls({ useState(null); // Enhanced pause/resume state - const [pausedSession, setPausedSession] = useState(null); + const [pausedSession, setPausedSession] = + useState(null); const [pausedElapsedTime, setPausedElapsedTime] = useState(0); const [pauseStartTime, setPauseStartTime] = useState(null); // Pomodoro and timer mode state const [timerMode, setTimerMode] = useState('stopwatch'); - const [pomodoroSettings, setPomodoroSettings] = useState(DEFAULT_POMODORO_SETTINGS); + const [pomodoroSettings, setPomodoroSettings] = useState( + DEFAULT_POMODORO_SETTINGS + ); const [countdownState, setCountdownState] = useState({ targetTime: 25 * 60, // 25 minutes in seconds remainingTime: 25 * 60, @@ -268,54 +270,60 @@ export function TimerControls({ cycleCount: 0, }); - const [customTimerSettings, setCustomTimerSettings] = useState({ - type: 'enhanced-stopwatch', - - // Enhanced Stopwatch defaults - targetDuration: 60, // 1 hour goal - enableIntervalBreaks: true, - intervalBreakDuration: 5, - intervalFrequency: 25, // break every 25 minutes - showProgressToTarget: true, - enableTargetNotification: true, - autoStopAtTarget: false, - - // Traditional Countdown defaults - countdownDuration: 25, - autoRestart: false, - showTimeRemaining: true, - - // Shared defaults - enableBreakReminders: true, - playCompletionSound: true, - showNotifications: true, - enableMotivationalMessages: true, - }); + const [customTimerSettings, setCustomTimerSettings] = + useState({ + type: 'enhanced-stopwatch', + + // Enhanced Stopwatch defaults + targetDuration: 60, // 1 hour goal + enableIntervalBreaks: true, + intervalBreakDuration: 5, + intervalFrequency: 25, // break every 25 minutes + showProgressToTarget: true, + enableTargetNotification: true, + autoStopAtTarget: false, + + // Traditional Countdown defaults + countdownDuration: 25, + autoRestart: false, + showTimeRemaining: true, + + // Shared defaults + enableBreakReminders: true, + playCompletionSound: true, + showNotifications: true, + enableMotivationalMessages: true, + }); const [showPomodoroSettings, setShowPomodoroSettings] = useState(false); const [showCustomSettings, setShowCustomSettings] = useState(false); const [showStopwatchSettings, setShowStopwatchSettings] = useState(false); - + // Enhanced stopwatch state (legacy - kept for hasReachedTarget) const [hasReachedTarget, setHasReachedTarget] = useState(false); - + // Stopwatch settings state - const [stopwatchSettings, setStopwatchSettings] = useState(DEFAULT_STOPWATCH_SETTINGS); - + const [stopwatchSettings, setStopwatchSettings] = useState( + DEFAULT_STOPWATCH_SETTINGS + ); + // Session protection state - const [sessionProtection, setSessionProtection] = useState({ - isActive: false, - currentMode: 'stopwatch', - canSwitchModes: true, - canModifySettings: true, - }); + const [sessionProtection, setSessionProtection] = useState( + { + isActive: false, + currentMode: 'stopwatch', + canSwitchModes: true, + canModifySettings: true, + } + ); // Separate break time tracking for each timer mode - const [stopwatchBreakState, setStopwatchBreakState] = useState({ - lastEyeBreakTime: Date.now(), - lastMovementBreakTime: Date.now(), - lastIntervalBreakTime: Date.now(), - intervalBreaksCount: 0, - }); + const [stopwatchBreakState, setStopwatchBreakState] = + useState({ + lastEyeBreakTime: Date.now(), + lastMovementBreakTime: Date.now(), + lastIntervalBreakTime: Date.now(), + intervalBreaksCount: 0, + }); const [pomodoroBreakState, setPomodoroBreakState] = useState({ lastEyeBreakTime: Date.now(), @@ -339,7 +347,7 @@ export function TimerControls({ pomodoro: null, custom: null, }); - + // Legacy break reminders state (kept for lastNotificationTime only) const [lastNotificationTime, setLastNotificationTime] = useState(0); @@ -348,44 +356,52 @@ export function TimerControls({ const TIMER_MODE_SESSIONS_KEY = `timer-mode-sessions-${wsId}-${currentUserId || 'user'}`; // Helper function to re-fetch session details by ID - const fetchSessionById = useCallback(async (sessionId: string): Promise => { - try { - const response = await apiCall(`/api/v1/workspaces/${wsId}/time-tracking/sessions/${sessionId}`); - return response.session || null; - } catch (error) { - console.warn('Failed to fetch session details:', error); - return null; - } - }, [apiCall, wsId]); - - // Helper functions for localStorage persistence (storing only minimal data) - const savePausedSessionToStorage = useCallback((session: SessionWithRelations, elapsed: number, pauseTime: Date) => { - if (typeof window !== 'undefined') { + const fetchSessionById = useCallback( + async (sessionId: string): Promise => { try { - const pausedData: PausedSessionData = { - sessionId: session.id, - elapsed, - pauseTime: pauseTime.toISOString(), - timerMode, - }; - localStorage.setItem(PAUSED_SESSION_KEY, JSON.stringify(pausedData)); + const response = await apiCall( + `/api/v1/workspaces/${wsId}/time-tracking/sessions/${sessionId}` + ); + return response.session || null; } catch (error) { - console.warn('Failed to save paused session to localStorage:', error); + console.warn('Failed to fetch session details:', error); + return null; } - } - }, [PAUSED_SESSION_KEY, timerMode]); + }, + [apiCall, wsId] + ); + + // Helper functions for localStorage persistence (storing only minimal data) + const savePausedSessionToStorage = useCallback( + (session: SessionWithRelations, elapsed: number, pauseTime: Date) => { + if (typeof window !== 'undefined') { + try { + const pausedData: PausedSessionData = { + sessionId: session.id, + elapsed, + pauseTime: pauseTime.toISOString(), + timerMode, + }; + localStorage.setItem(PAUSED_SESSION_KEY, JSON.stringify(pausedData)); + } catch (error) { + console.warn('Failed to save paused session to localStorage:', error); + } + } + }, + [PAUSED_SESSION_KEY, timerMode] + ); const loadPausedSessionFromStorage = useCallback(async () => { if (typeof window !== 'undefined') { try { const pausedDataStr = localStorage.getItem(PAUSED_SESSION_KEY); - + if (pausedDataStr) { const pausedData: PausedSessionData = JSON.parse(pausedDataStr); - + // Re-fetch the full session details by ID const session = await fetchSessionById(pausedData.sessionId); - + if (session) { const elapsed = pausedData.elapsed; const pauseTime = new Date(pausedData.pauseTime); @@ -393,7 +409,7 @@ export function TimerControls({ setPausedSession(session); setPausedElapsedTime(elapsed); setPauseStartTime(pauseTime); - + // Restore timer mode if different if (pausedData.timerMode !== timerMode) { setTimerMode(pausedData.timerMode); @@ -418,20 +434,26 @@ export function TimerControls({ try { localStorage.removeItem(PAUSED_SESSION_KEY); } catch (error) { - console.warn('Failed to clear paused session from localStorage:', error); + console.warn( + 'Failed to clear paused session from localStorage:', + error + ); } } }, [PAUSED_SESSION_KEY]); // Session protection utilities - const updateSessionProtection = useCallback((isActive: boolean, mode: TimerMode) => { - setSessionProtection({ - isActive, - currentMode: mode, - canSwitchModes: !isActive, - canModifySettings: !isActive, - }); - }, []); + const updateSessionProtection = useCallback( + (isActive: boolean, mode: TimerMode) => { + setSessionProtection({ + isActive, + currentMode: mode, + canSwitchModes: !isActive, + canModifySettings: !isActive, + }); + }, + [] + ); const getCurrentBreakState = useCallback(() => { switch (timerMode) { @@ -446,112 +468,133 @@ export function TimerControls({ } }, [timerMode, stopwatchBreakState, pomodoroBreakState, customBreakState]); - const updateCurrentBreakState = useCallback((updates: Partial) => { - switch (timerMode) { - case 'stopwatch': - setStopwatchBreakState(prev => ({ ...prev, ...updates })); - break; - case 'pomodoro': - setPomodoroBreakState(prev => ({ ...prev, ...updates })); - break; - case 'custom': - setCustomBreakState(prev => ({ ...prev, ...updates })); - break; - } - }, [timerMode]); - - // Safe timer mode switching with validation - const handleTimerModeChange = useCallback((newMode: TimerMode) => { - // Prevent mode switching if session is active - if (sessionProtection.isActive) { - toast.error('Cannot switch timer modes during an active session', { - description: 'Please stop or pause your current timer first.', - duration: 4000, - }); - return; - } - - // Save current mode session state if exists - if (currentSession) { - const currentBreakState = getCurrentBreakState(); - const sessionData: TimerModeSession = { - mode: timerMode, - sessionId: currentSession.id, - startTime: currentSession.start_time ? new Date(currentSession.start_time) : null, - elapsedTime: elapsedTime, - breakTimeState: currentBreakState, - pomodoroState: timerMode === 'pomodoro' ? countdownState : undefined, - customTimerState: timerMode === 'custom' ? { - hasReachedTarget, - targetProgress: elapsedTime / ((customTimerSettings.targetDuration || 60) * 60), - } : undefined, - }; - - setTimerModeSessions(prev => ({ - ...prev, - [timerMode]: sessionData, - })); - } - - // Switch to new mode - setTimerMode(newMode); - - // Restore previous session for new mode if exists - const previousSession = timerModeSessions[newMode]; - if (previousSession && previousSession.sessionId) { - // Restore the session state - setElapsedTime(previousSession.elapsedTime); - - // Restore break state for new mode - switch (newMode) { + const updateCurrentBreakState = useCallback( + (updates: Partial) => { + switch (timerMode) { case 'stopwatch': - setStopwatchBreakState(previousSession.breakTimeState); + setStopwatchBreakState((prev) => ({ ...prev, ...updates })); break; case 'pomodoro': - setPomodoroBreakState(previousSession.breakTimeState); - if (previousSession.pomodoroState) { - setCountdownState(previousSession.pomodoroState); - } + setPomodoroBreakState((prev) => ({ ...prev, ...updates })); break; case 'custom': - setCustomBreakState(previousSession.breakTimeState); - if (previousSession.customTimerState) { - setHasReachedTarget(previousSession.customTimerState.hasReachedTarget); - } + setCustomBreakState((prev) => ({ ...prev, ...updates })); break; } + }, + [timerMode] + ); - toast.success(`Switched to ${newMode} mode`, { - description: `Restored previous session with ${formatDuration(previousSession.elapsedTime)} tracked`, - duration: 3000, - }); - } else { - toast.success(`Switched to ${newMode} mode`, { - description: 'Ready to start a new session', - duration: 2000, - }); - } - }, [ - sessionProtection.isActive, - currentSession, - timerMode, - elapsedTime, - getCurrentBreakState, - countdownState, - hasReachedTarget, - customTimerSettings.targetDuration, - timerModeSessions, - setElapsedTime, - formatDuration, - ]); + // Safe timer mode switching with validation + const handleTimerModeChange = useCallback( + (newMode: TimerMode) => { + // Prevent mode switching if session is active + if (sessionProtection.isActive) { + toast.error('Cannot switch timer modes during an active session', { + description: 'Please stop or pause your current timer first.', + duration: 4000, + }); + return; + } + + // Save current mode session state if exists + if (currentSession) { + const currentBreakState = getCurrentBreakState(); + const sessionData: TimerModeSession = { + mode: timerMode, + sessionId: currentSession.id, + startTime: currentSession.start_time + ? new Date(currentSession.start_time) + : null, + elapsedTime: elapsedTime, + breakTimeState: currentBreakState, + pomodoroState: timerMode === 'pomodoro' ? countdownState : undefined, + customTimerState: + timerMode === 'custom' + ? { + hasReachedTarget, + targetProgress: + elapsedTime / + ((customTimerSettings.targetDuration || 60) * 60), + } + : undefined, + }; + + setTimerModeSessions((prev) => ({ + ...prev, + [timerMode]: sessionData, + })); + } + + // Switch to new mode + setTimerMode(newMode); + + // Restore previous session for new mode if exists + const previousSession = timerModeSessions[newMode]; + if (previousSession && previousSession.sessionId) { + // Restore the session state + setElapsedTime(previousSession.elapsedTime); + + // Restore break state for new mode + switch (newMode) { + case 'stopwatch': + setStopwatchBreakState(previousSession.breakTimeState); + break; + case 'pomodoro': + setPomodoroBreakState(previousSession.breakTimeState); + if (previousSession.pomodoroState) { + setCountdownState(previousSession.pomodoroState); + } + break; + case 'custom': + setCustomBreakState(previousSession.breakTimeState); + if (previousSession.customTimerState) { + setHasReachedTarget( + previousSession.customTimerState.hasReachedTarget + ); + } + break; + } + + toast.success(`Switched to ${newMode} mode`, { + description: `Restored previous session with ${formatDuration(previousSession.elapsedTime)} tracked`, + duration: 3000, + }); + } else { + toast.success(`Switched to ${newMode} mode`, { + description: 'Ready to start a new session', + duration: 2000, + }); + } + }, + [ + sessionProtection.isActive, + currentSession, + timerMode, + elapsedTime, + getCurrentBreakState, + countdownState, + hasReachedTarget, + customTimerSettings.targetDuration, + timerModeSessions, + setElapsedTime, + formatDuration, + ] + ); // Persistence for timer mode sessions const saveTimerModeSessionsToStorage = useCallback(() => { if (typeof window !== 'undefined') { try { - localStorage.setItem(TIMER_MODE_SESSIONS_KEY, JSON.stringify(timerModeSessions)); + localStorage.setItem( + TIMER_MODE_SESSIONS_KEY, + JSON.stringify(timerModeSessions) + ); } catch (error) { - console.warn('Failed to save timer mode sessions to localStorage:', error); + console.warn( + 'Failed to save timer mode sessions to localStorage:', + error + ); } } }, [TIMER_MODE_SESSIONS_KEY, timerModeSessions]); @@ -566,7 +609,10 @@ export function TimerControls({ return sessions; } } catch (error) { - console.warn('Failed to load timer mode sessions from localStorage:', error); + console.warn( + 'Failed to load timer mode sessions from localStorage:', + error + ); } } return null; @@ -577,8 +623,11 @@ export function TimerControls({ const loadData = async () => { const pausedData = await loadPausedSessionFromStorage(); if (pausedData) { - console.log('Restored paused session from localStorage:', pausedData.session.title); - + console.log( + 'Restored paused session from localStorage:', + pausedData.session.title + ); + // Show a toast to let user know their paused session was restored toast.success('Paused session restored!', { description: `${pausedData.session.title} - ${formatDuration(pausedData.elapsed)} tracked`, @@ -591,13 +640,23 @@ export function TimerControls({ }; loadData(); - }, [loadPausedSessionFromStorage, loadTimerModeSessionsFromStorage, formatDuration]); + }, [ + loadPausedSessionFromStorage, + loadTimerModeSessionsFromStorage, + formatDuration, + ]); // Update session protection when timer state changes useEffect(() => { const isActive = isRunning || !!currentSession || !!pausedSession; updateSessionProtection(isActive, timerMode); - }, [isRunning, currentSession, pausedSession, timerMode, updateSessionProtection]); + }, [ + isRunning, + currentSession, + pausedSession, + timerMode, + updateSessionProtection, + ]); // Save timer mode sessions when they change useEffect(() => { @@ -608,18 +667,19 @@ export function TimerControls({ useEffect(() => { return () => { // Only clear if we have a different user or workspace - const keys = Object.keys(localStorage).filter(key => - key.startsWith('paused-session-') && - !key.includes(`-${wsId}-${currentUserId}`) + const keys = Object.keys(localStorage).filter( + (key) => + key.startsWith('paused-session-') && + !key.includes(`-${wsId}-${currentUserId}`) ); - keys.forEach(key => { + keys.forEach((key) => { // Also clean up legacy keys (paused-elapsed and pause-time) const relatedKeys = [ key, key.replace('paused-session-', 'paused-elapsed-'), - key.replace('paused-session-', 'pause-time-') + key.replace('paused-session-', 'pause-time-'), ]; - relatedKeys.forEach(k => localStorage.removeItem(k)); + relatedKeys.forEach((k) => localStorage.removeItem(k)); }); }; }, [wsId, currentUserId]); @@ -627,7 +687,10 @@ export function TimerControls({ // Cleanup AudioContext on component unmount useEffect(() => { return () => { - if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + if ( + audioContextRef.current && + audioContextRef.current.state !== 'closed' + ) { audioContextRef.current.close(); } }; @@ -702,28 +765,35 @@ export function TimerControls({ try { // Lazily create a singleton AudioContext to prevent resource leaks if (!audioContextRef.current) { - audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioContextRef.current = new (window.AudioContext || + (window as any).webkitAudioContext)(); } - + const audioContext = audioContextRef.current; - + // Resume context if suspended (required for some browsers) if (audioContext.state === 'suspended') { audioContext.resume(); } - + const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); - + oscillator.connect(gainNode); gainNode.connect(audioContext.destination); - + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); - oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1); - + oscillator.frequency.setValueAtTime( + 600, + audioContext.currentTime + 0.1 + ); + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); - + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.5 + ); + oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.5); } catch (error) { @@ -732,89 +802,109 @@ export function TimerControls({ } }, []); - const showNotification = useCallback((title: string, body: string, actions?: { title: string; action: () => void }[]) => { - // Check if notifications are enabled and supported - if (!pomodoroSettings.enableNotifications || !('Notification' in window)) { - return; - } + const showNotification = useCallback( + ( + title: string, + body: string, + actions?: { title: string; action: () => void }[] + ) => { + // Check if notifications are enabled and supported + if ( + !pomodoroSettings.enableNotifications || + !('Notification' in window) + ) { + return; + } - // Request permission if needed - if (Notification.permission === 'default') { - Notification.requestPermission(); - return; - } + // Request permission if needed + if (Notification.permission === 'default') { + Notification.requestPermission(); + return; + } - if (Notification.permission === 'granted') { - const notification = new Notification(title, { - body, - icon: '/favicon.ico', - tag: 'pomodoro-timer', - requireInteraction: true, - }); + if (Notification.permission === 'granted') { + const notification = new Notification(title, { + body, + icon: '/favicon.ico', + tag: 'pomodoro-timer', + requireInteraction: true, + }); - notification.onclick = () => { - window.focus(); - notification.close(); - }; + notification.onclick = () => { + window.focus(); + notification.close(); + }; - // Auto-close after 10 seconds - setTimeout(() => notification.close(), 10000); - } + // Auto-close after 10 seconds + setTimeout(() => notification.close(), 10000); + } - // Also show a toast notification - toast.info(title, { - description: body, - duration: 5000, - action: actions?.[0] ? { - label: actions[0].title, - onClick: actions[0].action, - } : undefined, - }); + // Also show a toast notification + toast.info(title, { + description: body, + duration: 5000, + action: actions?.[0] + ? { + label: actions[0].title, + onClick: actions[0].action, + } + : undefined, + }); - playNotificationSound(); - }, [pomodoroSettings.enableNotifications, playNotificationSound]); + playNotificationSound(); + }, + [pomodoroSettings.enableNotifications, playNotificationSound] + ); // Pomodoro timer logic - const startPomodoroSession = useCallback((sessionType: SessionType) => { - let duration: number; - - switch (sessionType) { - case 'focus': - duration = pomodoroSettings.focusTime * 60; - break; - case 'short-break': - duration = pomodoroSettings.shortBreakTime * 60; - break; - case 'long-break': - duration = pomodoroSettings.longBreakTime * 60; - break; - } + const startPomodoroSession = useCallback( + (sessionType: SessionType) => { + let duration: number; - setCountdownState(prev => ({ - ...prev, - targetTime: duration, - remainingTime: duration, - sessionType, - })); - - const sessionName = sessionType === 'focus' ? 'Focus Session' : - sessionType === 'short-break' ? 'Short Break' : 'Long Break'; - - showNotification( - `${sessionName} Started!`, - `${Math.floor(duration / 60)} minutes of ${sessionType === 'focus' ? 'focused work' : 'break time'}` - ); - }, [pomodoroSettings, showNotification]); + switch (sessionType) { + case 'focus': + duration = pomodoroSettings.focusTime * 60; + break; + case 'short-break': + duration = pomodoroSettings.shortBreakTime * 60; + break; + case 'long-break': + duration = pomodoroSettings.longBreakTime * 60; + break; + } + + setCountdownState((prev) => ({ + ...prev, + targetTime: duration, + remainingTime: duration, + sessionType, + })); + + const sessionName = + sessionType === 'focus' + ? 'Focus Session' + : sessionType === 'short-break' + ? 'Short Break' + : 'Long Break'; + + showNotification( + `${sessionName} Started!`, + `${Math.floor(duration / 60)} minutes of ${sessionType === 'focus' ? 'focused work' : 'break time'}` + ); + }, + [pomodoroSettings, showNotification] + ); const handlePomodoroComplete = useCallback(() => { const { sessionType, pomodoroSession } = countdownState; - + if (sessionType === 'focus') { // Focus session completed const nextSession = pomodoroSession + 1; - const isTimeForLongBreak = nextSession > pomodoroSettings.sessionsUntilLongBreak; - - setCountdownState(prev => ({ + const isTimeForLongBreak = + nextSession > pomodoroSettings.sessionsUntilLongBreak; + + setCountdownState((prev) => ({ ...prev, pomodoroSession: isTimeForLongBreak ? 1 : nextSession, cycleCount: isTimeForLongBreak ? prev.cycleCount + 1 : prev.cycleCount, @@ -823,10 +913,15 @@ export function TimerControls({ showNotification( 'Focus Session Complete! 🎉', `Great work! Time for a ${isTimeForLongBreak ? 'long' : 'short'} break.`, - [{ - title: 'Start Break', - action: () => startPomodoroSession(isTimeForLongBreak ? 'long-break' : 'short-break') - }] + [ + { + title: 'Start Break', + action: () => + startPomodoroSession( + isTimeForLongBreak ? 'long-break' : 'short-break' + ), + }, + ] ); if (!pomodoroSettings.autoStartBreaks) { @@ -837,14 +932,12 @@ export function TimerControls({ } } else { // Break completed - showNotification( - 'Break Complete! ⚡', - 'Ready to focus again?', - [{ + showNotification('Break Complete! ⚡', 'Ready to focus again?', [ + { title: 'Start Focus', - action: () => startPomodoroSession('focus') - }] - ); + action: () => startPomodoroSession('focus'), + }, + ]); if (!pomodoroSettings.autoStartFocus) { setIsRunning(false); @@ -852,21 +945,28 @@ export function TimerControls({ startPomodoroSession('focus'); } } - }, [countdownState, pomodoroSettings, showNotification, startPomodoroSession, setIsRunning]); + }, [ + countdownState, + pomodoroSettings, + showNotification, + startPomodoroSession, + setIsRunning, + ]); // Break reminder logic - mode-aware const checkBreakReminders = useCallback(() => { const now = Date.now(); const currentBreakState = getCurrentBreakState(); - + // Get settings based on current timer mode let enableEyeBreaks = false; let enableMovementBreaks = false; - + switch (timerMode) { case 'stopwatch': enableEyeBreaks = stopwatchSettings.enable2020Rule || false; - enableMovementBreaks = stopwatchSettings.enableMovementReminder || false; + enableMovementBreaks = + stopwatchSettings.enableMovementReminder || false; break; case 'pomodoro': enableEyeBreaks = pomodoroSettings.enable2020Rule; @@ -874,17 +974,20 @@ export function TimerControls({ break; case 'custom': enableEyeBreaks = customTimerSettings.enableBreakReminders || false; - enableMovementBreaks = customTimerSettings.enableBreakReminders || false; + enableMovementBreaks = + customTimerSettings.enableBreakReminders || false; break; } - + // 20-20-20 rule: Every 20 minutes, look at something 20 feet away for 20 seconds - if (enableEyeBreaks && - now - currentBreakState.lastEyeBreakTime > 20 * 60 * 1000 && // 20 minutes - isRunning && - (timerMode === 'stopwatch' || countdownState.sessionType === 'focus')) { - - if (now - lastNotificationTime > 5 * 60 * 1000) { // Don't spam notifications + if ( + enableEyeBreaks && + now - currentBreakState.lastEyeBreakTime > 20 * 60 * 1000 && // 20 minutes + isRunning && + (timerMode === 'stopwatch' || countdownState.sessionType === 'focus') + ) { + if (now - lastNotificationTime > 5 * 60 * 1000) { + // Don't spam notifications showNotification( 'Eye Break Time! 👁️', 'Look at something 20 feet away for 20 seconds' @@ -895,11 +998,12 @@ export function TimerControls({ } // Movement reminder: Every 60 minutes - if (enableMovementBreaks && - now - currentBreakState.lastMovementBreakTime > 60 * 60 * 1000 && // 60 minutes - isRunning && - (timerMode === 'stopwatch' || countdownState.sessionType === 'focus')) { - + if ( + enableMovementBreaks && + now - currentBreakState.lastMovementBreakTime > 60 * 60 * 1000 && // 60 minutes + isRunning && + (timerMode === 'stopwatch' || countdownState.sessionType === 'focus') + ) { if (now - lastNotificationTime > 5 * 60 * 1000) { showNotification( 'Movement Break! 🚶', @@ -911,19 +1015,26 @@ export function TimerControls({ } // Session milestones for stopwatch mode - if (timerMode === 'stopwatch' && stopwatchSettings.enableSessionMilestones && isRunning) { + if ( + timerMode === 'stopwatch' && + stopwatchSettings.enableSessionMilestones && + isRunning + ) { const elapsedMinutes = Math.floor(elapsedTimeRef.current / 60); const milestones = [30, 60, 120, 180, 240]; // 30min, 1hr, 2hr, 3hr, 4hr - + for (const milestone of milestones) { - if (elapsedMinutes === milestone && now - lastNotificationTime > 5 * 60 * 1000) { + if ( + elapsedMinutes === milestone && + now - lastNotificationTime > 5 * 60 * 1000 + ) { const hours = Math.floor(milestone / 60); const mins = milestone % 60; const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; - + showNotification( `🎯 Session Milestone! (${timeStr})`, - stopwatchSettings.showProductivityInsights + stopwatchSettings.showProductivityInsights ? `Great focus! You've been working for ${timeStr}. Consider taking a break soon.` : `You've reached ${timeStr} of focused work.` ); @@ -932,45 +1043,68 @@ export function TimerControls({ } } } - }, [timerMode, getCurrentBreakState, updateCurrentBreakState, stopwatchSettings, pomodoroSettings, customTimerSettings, lastNotificationTime, isRunning, countdownState.sessionType, showNotification]); - - + }, [ + timerMode, + getCurrentBreakState, + updateCurrentBreakState, + stopwatchSettings, + pomodoroSettings, + customTimerSettings, + lastNotificationTime, + isRunning, + countdownState.sessionType, + showNotification, + ]); // Update countdown timer (for pomodoro and traditional countdown modes) useEffect(() => { - if ((timerMode === 'pomodoro' || (timerMode === 'custom' && customTimerSettings.type === 'traditional-countdown')) && isRunning && countdownState.remainingTime > 0) { + if ( + (timerMode === 'pomodoro' || + (timerMode === 'custom' && + customTimerSettings.type === 'traditional-countdown')) && + isRunning && + countdownState.remainingTime > 0 + ) { const interval = setInterval(() => { - setCountdownState(prev => { + setCountdownState((prev) => { const newRemainingTime = prev.remainingTime - 1; - + if (newRemainingTime <= 0) { if (timerMode === 'pomodoro') { handlePomodoroComplete(); - } else if (timerMode === 'custom' && customTimerSettings.type === 'traditional-countdown') { + } else if ( + timerMode === 'custom' && + customTimerSettings.type === 'traditional-countdown' + ) { // Handle traditional countdown completion showNotification( 'Countdown Complete! ⏰', - customTimerSettings.enableMotivationalMessages - ? 'Great work! You\'ve completed your custom countdown timer.' + customTimerSettings.enableMotivationalMessages + ? "Great work! You've completed your custom countdown timer." : 'Your countdown has finished.', - customTimerSettings.autoRestart ? [{ - title: 'Auto-restart in 3s...', - action: () => {} - }] : undefined + customTimerSettings.autoRestart + ? [ + { + title: 'Auto-restart in 3s...', + action: () => {}, + }, + ] + : undefined ); - + if (customTimerSettings.playCompletionSound) { playNotificationSound(); } - + if (customTimerSettings.autoRestart) { // Auto-restart after 3 seconds setTimeout(() => { - const restartDuration = (customTimerSettings.countdownDuration || 25) * 60; - setCountdownState(prev => ({ + const restartDuration = + (customTimerSettings.countdownDuration || 25) * 60; + setCountdownState((prev) => ({ ...prev, targetTime: restartDuration, - remainingTime: restartDuration + remainingTime: restartDuration, })); }, 3000); } else { @@ -979,73 +1113,95 @@ export function TimerControls({ } return { ...prev, remainingTime: 0 }; } - + return { ...prev, remainingTime: newRemainingTime }; }); }, 1000); return () => clearInterval(interval); } - }, [timerMode, isRunning, countdownState.remainingTime, customTimerSettings, handlePomodoroComplete, showNotification, playNotificationSound]); + }, [ + timerMode, + isRunning, + countdownState.remainingTime, + customTimerSettings, + handlePomodoroComplete, + showNotification, + playNotificationSound, + ]); // Enhanced stopwatch interval breaks and target monitoring useEffect(() => { - if (timerMode === 'custom' && customTimerSettings.type === 'enhanced-stopwatch' && isRunning) { + if ( + timerMode === 'custom' && + customTimerSettings.type === 'enhanced-stopwatch' && + isRunning + ) { const interval = setInterval(() => { const currentTime = Date.now(); const elapsedMinutes = Math.floor(elapsedTimeRef.current / 60); const targetMinutes = customTimerSettings.targetDuration || 60; - + // Check for interval breaks if (customTimerSettings.enableIntervalBreaks) { const intervalFreq = customTimerSettings.intervalFrequency || 25; const currentBreakState = getCurrentBreakStateRef.current(); - const timeSinceLastBreak = Math.floor((currentTime - currentBreakState.lastIntervalBreakTime) / (1000 * 60)); - + const timeSinceLastBreak = Math.floor( + (currentTime - currentBreakState.lastIntervalBreakTime) / + (1000 * 60) + ); + if (timeSinceLastBreak >= intervalFreq) { - const breakDuration = customTimerSettings.intervalBreakDuration || 5; + const breakDuration = + customTimerSettings.intervalBreakDuration || 5; const newBreaksCount = currentBreakState.intervalBreaksCount + 1; - + updateCurrentBreakStateRef.current({ intervalBreaksCount: newBreaksCount, - lastIntervalBreakTime: currentTime + lastIntervalBreakTime: currentTime, }); - + showNotification( `🕒 Interval Break Time! (${newBreaksCount})`, `Take a ${breakDuration}-minute break - you've been working for ${intervalFreq} minutes`, - [{ - title: 'Got it!', - action: () => {} - }] + [ + { + title: 'Got it!', + action: () => {}, + }, + ] ); - + if (customTimerSettings.playCompletionSound) { playNotificationSound(); } } } - + // Check for target achievement if (!hasReachedTargetRef.current && elapsedMinutes >= targetMinutes) { setHasReachedTarget(true); - + if (customTimerSettings.enableTargetNotification) { showNotification( `🎯 Target Achieved! (${targetMinutes} min)`, - customTimerSettings.enableMotivationalMessages - ? 'Congratulations! You\'ve reached your target duration. Keep going or take a well-deserved break!' + customTimerSettings.enableMotivationalMessages + ? "Congratulations! You've reached your target duration. Keep going or take a well-deserved break!" : `You've completed your ${targetMinutes}-minute goal.`, - [{ - title: customTimerSettings.autoStopAtTarget ? 'Timer Stopped' : 'Keep Going', - action: () => {} - }] + [ + { + title: customTimerSettings.autoStopAtTarget + ? 'Timer Stopped' + : 'Keep Going', + action: () => {}, + }, + ] ); - + if (customTimerSettings.playCompletionSound) { playNotificationSound(); } - + if (customTimerSettings.autoStopAtTarget) { setIsRunning(false); // Optionally stop the session completely @@ -1053,7 +1209,7 @@ export function TimerControls({ } } } - + // Check for break reminders (if enabled) if (customTimerSettings.enableBreakReminders) { checkBreakReminders(); @@ -1062,7 +1218,15 @@ export function TimerControls({ return () => clearInterval(interval); } - }, [timerMode, customTimerSettings, isRunning, showNotification, playNotificationSound, checkBreakReminders, setIsRunning]); + }, [ + timerMode, + customTimerSettings, + isRunning, + showNotification, + playNotificationSound, + checkBreakReminders, + setIsRunning, + ]); // Fetch boards with lists const fetchBoards = useCallback(async () => { @@ -1337,8 +1501,9 @@ export function TimerControls({ } else if (timerMode === 'custom') { // Initialize custom timer based on type if (customTimerSettings.type === 'traditional-countdown') { - const countdownDuration = (customTimerSettings.countdownDuration || 25) * 60; - setCountdownState(prev => ({ + const countdownDuration = + (customTimerSettings.countdownDuration || 25) * 60; + setCountdownState((prev) => ({ ...prev, targetTime: countdownDuration, remainingTime: countdownDuration, @@ -1351,9 +1516,9 @@ export function TimerControls({ intervalBreaksCount: 0, }); setHasReachedTarget(false); - + // No countdown for enhanced stopwatch - it counts up - setCountdownState(prev => ({ + setCountdownState((prev) => ({ ...prev, targetTime: 0, remainingTime: 0, @@ -1368,7 +1533,9 @@ export function TimerControls({ setSelectedTaskId('none'); onSessionUpdate(); - toast.success(`Timer started${timerMode === 'pomodoro' ? ' - Focus time!' : ''}`); + toast.success( + `Timer started${timerMode === 'pomodoro' ? ' - Focus time!' : ''}` + ); } catch (error) { console.error('Error starting timer:', error); toast.error('Failed to start timer'); @@ -1395,7 +1562,7 @@ export function TimerControls({ const completedSession = response.session; setJustCompleted(completedSession); - + // Clear all session states setCurrentSession(null); setPausedSession(null); @@ -1403,10 +1570,10 @@ export function TimerControls({ setElapsedTime(0); setPausedElapsedTime(0); setPauseStartTime(null); - + // Clear session protection - timer is no longer active updateSessionProtection(false, timerMode); - + // Clear from localStorage since session is completed clearPausedSessionFromStorage(); @@ -1449,10 +1616,10 @@ export function TimerControls({ setPausedSession(currentSession); setPausedElapsedTime(elapsedTime); setPauseStartTime(pauseTime); - + // Save to localStorage for persistence across sessions savePausedSessionToStorage(currentSession, elapsedTime, pauseTime); - + // Clear active session but keep paused state setCurrentSession(null); setIsRunning(false); @@ -1490,27 +1657,28 @@ export function TimerControls({ setCurrentSession(response.session || pausedSession); setElapsedTime(pausedElapsedTime); setIsRunning(true); - + // Restore session protection - timer is active again updateSessionProtection(true, timerMode); - + // Clear paused state setPausedSession(null); setPausedElapsedTime(0); setPauseStartTime(null); - + // Clear from localStorage since session is now active clearPausedSessionFromStorage(); - const pauseDuration = pauseStartTime + const pauseDuration = pauseStartTime ? Math.floor((new Date().getTime() - pauseStartTime.getTime()) / 1000) : 0; onSessionUpdate(); toast.success('Timer resumed!', { - description: pauseDuration > 0 - ? `Paused for ${formatDuration(pauseDuration)}` - : 'Welcome back to your session', + description: + pauseDuration > 0 + ? `Paused for ${formatDuration(pauseDuration)}` + : 'Welcome back to your session', duration: 3000, }); } catch (error) { @@ -1521,8 +1689,6 @@ export function TimerControls({ } }; - - // Start from template const startFromTemplate = async (template: SessionTemplate) => { setNewSessionTitle(template.title); @@ -1613,21 +1779,27 @@ export function TimerControls({ }, [tasks, taskSearchQuery, taskFilters]); // Get unique boards and lists for filter options (memoized) - const uniqueBoards = useMemo(() => [ - ...new Set( - tasks - .map((task) => task.board_name) - .filter((name): name is string => Boolean(name)) - ), - ], [tasks]); - - const uniqueLists = useMemo(() => [ - ...new Set( - tasks - .map((task) => task.list_name) - .filter((name): name is string => Boolean(name)) - ), - ], [tasks]); + const uniqueBoards = useMemo( + () => [ + ...new Set( + tasks + .map((task) => task.board_name) + .filter((name): name is string => Boolean(name)) + ), + ], + [tasks] + ); + + const uniqueLists = useMemo( + () => [ + ...new Set( + tasks + .map((task) => task.list_name) + .filter((name): name is string => Boolean(name)) + ), + ], + [tasks] + ); // Calculate dropdown position const calculateDropdownPosition = useCallback(() => { @@ -1870,8 +2042,6 @@ export function TimerControls({ sessionMode, ]); - - return ( <> {/* Custom Timer Advanced Settings Dialog */} @@ -1887,7 +2057,9 @@ export function TimerControls({ {/* Timer Type Specific Settings */} {customTimerSettings.type === 'enhanced-stopwatch' && (
-

Enhanced Stopwatch Settings

+

+ Enhanced Stopwatch Settings +

@@ -1896,10 +2068,12 @@ export function TimerControls({ min="10" max="480" value={customTimerSettings.targetDuration} - onChange={(e) => setCustomTimerSettings(prev => ({ - ...prev, - targetDuration: parseInt(e.target.value) || 60 - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + targetDuration: parseInt(e.target.value) || 60, + })) + } />
@@ -1909,10 +2083,12 @@ export function TimerControls({ min="5" max="120" value={customTimerSettings.intervalFrequency} - onChange={(e) => setCustomTimerSettings(prev => ({ - ...prev, - intervalFrequency: parseInt(e.target.value) || 25 - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + intervalFrequency: parseInt(e.target.value) || 25, + })) + } />
@@ -1923,10 +2099,12 @@ export function TimerControls({ min="1" max="30" value={customTimerSettings.intervalBreakDuration} - onChange={(e) => setCustomTimerSettings(prev => ({ - ...prev, - intervalBreakDuration: parseInt(e.target.value) || 5 - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + intervalBreakDuration: parseInt(e.target.value) || 5, + })) + } />
@@ -1934,10 +2112,12 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - autoStopAtTarget: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + autoStopAtTarget: e.target.checked, + })) + } />
@@ -1945,10 +2125,12 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - enableTargetNotification: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + enableTargetNotification: e.target.checked, + })) + } />
@@ -1956,7 +2138,9 @@ export function TimerControls({ {customTimerSettings.type === 'traditional-countdown' && (
-

Traditional Countdown Settings

+

+ Traditional Countdown Settings +

setCustomTimerSettings(prev => ({ - ...prev, - countdownDuration: parseInt(e.target.value) || 25 - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + countdownDuration: parseInt(e.target.value) || 25, + })) + } />
@@ -1975,10 +2161,12 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - autoRestart: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + autoRestart: e.target.checked, + })) + } />
@@ -1986,10 +2174,12 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - showTimeRemaining: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + showTimeRemaining: e.target.checked, + })) + } />
@@ -2002,14 +2192,17 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - enableBreakReminders: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + enableBreakReminders: e.target.checked, + })) + } />

- Get reminded to take eye breaks (20-20-20 rule) and movement breaks during long sessions + Get reminded to take eye breaks (20-20-20 rule) and movement + breaks during long sessions

@@ -2020,10 +2213,12 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - playCompletionSound: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + playCompletionSound: e.target.checked, + })) + } />
@@ -2031,10 +2226,12 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - showNotifications: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + showNotifications: e.target.checked, + })) + } />
@@ -2046,43 +2243,48 @@ export function TimerControls({ setCustomTimerSettings(prev => ({ - ...prev, - enableMotivationalMessages: e.target.checked - }))} + onChange={(e) => + setCustomTimerSettings((prev) => ({ + ...prev, + enableMotivationalMessages: e.target.checked, + })) + } />

- Receive encouraging messages and productivity tips during your sessions + Receive encouraging messages and productivity tips during your + sessions

@@ -2129,10 +2336,12 @@ export function TimerControls({ min="1" max="30" value={pomodoroSettings.shortBreakTime} - onChange={(e) => setPomodoroSettings(prev => ({ - ...prev, - shortBreakTime: parseInt(e.target.value) || 5 - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + shortBreakTime: parseInt(e.target.value) || 5, + })) + } />
@@ -2142,14 +2351,16 @@ export function TimerControls({ min="1" max="60" value={pomodoroSettings.longBreakTime} - onChange={(e) => setPomodoroSettings(prev => ({ - ...prev, - longBreakTime: parseInt(e.target.value) || 15 - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + longBreakTime: parseInt(e.target.value) || 15, + })) + } />
- +
setPomodoroSettings(prev => ({ - ...prev, - sessionsUntilLongBreak: parseInt(e.target.value) || 4 - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + sessionsUntilLongBreak: parseInt(e.target.value) || 4, + })) + } />
@@ -2170,58 +2383,68 @@ export function TimerControls({ setPomodoroSettings(prev => ({ - ...prev, - autoStartBreaks: e.target.checked - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + autoStartBreaks: e.target.checked, + })) + } />
- +
setPomodoroSettings(prev => ({ - ...prev, - autoStartFocus: e.target.checked - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + autoStartFocus: e.target.checked, + })) + } />
- +
setPomodoroSettings(prev => ({ - ...prev, - enableNotifications: e.target.checked - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + enableNotifications: e.target.checked, + })) + } />
- +
setPomodoroSettings(prev => ({ - ...prev, - enable2020Rule: e.target.checked - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + enable2020Rule: e.target.checked, + })) + } />
- +
setPomodoroSettings(prev => ({ - ...prev, - enableMovementReminder: e.target.checked - }))} + onChange={(e) => + setPomodoroSettings((prev) => ({ + ...prev, + enableMovementReminder: e.target.checked, + })) + } />
@@ -2246,7 +2469,10 @@ export function TimerControls({ {/* Stopwatch Settings Dialog */} - + ⏱️ Stopwatch Settings @@ -2261,82 +2487,96 @@ export function TimerControls({ setStopwatchSettings(prev => ({ - ...prev, - enableBreakReminders: e.target.checked - }))} + onChange={(e) => + setStopwatchSettings((prev) => ({ + ...prev, + enableBreakReminders: e.target.checked, + })) + } />
- +
setStopwatchSettings(prev => ({ - ...prev, - enable2020Rule: e.target.checked - }))} + onChange={(e) => + setStopwatchSettings((prev) => ({ + ...prev, + enable2020Rule: e.target.checked, + })) + } />
- +
setStopwatchSettings(prev => ({ - ...prev, - enableMovementReminder: e.target.checked - }))} + onChange={(e) => + setStopwatchSettings((prev) => ({ + ...prev, + enableMovementReminder: e.target.checked, + })) + } />
- +
setStopwatchSettings(prev => ({ - ...prev, - showProductivityInsights: e.target.checked - }))} + onChange={(e) => + setStopwatchSettings((prev) => ({ + ...prev, + showProductivityInsights: e.target.checked, + })) + } />
- +
setStopwatchSettings(prev => ({ - ...prev, - enableNotifications: e.target.checked - }))} + onChange={(e) => + setStopwatchSettings((prev) => ({ + ...prev, + enableNotifications: e.target.checked, + })) + } />
- +
setStopwatchSettings(prev => ({ - ...prev, - enableSessionMilestones: e.target.checked - }))} + onChange={(e) => + setStopwatchSettings((prev) => ({ + ...prev, + enableSessionMilestones: e.target.checked, + })) + } />
- +
setStopwatchSettings(prev => ({ - ...prev, - playCompletionSound: e.target.checked - }))} + onChange={(e) => + setStopwatchSettings((prev) => ({ + ...prev, + playCompletionSound: e.target.checked, + })) + } />
@@ -2374,25 +2614,39 @@ export function TimerControls({
{/* Timer Mode Selector */}
- + handleTimerModeChange(value) + } disabled={sessionProtection.isActive} > - + - + ⏱️ Stopwatch - + 🍅 Pomodoro - + ⏲️ Custom @@ -2408,19 +2662,27 @@ export function TimerControls({ size="sm" onClick={() => { if (sessionProtection.isActive) { - toast.error('Cannot modify settings during active session', { - description: 'Please stop or pause your timer first.', - duration: 3000, - }); + toast.error( + 'Cannot modify settings during active session', + { + description: 'Please stop or pause your timer first.', + duration: 3000, + } + ); return; } setShowStopwatchSettings(true); }} className={cn( - "h-8 w-8 p-0", - sessionProtection.isActive && "opacity-50 cursor-not-allowed" + 'h-8 w-8 p-0', + sessionProtection.isActive && + 'cursor-not-allowed opacity-50' )} - title={sessionProtection.isActive ? "Settings locked during active session" : "Stopwatch Settings"} + title={ + sessionProtection.isActive + ? 'Settings locked during active session' + : 'Stopwatch Settings' + } disabled={sessionProtection.isActive} > ⚙️ @@ -2432,19 +2694,27 @@ export function TimerControls({ size="sm" onClick={() => { if (sessionProtection.isActive) { - toast.error('Cannot modify settings during active session', { - description: 'Please stop or pause your timer first.', - duration: 3000, - }); + toast.error( + 'Cannot modify settings during active session', + { + description: 'Please stop or pause your timer first.', + duration: 3000, + } + ); return; } setShowPomodoroSettings(true); }} className={cn( - "h-8 w-8 p-0", - sessionProtection.isActive && "opacity-50 cursor-not-allowed" + 'h-8 w-8 p-0', + sessionProtection.isActive && + 'cursor-not-allowed opacity-50' )} - title={sessionProtection.isActive ? "Settings locked during active session" : "Pomodoro Settings"} + title={ + sessionProtection.isActive + ? 'Settings locked during active session' + : 'Pomodoro Settings' + } disabled={sessionProtection.isActive} > ⚙️ @@ -2456,19 +2726,27 @@ export function TimerControls({ size="sm" onClick={() => { if (sessionProtection.isActive) { - toast.error('Cannot modify settings during active session', { - description: 'Please stop or pause your timer first.', - duration: 3000, - }); + toast.error( + 'Cannot modify settings during active session', + { + description: 'Please stop or pause your timer first.', + duration: 3000, + } + ); return; } setShowCustomSettings(true); }} className={cn( - "h-8 w-8 p-0", - sessionProtection.isActive && "opacity-50 cursor-not-allowed" + 'h-8 w-8 p-0', + sessionProtection.isActive && + 'cursor-not-allowed opacity-50' )} - title={sessionProtection.isActive ? "Settings locked during active session" : "Custom Timer Settings"} + title={ + sessionProtection.isActive + ? 'Settings locked during active session' + : 'Custom Timer Settings' + } disabled={sessionProtection.isActive} > ⚙️ @@ -2478,14 +2756,16 @@ export function TimerControls({
- {timerMode === 'stopwatch' && 'Track your time with detailed analytics'} - {timerMode === 'pomodoro' && `Focus for ${pomodoroSettings.focusTime}min, break for ${pomodoroSettings.shortBreakTime}min`} - {timerMode === 'custom' && customTimerSettings.type === 'enhanced-stopwatch' && - `Enhanced stopwatch with ${customTimerSettings.targetDuration}min target${customTimerSettings.enableIntervalBreaks ? `, breaks every ${customTimerSettings.intervalFrequency}min` : ''}` - } - {timerMode === 'custom' && customTimerSettings.type === 'traditional-countdown' && - `Traditional countdown for ${customTimerSettings.countdownDuration}min${customTimerSettings.autoRestart ? ' (auto-restart)' : ''}` - } + {timerMode === 'stopwatch' && + 'Track your time with detailed analytics'} + {timerMode === 'pomodoro' && + `Focus for ${pomodoroSettings.focusTime}min, break for ${pomodoroSettings.shortBreakTime}min`} + {timerMode === 'custom' && + customTimerSettings.type === 'enhanced-stopwatch' && + `Enhanced stopwatch with ${customTimerSettings.targetDuration}min target${customTimerSettings.enableIntervalBreaks ? `, breaks every ${customTimerSettings.intervalFrequency}min` : ''}`} + {timerMode === 'custom' && + customTimerSettings.type === 'traditional-countdown' && + `Traditional countdown for ${customTimerSettings.countdownDuration}min${customTimerSettings.autoRestart ? ' (auto-restart)' : ''}`}
@@ -2512,65 +2792,96 @@ export function TimerControls({
- {customTimerSettings.type === 'enhanced-stopwatch' ? '⏱️' : '⏲️'} + {customTimerSettings.type === 'enhanced-stopwatch' + ? '⏱️' + : '⏲️'}

- {customTimerSettings.type === 'enhanced-stopwatch' ? 'Enhanced Stopwatch' : 'Traditional Countdown'} + {customTimerSettings.type === 'enhanced-stopwatch' + ? 'Enhanced Stopwatch' + : 'Traditional Countdown'}

- {customTimerSettings.type === 'enhanced-stopwatch' - ? 'Target-based with interval breaks' - : 'Simple countdown timer' - } + {customTimerSettings.type === 'enhanced-stopwatch' + ? 'Target-based with interval breaks' + : 'Simple countdown timer'}

- + {/* Quick Timer Type Switcher */}
@@ -2579,39 +2890,50 @@ export function TimerControls({ {/* Essential Settings Only - Interval Breaks for Enhanced Stopwatch */} {customTimerSettings.type === 'enhanced-stopwatch' && (
-
- Interval Breaks: -
- { - if (sessionProtection.isActive) { - toast.error('Cannot modify break settings during active session', { - description: 'Please stop or pause your timer first.', +
+ + Interval Breaks: + +
+ { + if (sessionProtection.isActive) { + toast.error( + 'Cannot modify break settings during active session', + { + description: + 'Please stop or pause your timer first.', duration: 3000, - }); - return; - } - setCustomTimerSettings(prev => ({ - ...prev, - enableIntervalBreaks: e.target.checked - })); - }} - className={cn( - "h-3 w-3 rounded", - sessionProtection.isActive && "opacity-50 cursor-not-allowed" - )} - disabled={sessionProtection.isActive} - title={sessionProtection.isActive ? "Settings locked during active session" : "Enable interval breaks"} - /> - {customTimerSettings.enableIntervalBreaks && ( - - every {customTimerSettings.intervalFrequency}min - + } + ); + return; + } + setCustomTimerSettings((prev) => ({ + ...prev, + enableIntervalBreaks: e.target.checked, + })); + }} + className={cn( + 'h-3 w-3 rounded', + sessionProtection.isActive && + 'cursor-not-allowed opacity-50' )} -
+ disabled={sessionProtection.isActive} + title={ + sessionProtection.isActive + ? 'Settings locked during active session' + : 'Enable interval breaks' + } + /> + {customTimerSettings.enableIntervalBreaks && ( + + every {customTimerSettings.intervalFrequency}min + + )}
+
)}
@@ -2621,125 +2943,160 @@ export function TimerControls({ {currentSession ? (
{/* Enhanced Active Session Display */} -
-
-
-
- {(timerMode === 'pomodoro' || (timerMode === 'custom' && customTimerSettings.type === 'traditional-countdown')) ? formatTime(countdownState.remainingTime) : formatTime(elapsedTime)} + ? 'bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950/20 dark:to-purple-900/20' + : 'bg-gradient-to-br from-red-50 to-red-100 dark:from-red-950/20 dark:to-red-900/20' + )} + > +
+
+
+ {timerMode === 'pomodoro' || + (timerMode === 'custom' && + customTimerSettings.type === 'traditional-countdown') + ? formatTime(countdownState.remainingTime) + : formatTime(elapsedTime)}
- + {/* Pomodoro Progress Indicator */} {timerMode === 'pomodoro' && (
- {countdownState.sessionType === 'focus' ? `🍅 Focus ${countdownState.pomodoroSession}` : - countdownState.sessionType === 'short-break' ? '☕ Short Break' : - '🌟 Long Break'} + {countdownState.sessionType === 'focus' + ? `🍅 Focus ${countdownState.pomodoroSession}` + : countdownState.sessionType === 'short-break' + ? '☕ Short Break' + : '🌟 Long Break'}
- + {/* Progress bar for current session */} -
-
+
0 ? ((countdownState.targetTime - countdownState.remainingTime) / countdownState.targetTime) * 100 : 0}%` + style={{ + width: `${countdownState.targetTime > 0 ? ((countdownState.targetTime - countdownState.remainingTime) / countdownState.targetTime) * 100 : 0}%`, }} />
- + {/* Pomodoro sessions indicator */} {countdownState.sessionType === 'focus' && ( -
- {Array.from({ length: pomodoroSettings.sessionsUntilLongBreak }, (_, i) => ( -
- ))} +
+ {Array.from( + { length: pomodoroSettings.sessionsUntilLongBreak }, + (_, i) => ( +
+ ) + )}
)}
)} - -
-
+ +
+
{timerMode === 'pomodoro' ? ( - {countdownState.remainingTime > 0 ? - `${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')} remaining` : - 'Session complete!' - } + {countdownState.remainingTime > 0 + ? `${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')} remaining` + : 'Session complete!'} ) : timerMode === 'custom' ? ( - {customTimerSettings.type === 'traditional-countdown' ? ( - countdownState.remainingTime > 0 ? - `⏲️ ${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')} remaining` : - 'Countdown complete!' - ) : customTimerSettings.type === 'enhanced-stopwatch' ? ( - hasReachedTarget ? - `🎯 Target achieved! (${customTimerSettings.targetDuration || 60}min)` : - `⏱️ Enhanced Stopwatch ${customTimerSettings.targetDuration ? `(target: ${customTimerSettings.targetDuration}min)` : ''}` - ) : ( - '⏱️ Custom Timer' - )} + {customTimerSettings.type === 'traditional-countdown' + ? countdownState.remainingTime > 0 + ? `⏲️ ${Math.floor(countdownState.remainingTime / 60)}:${(countdownState.remainingTime % 60).toString().padStart(2, '0')} remaining` + : 'Countdown complete!' + : customTimerSettings.type === 'enhanced-stopwatch' + ? hasReachedTarget + ? `🎯 Target achieved! (${customTimerSettings.targetDuration || 60}min)` + : `⏱️ Enhanced Stopwatch ${customTimerSettings.targetDuration ? `(target: ${customTimerSettings.targetDuration}min)` : ''}` + : '⏱️ Custom Timer'} ) : ( <> Started at{' '} - {new Date(currentSession.start_time).toLocaleTimeString()} + {new Date( + currentSession.start_time + ).toLocaleTimeString()} {elapsedTime > 1800 && ( {elapsedTime > 3600 ? 'Long session!' : 'Deep work'} @@ -2816,7 +3173,7 @@ export function TimerControls({ {/* Productivity Insights */} {elapsedTime > 600 && (
-
+
Session Insights @@ -2826,17 +3183,21 @@ export function TimerControls({
Duration: - {elapsedTime < 1500 ? 'Warming up' : - elapsedTime < 3600 ? 'Focused session' : - 'Deep work zone!'} + {elapsedTime < 1500 + ? 'Warming up' + : elapsedTime < 3600 + ? 'Focused session' + : 'Deep work zone!'}
Productivity: - {elapsedTime < 900 ? 'Getting started' : - elapsedTime < 2700 ? 'In the flow' : - 'Exceptional focus'} + {elapsedTime < 900 + ? 'Getting started' + : elapsedTime < 2700 + ? 'In the flow' + : 'Exceptional focus'}
@@ -2866,10 +3227,12 @@ export function TimerControls({
{/* Quick Actions during session */} -
+
⌘/Ctrl + P for break - ⌘/Ctrl + Enter + + ⌘/Ctrl + Enter + to complete
@@ -2894,12 +3257,20 @@ export function TimerControls({ Paused at {pauseStartTime?.toLocaleTimeString()} {pauseStartTime && ( - • Break: {formatDuration(Math.floor((new Date().getTime() - pauseStartTime.getTime()) / 1000))} + • Break:{' '} + {formatDuration( + Math.floor( + (new Date().getTime() - + pauseStartTime.getTime()) / + 1000 + ) + )} )}
- Session was running for {formatDuration(pausedElapsedTime)} before pause + Session was running for{' '} + {formatDuration(pausedElapsedTime)} before pause
@@ -2933,7 +3304,10 @@ export function TimerControls({
)} - + On break
@@ -2944,7 +3318,7 @@ export function TimerControls({
- -
{ - const newSettings = { ...heatmapSettings, viewMode: value }; + +

- {heatmapSettings.viewMode === 'original' && 'GitHub-style grid view with day labels'} - {heatmapSettings.viewMode === 'hybrid' && 'Year overview plus monthly calendar details'} - {heatmapSettings.viewMode === 'calendar-only' && 'Traditional calendar interface'} - {heatmapSettings.viewMode === 'compact-cards' && 'Monthly summary cards with key metrics and mini previews'} + {heatmapSettings.viewMode === 'original' && + 'GitHub-style grid view with day labels'} + {heatmapSettings.viewMode === 'hybrid' && + 'Year overview plus monthly calendar details'} + {heatmapSettings.viewMode === 'calendar-only' && + 'Traditional calendar interface'} + {heatmapSettings.viewMode === 'compact-cards' && + 'Monthly summary cards with key metrics and mini previews'}

- - { + const newSettings = { + ...heatmapSettings, + timeReference: value, + }; setHeatmapSettings(newSettings); - localStorage.setItem('heatmap-settings', JSON.stringify(newSettings)); + localStorage.setItem( + 'heatmap-settings', + JSON.stringify(newSettings) + ); }} > - Relative ("2 weeks ago") - Absolute ("Jan 15, 2024") - Smart (Both combined) + + Relative ("2 weeks ago") + + + Absolute ("Jan 15, 2024") + + + Smart (Both combined) +
@@ -2185,12 +2352,21 @@ export default function TimeTrackerContent({ id="onboarding-tips" checked={heatmapSettings.showOnboardingTips} onCheckedChange={(checked) => { - const newSettings = { ...heatmapSettings, showOnboardingTips: checked }; + const newSettings = { + ...heatmapSettings, + showOnboardingTips: checked, + }; setHeatmapSettings(newSettings); - localStorage.setItem('heatmap-settings', JSON.stringify(newSettings)); + localStorage.setItem( + 'heatmap-settings', + JSON.stringify(newSettings) + ); }} /> -
@@ -2199,12 +2375,15 @@ export default function TimeTrackerContent({ {/* Coming Soon Section */}
-
+
-

More Settings Coming Soon

+

+ More Settings Coming Soon +

- Notifications, default categories, productivity goals, and more customization options. + Notifications, default categories, productivity goals, + and more customization options.

@@ -2223,7 +2402,7 @@ export default function TimeTrackerContent({
-
+

Continue Last Session? @@ -2233,7 +2412,7 @@ export default function TimeTrackerContent({

- +

{recentSessions[0].title} @@ -2245,12 +2424,14 @@ export default function TimeTrackerContent({ )} {recentSessions[0].category && (

-
+
{recentSessions[0].category.name} @@ -2312,7 +2493,8 @@ export default function TimeTrackerContent({ Choose Your Next Task

- Tasks prioritized: Your urgent tasks → Urgent unassigned → Your other tasks + Tasks prioritized: Your urgent tasks → Urgent unassigned → + Your other tasks

@@ -2327,9 +2509,10 @@ export default function TimeTrackerContent({ No Tasks Available

- You don't have any assigned tasks. Create a new task or check available boards. + You don't have any assigned tasks. Create a new task or + check available boards.

- +
) : ( availableTasks.map((task) => { - const getPriorityBadge = (priority: number | null | undefined) => { + const getPriorityBadge = ( + priority: number | null | undefined + ) => { switch (priority) { case 1: return { text: 'Urgent', color: 'bg-red-500' }; @@ -2380,108 +2565,131 @@ export default function TimeTrackerContent({ }; const priorityBadge = getPriorityBadge(task.priority); - const isUnassigned = !task || !task.assignees || task.assignees.length === 0; + const isUnassigned = + !task || !task.assignees || task.assignees.length === 0; - return ( - + ); }) )} @@ -2497,10 +2705,11 @@ export default function TimeTrackerContent({ {availableTasks.length > 0 && (

- {availableTasks.length} task{availableTasks.length !== 1 ? 's' : ''} prioritized + {availableTasks.length} task + {availableTasks.length !== 1 ? 's' : ''} prioritized

-
)}
- {isPeakHour - ? "Perfect timing for deep focus work" - : "Start tracking time instantly"} + {isPeakHour + ? 'Perfect timing for deep focus work' + : 'Start tracking time instantly'}
@@ -112,7 +123,9 @@ export function QuickActions({
- Analytics + + Analytics +
diff --git a/apps/web/src/components/command/quick-time-tracker.tsx b/apps/web/src/components/command/quick-time-tracker.tsx index fc15fad53..ea22685b5 100644 --- a/apps/web/src/components/command/quick-time-tracker.tsx +++ b/apps/web/src/components/command/quick-time-tracker.tsx @@ -6,11 +6,11 @@ import { CommandGroup } from '@tuturuuu/ui/command'; import { Input } from '@tuturuuu/ui/input'; import { Label } from '@tuturuuu/ui/label'; import { toast } from '@tuturuuu/ui/sonner'; +import { cn } from '@tuturuuu/utils/format'; import { CheckCircle, ExternalLink, Play, Square, Timer } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; -import { cn } from '@tuturuuu/utils/format'; // Focus score calculation constants const FOCUS_SCORE_CONSTANTS = { @@ -20,15 +20,15 @@ const FOCUS_SCORE_CONSTANTS = { TIME_BONUS: 20, CATEGORY_BONUS: 10, TASK_BONUS: 10, - PEAK_HOURS: { morning: [9, 11], afternoon: [14, 16] } + PEAK_HOURS: { morning: [9, 11], afternoon: [14, 16] }, } as const; // Session duration thresholds (in seconds) const SESSION_THRESHOLDS = { - DEEP_WORK: 7200, // 2 hours - FOCUSED: 3600, // 1 hour - STANDARD: 1800, // 30 minutes - QUICK_START: 900 // 15 minutes + DEEP_WORK: 7200, // 2 hours + FOCUSED: 3600, // 1 hour + STANDARD: 1800, // 30 minutes + QUICK_START: 900, // 15 minutes } as const; // Helper function to calculate focus score @@ -38,18 +38,28 @@ const calculateFocusScore = ( taskId: string | undefined, currentHour: number ): number => { - const durationScore = Math.min(elapsedTime / FOCUS_SCORE_CONSTANTS.MAX_DURATION_SECONDS, 1) * FOCUS_SCORE_CONSTANTS.DURATION_WEIGHT; + const durationScore = + Math.min(elapsedTime / FOCUS_SCORE_CONSTANTS.MAX_DURATION_SECONDS, 1) * + FOCUS_SCORE_CONSTANTS.DURATION_WEIGHT; const consistencyBonus = FOCUS_SCORE_CONSTANTS.CONSISTENCY_BONUS; - const timeBonus = ( - (currentHour >= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.morning[0] && currentHour <= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.morning[1]) || - (currentHour >= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.afternoon[0] && currentHour <= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.afternoon[1]) - ) ? FOCUS_SCORE_CONSTANTS.TIME_BONUS : 0; - const categoryBonus = category?.name?.toLowerCase().includes('work') ? FOCUS_SCORE_CONSTANTS.CATEGORY_BONUS : 0; + const timeBonus = + (currentHour >= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.morning[0] && + currentHour <= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.morning[1]) || + (currentHour >= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.afternoon[0] && + currentHour <= FOCUS_SCORE_CONSTANTS.PEAK_HOURS.afternoon[1]) + ? FOCUS_SCORE_CONSTANTS.TIME_BONUS + : 0; + const categoryBonus = category?.name?.toLowerCase().includes('work') + ? FOCUS_SCORE_CONSTANTS.CATEGORY_BONUS + : 0; const taskBonus = taskId ? FOCUS_SCORE_CONSTANTS.TASK_BONUS : 0; - - return Math.min(Math.round( - durationScore + consistencyBonus + timeBonus + categoryBonus + taskBonus - ), 100); + + return Math.min( + Math.round( + durationScore + consistencyBonus + timeBonus + categoryBonus + taskBonus + ), + 100 + ); }; interface QuickTimeTrackerProps { @@ -415,18 +425,26 @@ export function QuickTimeTracker({ {/* Live Focus Score */}
- Live Focus Score + + Live Focus Score +
-
= SESSION_THRESHOLDS.DEEP_WORK ? "bg-green-500 dark:bg-green-600" : - elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? "bg-blue-500 dark:bg-blue-600" : - elapsedTime >= SESSION_THRESHOLDS.STANDARD ? "bg-yellow-500 dark:bg-yellow-600" : "bg-gray-500 dark:bg-gray-600" - )}> -
= SESSION_THRESHOLDS.DEEP_WORK + ? 'bg-green-500 dark:bg-green-600' + : elapsedTime >= SESSION_THRESHOLDS.FOCUSED + ? 'bg-blue-500 dark:bg-blue-600' + : elapsedTime >= SESSION_THRESHOLDS.STANDARD + ? 'bg-yellow-500 dark:bg-yellow-600' + : 'bg-gray-500 dark:bg-gray-600' + )} + > +
@@ -440,19 +458,29 @@ export function QuickTimeTracker({
- + {/* Productivity Tips */}
{elapsedTime >= SESSION_THRESHOLDS.DEEP_WORK ? ( - 🧠 Deep work mode! Excellent focus. + + 🧠 Deep work mode! Excellent focus. + ) : elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? ( - 🎯 Great focus! Consider a break soon. + + 🎯 Great focus! Consider a break soon. + ) : elapsedTime >= SESSION_THRESHOLDS.STANDARD ? ( - 📈 Building momentum! Keep going. + + 📈 Building momentum! Keep going. + ) : elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? ( - ⏰ Good start! Focus is building. + + ⏰ Good start! Focus is building. + ) : ( - 🚀 Just started! Focus will improve. + + 🚀 Just started! Focus will improve. + )}
@@ -460,22 +488,35 @@ export function QuickTimeTracker({ {/* Session Type Indicator */}
Session Type: -
= SESSION_THRESHOLDS.FOCUSED ? "text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-950/30" : - elapsedTime >= SESSION_THRESHOLDS.STANDARD ? "text-blue-700 bg-blue-100 dark:text-blue-300 dark:bg-blue-950/30" : - elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? "text-yellow-700 bg-yellow-100 dark:text-yellow-300 dark:bg-yellow-950/30" : - "text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-950/30" - )}> +
= SESSION_THRESHOLDS.FOCUSED + ? 'bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-300' + : elapsedTime >= SESSION_THRESHOLDS.STANDARD + ? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300' + : elapsedTime >= SESSION_THRESHOLDS.QUICK_START + ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-300' + : 'bg-gray-100 text-gray-700 dark:bg-gray-950/30 dark:text-gray-300' + )} + > - {elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? '🧠' : - elapsedTime >= SESSION_THRESHOLDS.STANDARD ? '🎯' : - elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? '📋' : '⚡'} + {elapsedTime >= SESSION_THRESHOLDS.FOCUSED + ? '🧠' + : elapsedTime >= SESSION_THRESHOLDS.STANDARD + ? '🎯' + : elapsedTime >= SESSION_THRESHOLDS.QUICK_START + ? '📋' + : '⚡'} - {elapsedTime >= SESSION_THRESHOLDS.FOCUSED ? 'Deep Work' : - elapsedTime >= SESSION_THRESHOLDS.STANDARD ? 'Focused' : - elapsedTime >= SESSION_THRESHOLDS.QUICK_START ? 'Standard' : 'Quick Task'} + {elapsedTime >= SESSION_THRESHOLDS.FOCUSED + ? 'Deep Work' + : elapsedTime >= SESSION_THRESHOLDS.STANDARD + ? 'Focused' + : elapsedTime >= SESSION_THRESHOLDS.QUICK_START + ? 'Standard' + : 'Quick Task'}
From 6eb032dfb02a2c065e752497911dad7a108288d3 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 13:54:38 +0800 Subject: [PATCH 32/35] fix(api): improve type safety in tasks API route with proper interfaces 2. fix(types): resolve TaskSidebarFilters type compatibility issues 3. fix(time-tracker): remove unused variables and improve string access safety --- .../components/activity-heatmap.tsx | 23 ----------------- .../(dashboard)/[wsId]/time-tracker/types.ts | 6 ++--- .../(dashboard)/[wsId]/time-tracker/utils.ts | 25 +++++++++++++------ .../api/v1/workspaces/[wsId]/tasks/route.ts | 20 +++++++-------- 4 files changed, 30 insertions(+), 44 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx index ce5f569fc..ebaa49d35 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx @@ -1133,7 +1133,6 @@ export function ActivityHeatmap({ }; const UpcomingCard = ({ - monthKey, name, }: { monthKey: string; @@ -1438,28 +1437,6 @@ export function ActivityHeatmap({ const [currentIndex, setCurrentIndex] = useState(0); const maxVisibleCards = 4; - const totalCards = allCards.length; - const canScrollLeft = currentIndex > 0; - const canScrollRight = currentIndex < totalCards - maxVisibleCards; - - const scrollLeft = () => { - if (canScrollLeft) { - setCurrentIndex((prev) => Math.max(0, prev - 1)); - } - }; - - const scrollRight = () => { - if (canScrollRight) { - setCurrentIndex((prev) => - Math.min(totalCards - maxVisibleCards, prev + 1) - ); - } - }; - - const visibleCards = allCards.slice( - currentIndex, - currentIndex + maxVisibleCards - ); return ( 0) { // Handle names with multiple parts (e.g., "John Doe" -> "JD") const parts = name.split(/\s+/).filter(Boolean); - if (parts.length >= 2) { + if (parts.length >= 2 && parts[0] && parts[1] && parts[0].length > 0 && parts[1].length > 0) { return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); } - return name[0].toUpperCase(); + // Safe access to first character + const firstChar = name.charAt(0); + if (firstChar) { + return firstChar.toUpperCase(); + } } - if (email) { + if (email && email.length > 0) { // Use part before @ for email const username = email.split('@')[0]; - return username[0].toUpperCase(); + if (username && username.length > 0) { + const firstChar = username.charAt(0); + if (firstChar) { + return firstChar.toUpperCase(); + } + } } return '?'; diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts index 41ca2ea61..3076c36a9 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts @@ -5,18 +5,18 @@ import { NextRequest, NextResponse } from 'next/server'; interface TaskAssigneeData { user: { id: string; - display_name?: string; - avatar_url?: string; + display_name: string | null; + avatar_url: string | null; email?: string; } | null; } interface TaskListData { id: string; - name: string; + name: string | null; workspace_boards: { id: string; - name: string; + name: string | null; ws_id: string; } | null; } @@ -24,12 +24,12 @@ interface TaskListData { interface RawTaskData { id: string; name: string; - description?: string; - priority?: number; - completed: boolean; - start_date?: string; - end_date?: string; - created_at: string; + description: string | null; + priority: number | null; + completed: boolean | null; + start_date: string | null; + end_date: string | null; + created_at: string | null; list_id: string; task_lists: TaskListData | null; assignees?: TaskAssigneeData[]; From db073d40efb99b1c3ce0d909e23d3216ce9b8965 Mon Sep 17 00:00:00 2001 From: Adinorio Date: Wed, 18 Jun 2025 14:43:04 +0800 Subject: [PATCH 33/35] fix(time-tracker): resolve all comments and errors --- .../20250119000000_add_was_resumed_field.sql | 11 +- .../components/tasks-sidebar-content.tsx | 19 +- .../components/activity-heatmap.tsx | 170 ++++++++++-------- .../components/session-history.tsx | 52 +++--- .../api/v1/workspaces/[wsId]/tasks/route.ts | 43 ++--- 5 files changed, 161 insertions(+), 134 deletions(-) diff --git a/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql b/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql index 3171ff6f3..5510e4efd 100644 --- a/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql +++ b/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql @@ -4,14 +4,21 @@ ALTER TABLE public.time_tracking_sessions ADD COLUMN was_resumed boolean DEFAULT false; +-- Back-fill existing rows to ensure no NULL values +UPDATE public.time_tracking_sessions +SET was_resumed = false +WHERE was_resumed IS NULL; + +-- Add NOT NULL constraint to prevent tri-state logic +ALTER TABLE public.time_tracking_sessions +ALTER COLUMN was_resumed SET NOT NULL; + -- Add index for analytics queries that filter by was_resumed CREATE INDEX idx_time_tracking_sessions_was_resumed ON public.time_tracking_sessions USING btree (was_resumed) WHERE was_resumed = true; -- Update the time_tracking_session_analytics view to include was_resumed -DROP VIEW IF EXISTS time_tracking_session_analytics; - CREATE OR REPLACE VIEW time_tracking_session_analytics AS SELECT tts.*, diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx index b088cb2d6..1590002db 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx @@ -7,7 +7,7 @@ import QuickTaskTimer from './quick-task-timer'; import { TaskForm } from './task-form'; import { TaskListForm } from './task-list-form'; import TimeTracker from './time-tracker'; -import type { AIChat, WorkspaceTaskBoard } from '@tuturuuu/types/db'; +import type { AIChat, WorkspaceTask, WorkspaceTaskBoard } from '@tuturuuu/types/db'; import { Accordion, AccordionContent, @@ -188,9 +188,16 @@ export default function TasksSidebarContent({ board.lists?.forEach((list) => { if (list.tasks) { // Transform Partial to ExtendedWorkspaceTask + interface TaskWithAssigneeMeta extends Partial { + assignee_name?: string; + assignee_avatar?: string; + is_assigned_to_current_user?: boolean; + assignees?: ExtendedWorkspaceTask['assignees']; + } + const extendedTasks = list.tasks.map( (task): ExtendedWorkspaceTask => { - const taskAny = task as any; + const taskMeta = task as TaskWithAssigneeMeta; // Type-safe conversion from Partial to ExtendedWorkspaceTask // Convert undefined values to null to match the expected type constraints @@ -218,11 +225,11 @@ export default function TasksSidebarContent({ list_name: list.name ?? undefined, // Keep existing assignee metadata if present - assignee_name: taskAny.assignee_name || undefined, - assignee_avatar: taskAny.assignee_avatar || undefined, + assignee_name: taskMeta.assignee_name || undefined, + assignee_avatar: taskMeta.assignee_avatar || undefined, is_assigned_to_current_user: - taskAny.is_assigned_to_current_user || undefined, - assignees: taskAny.assignees || undefined, + taskMeta.is_assigned_to_current_user || undefined, + assignees: taskMeta.assignees || undefined, }; return extendedTask; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx index ebaa49d35..981f75d5e 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx @@ -601,11 +601,13 @@ export function ActivityHeatmap({ 0 ); + const avgDuration = monthActivity.length > 0 ? totalDuration / monthActivity.length : 0; + return { month: monthStart, duration: totalDuration, sessions: totalSessions, - intensity: getIntensity(totalDuration / monthActivity.length || 0), + intensity: getIntensity(avgDuration), }; }); @@ -711,6 +713,7 @@ export function ActivityHeatmap({
); } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 2c8996dda..9e802b3fa 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -613,21 +613,15 @@ const StackedSessionItem: FC<{ ? dayjs.utc(session.end_time).tz(userTimezone) : null; - // Calculate gap from previous session (considering the full session list for gaps) - const actualIndex = showAllSessions ? index : index; - const prevSession = - actualIndex > 0 - ? showAllSessions - ? stackedSession.sessions[index - 1] - : stackedSession.sessions[index - 1] - : null; - const gapInSeconds = - prevSession && prevSession.end_time - ? sessionStart.diff( - dayjs.utc(prevSession.end_time).tz(userTimezone), - 'seconds' - ) - : null; + // Calculate gap from previous session + const prevSession = index > 0 ? stackedSession.sessions[index - 1] : null; + + const gapInSeconds = prevSession?.end_time + ? sessionStart.diff( + dayjs.utc(prevSession.end_time).tz(userTimezone), + 'seconds' + ) + : null; // Format gap duration based on length const formatGap = (seconds: number) => { @@ -719,7 +713,7 @@ const StackedSessionItem: FC<{ {session.is_running ? (
) : ( - (showAllSessions ? index : index) + 1 + index + 1 )}
@@ -882,7 +876,8 @@ export function SessionHistory({ const durationScore = Math.min(session.duration_seconds / 7200, 1) * 40; // Max 40 points for 2+ hours // Bonus for consistency (sessions without interruptions) - const consistencyBonus = session.was_resumed ? 0 : 20; + // was_resumed can be null in database, so check explicitly for true + const consistencyBonus = session.was_resumed === true ? 0 : 20; // Time of day bonus (peak hours get bonus) const sessionHour = dayjs.utc(session.start_time).tz(userTimezone).hour(); @@ -1127,12 +1122,12 @@ export function SessionHistory({ ).length, }; - const bestTimeOfDay = Object.entries(timeOfDayBreakdown).reduce((a, b) => - timeOfDayBreakdown[a[0] as keyof typeof timeOfDayBreakdown] > - timeOfDayBreakdown[b[0] as keyof typeof timeOfDayBreakdown] - ? a - : b - )[0]; + const bestTimeOfDay = sessionsForPeriod.length > 0 + ? Object.entries(timeOfDayBreakdown).reduce<[string, number]>( + (a, b) => a[1] > b[1] ? a : b, + ['morning', 0] // sensible default prevents TypeError + )[0] + : 'none'; const longestSession = sessionsForPeriod.length > 0 @@ -1327,6 +1322,9 @@ export function SessionHistory({ 'Description', ]; + const escape = (v: string) => + /^[=+\-@]/.test(v) ? `'${v}` : v; + const csvData = sessionsForPeriod.map((session) => { const userTz = dayjs.tz.guess(); const startTime = dayjs.utc(session.start_time).tz(userTz); @@ -1336,15 +1334,15 @@ export function SessionHistory({ return [ startTime.format('YYYY-MM-DD'), - session.title, - session.category?.name || '', - session.task?.name || '', + escape(session.title), + escape(session.category?.name || ''), + escape(session.task?.name || ''), startTime.format('HH:mm:ss'), endTime?.format('HH:mm:ss') || '', session.duration_seconds ? (session.duration_seconds / 3600).toFixed(2) : '0', - session.description || '', + escape(session.description || ''), ]; }); diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts index 3076c36a9..ad0189b8d 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts @@ -68,11 +68,12 @@ export async function GET( } const url = new URL(request.url); - const limit = Math.min( - parseInt(url.searchParams.get('limit') || '100'), - 200 - ); - const offset = parseInt(url.searchParams.get('offset') || '0'); + + const parsedLimit = Number.parseInt(url.searchParams.get('limit') ?? '', 10); + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 100; + + const parsedOffset = Number.parseInt(url.searchParams.get('offset') ?? '', 10); + const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; const boardId = url.searchParams.get('boardId'); const listId = url.searchParams.get('listId'); @@ -90,11 +91,11 @@ export async function GET( end_date, created_at, list_id, - task_lists ( + task_lists!inner ( id, name, board_id, - workspace_boards ( + workspace_boards!inner ( id, name, ws_id @@ -126,16 +127,13 @@ export async function GET( if (error) { console.error('Database error in tasks query:', error); - throw error; + throw new Error('TASKS_QUERY_FAILED'); } // Transform the data to match the expected WorkspaceTask format const tasks = data - ?.filter((task: RawTaskData) => { - // Filter out tasks that don't belong to this workspace - return task.task_lists?.workspace_boards?.ws_id === wsId; - }) + ?.map((task: RawTaskData) => ({ id: task.id, name: task.name, @@ -150,15 +148,18 @@ export async function GET( board_name: task.task_lists?.workspace_boards?.name, list_name: task.task_lists?.name, // Add assignee information - assignees: - task.assignees - ?.map((a: TaskAssigneeData) => a.user) - .filter( - (user, index: number, self) => - user && - user.id && - self.findIndex((u) => u?.id === user.id) === index - ) || [], + assignees: [ + ...(task.assignees ?? []) + .map((a) => a.user) + .filter((u): u is NonNullable => !!u?.id) + .reduce((uniqueUsers, user) => { + if (!uniqueUsers.has(user.id)) { + uniqueUsers.set(user.id, user); + } + return uniqueUsers; + }, new Map[0]['user']>()) + .values(), + ], // Add helper field to identify if current user is assigned is_assigned_to_current_user: task.assignees?.some( From fb348b8692cde7e175ae8f8f0b8eb16ef2034d94 Mon Sep 17 00:00:00 2001 From: Adinorio <156925721+Adinorio@users.noreply.github.com> Date: Wed, 18 Jun 2025 06:45:36 +0000 Subject: [PATCH 34/35] style: apply prettier formatting --- .../components/tasks-sidebar-content.tsx | 6 +- .../components/activity-heatmap.tsx | 23 +++-- .../components/session-history.tsx | 19 ++-- .../(dashboard)/[wsId]/time-tracker/utils.ts | 8 +- .../api/v1/workspaces/[wsId]/tasks/route.ts | 86 ++++++++++--------- 5 files changed, 79 insertions(+), 63 deletions(-) diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx index 1590002db..5e6b78c04 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/tasks-sidebar-content.tsx @@ -7,7 +7,11 @@ import QuickTaskTimer from './quick-task-timer'; import { TaskForm } from './task-form'; import { TaskListForm } from './task-list-form'; import TimeTracker from './time-tracker'; -import type { AIChat, WorkspaceTask, WorkspaceTaskBoard } from '@tuturuuu/types/db'; +import type { + AIChat, + WorkspaceTask, + WorkspaceTaskBoard, +} from '@tuturuuu/types/db'; import { Accordion, AccordionContent, diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx index 981f75d5e..1b1ee5eb6 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/activity-heatmap.tsx @@ -601,7 +601,8 @@ export function ActivityHeatmap({ 0 ); - const avgDuration = monthActivity.length > 0 ? totalDuration / monthActivity.length : 0; + const avgDuration = + monthActivity.length > 0 ? totalDuration / monthActivity.length : 0; return { month: monthStart, @@ -1143,12 +1144,7 @@ export function ActivityHeatmap({ ); }; - const UpcomingCard = ({ - name, - }: { - monthKey: string; - name: string; - }) => ( + const UpcomingCard = ({ name }: { monthKey: string; name: string }) => (
@@ -1384,7 +1380,8 @@ export function ActivityHeatmap({ const buildCardsList = useCallback( (monthlyStatsParam: ReturnType) => { - const { sortedMonths, monthsWithTrends, overallStats } = monthlyStatsParam; + const { sortedMonths, monthsWithTrends, overallStats } = + monthlyStatsParam; const allCards = []; // Determine if user is "established" enough to show upcoming month suggestions @@ -1567,10 +1564,10 @@ export function ActivityHeatmap({ !externalSettings && setInternalSettings((prev) => ({ ...prev, - timeReference: checked - ? 'smart' - : prev.timeReference === 'smart' - ? 'relative' + timeReference: checked + ? 'smart' + : prev.timeReference === 'smart' + ? 'relative' : prev.timeReference, })) } @@ -1782,7 +1779,7 @@ export function ActivityHeatmap({
)} - {settings.viewMode === 'compact-cards' && } + {settings.viewMode === 'compact-cards' && }
); } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx index 9e802b3fa..eec2778d7 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/components/session-history.tsx @@ -614,7 +614,8 @@ const StackedSessionItem: FC<{ : null; // Calculate gap from previous session - const prevSession = index > 0 ? stackedSession.sessions[index - 1] : null; + const prevSession = + index > 0 ? stackedSession.sessions[index - 1] : null; const gapInSeconds = prevSession?.end_time ? sessionStart.diff( @@ -1122,12 +1123,13 @@ export function SessionHistory({ ).length, }; - const bestTimeOfDay = sessionsForPeriod.length > 0 - ? Object.entries(timeOfDayBreakdown).reduce<[string, number]>( - (a, b) => a[1] > b[1] ? a : b, - ['morning', 0] // sensible default prevents TypeError - )[0] - : 'none'; + const bestTimeOfDay = + sessionsForPeriod.length > 0 + ? Object.entries(timeOfDayBreakdown).reduce<[string, number]>( + (a, b) => (a[1] > b[1] ? a : b), + ['morning', 0] // sensible default prevents TypeError + )[0] + : 'none'; const longestSession = sessionsForPeriod.length > 0 @@ -1322,8 +1324,7 @@ export function SessionHistory({ 'Description', ]; - const escape = (v: string) => - /^[=+\-@]/.test(v) ? `'${v}` : v; + const escape = (v: string) => (/^[=+\-@]/.test(v) ? `'${v}` : v); const csvData = sessionsForPeriod.map((session) => { const userTz = dayjs.tz.guess(); diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts index 2442933f3..85f0f679a 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/time-tracker/utils.ts @@ -185,7 +185,13 @@ export function generateAssigneeInitials(assignee: { if (name && name.length > 0) { // Handle names with multiple parts (e.g., "John Doe" -> "JD") const parts = name.split(/\s+/).filter(Boolean); - if (parts.length >= 2 && parts[0] && parts[1] && parts[0].length > 0 && parts[1].length > 0) { + if ( + parts.length >= 2 && + parts[0] && + parts[1] && + parts[0].length > 0 && + parts[1].length > 0 + ) { return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); } // Safe access to first character diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts index ad0189b8d..0b2026855 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts @@ -68,12 +68,22 @@ export async function GET( } const url = new URL(request.url); - - const parsedLimit = Number.parseInt(url.searchParams.get('limit') ?? '', 10); - const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 100; - const parsedOffset = Number.parseInt(url.searchParams.get('offset') ?? '', 10); - const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; + const parsedLimit = Number.parseInt( + url.searchParams.get('limit') ?? '', + 10 + ); + const limit = + Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.min(parsedLimit, 200) + : 100; + + const parsedOffset = Number.parseInt( + url.searchParams.get('offset') ?? '', + 10 + ); + const offset = + Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; const boardId = url.searchParams.get('boardId'); const listId = url.searchParams.get('listId'); @@ -132,40 +142,38 @@ export async function GET( // Transform the data to match the expected WorkspaceTask format const tasks = - data - - ?.map((task: RawTaskData) => ({ - id: task.id, - name: task.name, - description: task.description, - priority: task.priority, - completed: task.completed, - start_date: task.start_date, - end_date: task.end_date, - created_at: task.created_at, - list_id: task.list_id, - // Add board information for context - board_name: task.task_lists?.workspace_boards?.name, - list_name: task.task_lists?.name, - // Add assignee information - assignees: [ - ...(task.assignees ?? []) - .map((a) => a.user) - .filter((u): u is NonNullable => !!u?.id) - .reduce((uniqueUsers, user) => { - if (!uniqueUsers.has(user.id)) { - uniqueUsers.set(user.id, user); - } - return uniqueUsers; - }, new Map[0]['user']>()) - .values(), - ], - // Add helper field to identify if current user is assigned - is_assigned_to_current_user: - task.assignees?.some( - (a: TaskAssigneeData) => a.user?.id === user.id - ) || false, - })) || []; + data?.map((task: RawTaskData) => ({ + id: task.id, + name: task.name, + description: task.description, + priority: task.priority, + completed: task.completed, + start_date: task.start_date, + end_date: task.end_date, + created_at: task.created_at, + list_id: task.list_id, + // Add board information for context + board_name: task.task_lists?.workspace_boards?.name, + list_name: task.task_lists?.name, + // Add assignee information + assignees: [ + ...(task.assignees ?? []) + .map((a) => a.user) + .filter((u): u is NonNullable => !!u?.id) + .reduce((uniqueUsers, user) => { + if (!uniqueUsers.has(user.id)) { + uniqueUsers.set(user.id, user); + } + return uniqueUsers; + }, new Map[0]['user']>()) + .values(), + ], + // Add helper field to identify if current user is assigned + is_assigned_to_current_user: + task.assignees?.some( + (a: TaskAssigneeData) => a.user?.id === user.id + ) || false, + })) || []; return NextResponse.json({ tasks }); } catch (error) { From 37b6d71a86b9d07f953e902653fcfe2067c230c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?= Date: Wed, 18 Jun 2025 19:32:47 +0700 Subject: [PATCH 35/35] feat(database): add was_resumed field to time_tracking_sessions and update analytics view --- ... 20250618000000_add_was_resumed_field.sql} | 2 + packages/types/src/supabase.ts | 172 +++++++++--------- 2 files changed, 88 insertions(+), 86 deletions(-) rename apps/db/supabase/migrations/{20250119000000_add_was_resumed_field.sql => 20250618000000_add_was_resumed_field.sql} (97%) diff --git a/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql b/apps/db/supabase/migrations/20250618000000_add_was_resumed_field.sql similarity index 97% rename from apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql rename to apps/db/supabase/migrations/20250618000000_add_was_resumed_field.sql index 5510e4efd..b5daea2c1 100644 --- a/apps/db/supabase/migrations/20250119000000_add_was_resumed_field.sql +++ b/apps/db/supabase/migrations/20250618000000_add_was_resumed_field.sql @@ -18,6 +18,8 @@ CREATE INDEX idx_time_tracking_sessions_was_resumed ON public.time_tracking_sessions USING btree (was_resumed) WHERE was_resumed = true; +drop view time_tracking_session_analytics; + -- Update the time_tracking_session_analytics view to include was_resumed CREATE OR REPLACE VIEW time_tracking_session_analytics AS SELECT diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index b1c8446e0..6d8effa46 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -3795,7 +3795,7 @@ export type Database = { title: string; updated_at: string | null; user_id: string; - was_resumed: boolean | null; + was_resumed: boolean; ws_id: string; }; Insert: { @@ -3813,7 +3813,7 @@ export type Database = { title: string; updated_at?: string | null; user_id: string; - was_resumed?: boolean | null; + was_resumed?: boolean; ws_id: string; }; Update: { @@ -3831,7 +3831,7 @@ export type Database = { title?: string; updated_at?: string | null; user_id?: string; - was_resumed?: boolean | null; + was_resumed?: boolean; ws_id?: string; }; Relationships: [ @@ -7420,53 +7420,53 @@ export type Database = { | { search_query: string } | { search_query: string; - enabled_filter?: boolean; role_filter?: string; + enabled_filter?: boolean; }; Returns: number; }; create_ai_chat: { - Args: { model: string; message: string; title: string }; + Args: { title: string; model: string; message: string }; Returns: string; }; generate_cross_app_token: { Args: | { p_expiry_seconds?: number; - p_target_app: string; p_origin_app: string; p_session_data?: Json; p_user_id: string; + p_target_app: string; } | { - p_target_app: string; - p_origin_app: string; p_user_id: string; + p_origin_app: string; + p_target_app: string; p_expiry_seconds?: number; }; Returns: string; }; get_challenge_stats: { - Args: { user_id_param: string; challenge_id_param: string }; + Args: { challenge_id_param: string; user_id_param: string }; Returns: { total_score: number; problems_attempted: number; }[]; }; get_daily_income_expense: { - Args: { _ws_id: string; past_days?: number }; + Args: { past_days?: number; _ws_id: string }; Returns: { + total_expense: number; day: string; total_income: number; - total_expense: number; }[]; }; get_daily_prompt_completion_tokens: { Args: { past_days?: number }; Returns: { total_completion_tokens: number; - day: string; total_prompt_tokens: number; + day: string; }[]; }; get_finance_invoices_count: { @@ -7493,8 +7493,8 @@ export type Database = { Args: { past_hours?: number }; Returns: { total_prompt_tokens: number; - hour: string; total_completion_tokens: number; + hour: string; }[]; }; get_inventory_batches_count: { @@ -7509,20 +7509,20 @@ export type Database = { Args: { _warehouse_ids?: string[]; _has_unit?: boolean; - _ws_id?: string; _category_ids?: string[]; + _ws_id?: string; }; Returns: { + id: string; + name: string; + manufacturer: string; + unit: string; + unit_id: string; + category: string; + price: number; created_at: string; - ws_id: string; amount: number; - price: number; - category: string; - unit_id: string; - unit: string; - manufacturer: string; - name: string; - id: string; + ws_id: string; }[]; }; get_inventory_products_count: { @@ -7544,16 +7544,16 @@ export type Database = { get_monthly_income_expense: { Args: { _ws_id: string; past_months?: number }; Returns: { + total_expense: number; month: string; total_income: number; - total_expense: number; }[]; }; get_monthly_prompt_completion_tokens: { Args: { past_months?: number }; Returns: { - month: string; total_prompt_tokens: number; + month: string; total_completion_tokens: number; }[]; }; @@ -7564,18 +7564,18 @@ export type Database = { get_possible_excluded_groups: { Args: { _ws_id: string; included_groups: string[] }; Returns: { - id: string; amount: number; - ws_id: string; + id: string; name: string; + ws_id: string; }[]; }; get_possible_excluded_tags: { - Args: { _ws_id: string; included_tags: string[] }; + Args: { included_tags: string[]; _ws_id: string }; Returns: { - ws_id: string; id: string; name: string; + ws_id: string; amount: number; }[]; }; @@ -7583,25 +7583,25 @@ export type Database = { Args: Record; Returns: { unique_users_count: number; - total_count: number; - active_count: number; completed_count: number; + total_count: number; latest_session_date: string; + active_count: number; }[]; }; get_session_templates: { Args: { workspace_id: string; - limit_count?: number; user_id_param: string; + limit_count?: number; }; Returns: { - description: string; + category_name: string; title: string; + description: string; category_id: string; task_id: string; tags: string[]; - category_name: string; category_color: string; task_name: string; usage_count: number; @@ -7612,19 +7612,19 @@ export type Database = { get_submission_statistics: { Args: Record; Returns: { - latest_submission_date: string; total_count: number; unique_users_count: number; + latest_submission_date: string; }[]; }; get_transaction_categories_with_amount: { Args: Record; Returns: { created_at: string; - id: string; - name: string; ws_id: string; is_expense: boolean; + name: string; + id: string; amount: number; }[]; }; @@ -7635,34 +7635,34 @@ export type Database = { get_user_session_stats: { Args: { user_id: string }; Returns: { - active_sessions: number; current_session_age: unknown; total_sessions: number; + active_sessions: number; }[]; }; get_user_sessions: { Args: { user_id: string }; Returns: { + session_id: string; + created_at: string; updated_at: string; user_agent: string; is_current: boolean; ip: string; - session_id: string; - created_at: string; }[]; }; get_user_tasks: { Args: { _board_id: string }; Returns: { - name: string; + list_id: string; + start_date: string; + completed: boolean; priority: number; description: string; + name: string; id: string; board_id: string; - list_id: string; end_date: string; - start_date: string; - completed: boolean; }[]; }; get_workspace_drive_size: { @@ -7683,19 +7683,19 @@ export type Database = { }; get_workspace_user_groups: { Args: { - _ws_id: string; - included_tags: string[]; - excluded_tags: string[]; search_query: string; + excluded_tags: string[]; + included_tags: string[]; + _ws_id: string; }; Returns: { - id: string; - name: string; - notes: string; - ws_id: string; tags: string[]; tag_count: number; created_at: string; + id: string; + ws_id: string; + notes: string; + name: string; }[]; }; get_workspace_user_groups_count: { @@ -7705,11 +7705,21 @@ export type Database = { get_workspace_users: { Args: { _ws_id: string; - search_query: string; - excluded_groups: string[]; included_groups: string[]; + excluded_groups: string[]; + search_query: string; }; Returns: { + full_name: string; + display_name: string; + updated_at: string; + created_at: string; + linked_users: Json; + group_count: number; + groups: string[]; + ws_id: string; + balance: number; + note: string; national_id: string; address: string; guardian: string; @@ -7718,18 +7728,8 @@ export type Database = { gender: string; phone: string; email: string; - display_name: string; - full_name: string; - avatar_url: string; id: string; - note: string; - balance: number; - ws_id: string; - groups: string[]; - group_count: number; - linked_users: Json; - created_at: string; - updated_at: string; + avatar_url: string; }[]; }; get_workspace_users_count: { @@ -7741,11 +7741,11 @@ export type Database = { Returns: number; }; get_workspace_wallets_expense: { - Args: { ws_id: string; start_date?: string; end_date?: string }; + Args: { end_date?: string; start_date?: string; ws_id: string }; Returns: number; }; get_workspace_wallets_income: { - Args: { ws_id: string; start_date?: string; end_date?: string }; + Args: { end_date?: string; start_date?: string; ws_id: string }; Returns: number; }; has_other_owner: { @@ -7753,7 +7753,7 @@ export type Database = { Returns: boolean; }; insert_ai_chat_message: { - Args: { source: string; chat_id: string; message: string }; + Args: { message: string; chat_id: string; source: string }; Returns: undefined; }; is_list_accessible: { @@ -7781,7 +7781,7 @@ export type Database = { Returns: boolean; }; is_org_member: { - Args: { _org_id: string; _user_id: string }; + Args: { _user_id: string; _org_id: string }; Returns: boolean; }; is_project_member: { @@ -7793,7 +7793,7 @@ export type Database = { Returns: boolean; }; is_task_board_member: { - Args: { _board_id: string; _user_id: string }; + Args: { _user_id: string; _board_id: string }; Returns: boolean; }; is_user_task_in_board: { @@ -7805,15 +7805,15 @@ export type Database = { Returns: Json; }; nova_get_challenge_with_user_stats: { - Args: { user_id: string; challenge_id: string }; + Args: { challenge_id: string; user_id: string }; Returns: Json; }; nova_get_user_daily_sessions: { - Args: { challenge_id: string; user_id: string }; + Args: { user_id: string; challenge_id: string }; Returns: number; }; nova_get_user_total_sessions: { - Args: { user_id: string; challenge_id: string }; + Args: { challenge_id: string; user_id: string }; Returns: number; }; revoke_all_cross_app_tokens: { @@ -7825,25 +7825,25 @@ export type Database = { Returns: number; }; revoke_user_session: { - Args: { target_user_id: string; session_id: string }; + Args: { session_id: string; target_user_id: string }; Returns: boolean; }; search_users: { Args: - | { page_number: number; page_size: number; search_query: string } | { - search_query: string; - page_number: number; - page_size: number; role_filter?: string; + page_size: number; + page_number: number; + search_query: string; enabled_filter?: boolean; - }; + } + | { search_query: string; page_number: number; page_size: number }; Returns: { + handle: string; id: string; display_name: string; deleted: boolean; avatar_url: string; - handle: string; bio: string; created_at: string; user_id: string; @@ -7859,16 +7859,16 @@ export type Database = { }; search_users_by_name: { Args: { - min_similarity?: number; search_query: string; result_limit?: number; + min_similarity?: number; }; Returns: { handle: string; + id: string; display_name: string; - relevance: number; avatar_url: string; - id: string; + relevance: number; }[]; }; sum_quiz_scores: { @@ -7878,11 +7878,11 @@ export type Database = { }[]; }; transactions_have_same_abs_amount: { - Args: { transaction_id_1: string; transaction_id_2: string }; + Args: { transaction_id_2: string; transaction_id_1: string }; Returns: boolean; }; transactions_have_same_amount: { - Args: { transaction_id_1: string; transaction_id_2: string }; + Args: { transaction_id_2: string; transaction_id_1: string }; Returns: boolean; }; update_expired_sessions: { @@ -7890,15 +7890,15 @@ export type Database = { Returns: undefined; }; update_session_total_score: { - Args: { challenge_id_param: string; user_id_param: string }; + Args: { user_id_param: string; challenge_id_param: string }; Returns: undefined; }; validate_cross_app_token: { - Args: { p_target_app: string; p_token: string }; + Args: { p_token: string; p_target_app: string }; Returns: string; }; validate_cross_app_token_with_session: { - Args: { p_token: string; p_target_app: string }; + Args: { p_target_app: string; p_token: string }; Returns: { user_id: string; session_data: Json;