From 953c62cc666e5cc2fac5c3cf232a887c83e0cb8b Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 11 Jul 2025 20:10:28 -0400 Subject: [PATCH] feat: Enhance UserPoints component with improved filtering and layout - Updated UserPoints component to include a more user-friendly filter bar with enhanced styling. - Added a summary section to display the current filter context. - Refactored user selection and time period filtering logic for better clarity and performance. - Improved the layout of points cards and history sections for better visual hierarchy. - Integrated a bar chart for visual representation of points over time. - Updated redeem points functionality with better user feedback. feat: Add keyboard shortcuts in AddTaskModal for improved usability - Implemented keyboard shortcuts for adding descriptions, subtasks, and due dates. - Enhanced user experience by providing visual hints for keyboard shortcuts. - Refactored task creation logic to streamline the process. fix: Refactor ChoreActionMenu to handle mouse events and improve accessibility - Added mouse enter and leave event handlers for better interaction feedback. - Adjusted menu positioning for improved usability. refactor: Update RichTextEditor to support focus handling from parent components - Converted RichTextEditor to use forwardRef for better integration with parent components. - Exposed focus and blur methods for external control. - Improved image upload handling with better error management. fix: Adjust SubTask component to handle Enter key behavior correctly - Modified key event handling to prevent unintended task creation when holding meta or ctrl keys. - Added autoFocus prop to new task input for better user experience. --- src/App.jsx | 10 +- src/contexts/Contexts.jsx | 4 + src/contexts/RouterContext.jsx | 5 + src/hooks/useSSE.js | 174 ++- src/main.jsx | 2 +- src/queries/ChoreQueries.jsx | 2 +- src/service/AlertsProvider.jsx | 84 ++ src/utils/Chores.jsx | 111 ++ src/utils/Fetcher.jsx | 67 +- src/utils/PlatformUtils.js | 63 + src/utils/TokenManager.jsx | 9 +- src/views/ChoreEdit/ChoreEdit.jsx | 30 +- src/views/ChoreEdit/ChoreView.jsx | 263 ++++- src/views/ChoreEdit/TimePassedCard.jsx | 134 ++- src/views/ChoreEdit/TimerSplitButton.jsx | 162 +++ src/views/Chores/ActivitesCard.jsx | 48 +- src/views/Chores/ChoreCard.jsx | 1009 ++++++++++++----- .../Chores/LocalNotificationScheduler.js | 326 ++++-- src/views/Chores/MultiSelectHelp.jsx | 194 ++-- src/views/Chores/MyChores.jsx | 336 ++++-- .../Chores/NotificationAccessSnackbar.jsx | 30 +- src/views/Chores/SortAndGrouping.jsx | 3 +- src/views/History/ChoreHistory.jsx | 10 +- src/views/History/HistoryCard.jsx | 83 +- src/views/Labels/LabelView.jsx | 156 ++- src/views/Modals/Inputs/ConfirmationModal.jsx | 79 +- src/views/Modals/Inputs/DateModal.jsx | 50 +- src/views/Modals/Inputs/EditThingState.jsx | 47 +- src/views/Modals/Inputs/TimerEditModal.jsx | 236 +++- src/views/Modals/RedeemPointsModal.jsx | 291 ++++- src/views/Settings/NotificationSetting.jsx | 4 +- src/views/Settings/Settings.jsx | 6 +- src/views/TestView/TimerCard.jsx | 490 ++++++++ src/views/Things/ThingsHistory.jsx | 148 ++- src/views/Things/ThingsView.jsx | 648 +++++++---- src/views/Timer/TimerDetails.jsx | 999 ++++++++++++++++ src/views/User/UserActivities.jsx | 865 ++++++++++---- src/views/User/UserPoints.jsx | 396 ++++--- src/views/components/AddTaskModal.jsx | 152 ++- src/views/components/ChoreActionMenu.jsx | 15 +- src/views/components/RichTextEditor.jsx | 402 ++++--- src/views/components/SubTask.jsx | 6 +- 42 files changed, 6262 insertions(+), 1887 deletions(-) create mode 100644 src/service/AlertsProvider.jsx create mode 100644 src/utils/PlatformUtils.js create mode 100644 src/views/ChoreEdit/TimerSplitButton.jsx create mode 100644 src/views/TestView/TimerCard.jsx create mode 100644 src/views/Timer/TimerDetails.jsx diff --git a/src/App.jsx b/src/App.jsx index 665fd18..2587f7c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,10 +7,7 @@ import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' import { AuthenticationProvider } from './service/AuthenticationService' -import { - NotificationProvider, - useNotification, -} from './service/NotificationProvider' +import { useNotification } from './service/NotificationProvider' import { apiManager } from './utils/TokenManager' import NetworkBanner from './views/components/NetworkBanner' @@ -118,10 +115,9 @@ function App() { return (
+ - - - +
) diff --git a/src/contexts/Contexts.jsx b/src/contexts/Contexts.jsx index 2b3472a..1bef341 100644 --- a/src/contexts/Contexts.jsx +++ b/src/contexts/Contexts.jsx @@ -1,3 +1,5 @@ +import { AlertsProvider } from '../service/AlertsProvider' +import { NotificationProvider } from '../service/NotificationProvider' import QueryContext from './QueryContext' import RouterContext from './RouterContext' import SSEProvider from './SSEContext' @@ -6,8 +8,10 @@ import WebSocketProvider from './WebSocketContext' const Contexts = () => { const contexts = [ + AlertsProvider, ThemeContext, QueryContext, + NotificationProvider, SSEProvider, WebSocketProvider, RouterContext, diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 1c5a37c..654750f 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -24,6 +24,7 @@ import TermsView from '../views/Terms/TermsView' import TestView from '../views/TestView/Test' import ThingsHistory from '../views/Things/ThingsHistory' import ThingsView from '../views/Things/ThingsView' +import TimerDetails from '../views/Timer/TimerDetails' import UserActivities from '../views/User/UserActivities' import UserPoints from '../views/User/UserPoints' import NotFound from '../views/components/NotFound' @@ -70,6 +71,10 @@ const Router = createBrowserRouter([ path: '/chores/:choreId/history', element: , }, + { + path: '/chores/:choreId/timer', + element: , + }, { path: '/my/chores', element: , diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js index 26b92cd..55c009e 100644 --- a/src/hooks/useSSE.js +++ b/src/hooks/useSSE.js @@ -1,8 +1,9 @@ import { useQueryClient } from '@tanstack/react-query' import { EventSourcePolyfill } from 'event-source-polyfill' import { useCallback, useEffect, useRef, useState } from 'react' +import { useAlerts } from '../service/AlertsProvider' +import { useNotification } from '../service/NotificationProvider' import { apiManager, isTokenValid } from '../utils/TokenManager' - const SSE_STATES = { CONNECTING: 0, OPEN: 1, @@ -27,6 +28,8 @@ export const useSSE = () => { const heartbeatMonitorRef = useRef(null) const queryClient = useQueryClient() + const { showError, showNotification } = useNotification() + const { showAlert } = useAlerts() const getSSEUrl = useCallback(() => { const token = localStorage.getItem('ca_token') @@ -54,54 +57,111 @@ export const useSSE = () => { if (eventData.type === 'heartbeat') { lastHeartbeatRef.current = Date.now() } + console.log('SSE Message received:', eventData) // Handle different event types and update React Query cache accordingly switch (eventData.type) { case 'chore.created': case 'chore.updated': case 'chore.completed': - case 'chore.skipped': - queryClient.invalidateQueries(['choresHistory', 7]) - queryClient.invalidateQueries(['chores']) + case 'chore.skipped': { + showNotification({ + type: 'info', + title: `Task ${eventData.type.replace('chore.', '')}`, + message: `${eventData.data.user.displayName} ${eventData.type.replace('chore.', '')} "${eventData.data.chore.name}"`, + duration: 5000, + }) + const updatedChore = eventData.data.chore + + // Update individual chore cache + queryClient.setQueryData(['chore', updatedChore.id], oldData => { + if (!oldData) return { res: updatedChore } + return { res: { ...oldData.res, ...updatedChore } } + }) + + // Update chores list cache - add debugging + queryClient.setQueryData(['chores'], oldData => { + if (!oldData) return { res: [updatedChore] } + + if (!oldData.res || !Array.isArray(oldData.res)) { + return { res: [updatedChore] } + } + + // Check if the chore exists in the cache + const choreExists = oldData.res.some( + chore => chore.id === updatedChore.id, + ) + + // If it's a one-time chore that's completed, we might need to remove it + if ( + eventData.type === 'chore.completed' && + updatedChore.frequencyType === 'once' + ) { + return { + res: oldData.res.filter( + chore => chore.id !== updatedChore.id, + ), + } + } + + // If chore update then also refetch chore details: + if (eventData.type === 'chore.updated') { + queryClient.invalidateQueries(['choreDetails', updatedChore.id]) + queryClient.refetchQueries({ + queryKey: ['choreDetails', updatedChore.id], + }) + } + + // Otherwise update the existing chore or add if it doesn't exist + return { + res: choreExists + ? oldData.res.map(chore => { + if (chore.id === updatedChore.id) { + return { ...chore, ...updatedChore } + } + return chore + }) + : [...oldData.res, updatedChore], + } + }) - // If it's a specific chore event, also invalidate that chore's details - if (eventData.data.chore?.id) { - queryClient.invalidateQueries(['chore', eventData.data.chore.id]) - queryClient.invalidateQueries([ - 'choreDetails', - eventData.data.chore.id, - ]) - } break + } case 'chore.deleted': - // Invalidate chores queries to refetch data - queryClient.invalidateQueries(['chores']) + // update chores list cache + queryClient.setQueryData(['chores'], oldData => { + if (!oldData || !oldData.res) return oldData + return { + res: oldData.res.filter( + chore => chore.id !== eventData.data.choreId, + ), + } + }) - // If it's a specific chore event, also invalidate that chore's details - if (eventData.data.chore?.id) { - queryClient.invalidateQueries(['chore', eventData.data.chore.id]) - queryClient.invalidateQueries([ - 'choreDetails', - eventData.data.chore.id, - ]) - } break case 'subtask.updated': case 'subtask.completed': - // Invalidate the specific chore that contains this subtask - if (eventData.data.choreId) { - queryClient.invalidateQueries(['chore', eventData.data.choreId]) - queryClient.invalidateQueries([ - 'choreDetails', - eventData.data.choreId, - ]) - } - // Also invalidate general chores list - queryClient.invalidateQueries(['chores']) - break + queryClient.refetchQueries({ + queryKey: ['choreDetails', eventData.data.choreId], + }) + // Invalidate the specific chore that contains this subtask + // if (eventData.data.choreId) { + // queryClient.invalidateQueries(['chore', eventData.data.choreId]) + // queryClient.invalidateQueries([ + // 'choreDetails', + // eventData.data.choreId, + // ]) + // } + // Also invalidate general chores list + // queryClient.invalidateQueries(['chores']) + break + case 'chore.status': + console.log('SSE chore.status event received:', eventData.data) + + break case 'heartbeat': // Heartbeat events don't need cache invalidation console.debug('SSE Heartbeat received at', new Date().toISOString()) @@ -111,11 +171,21 @@ export const useSSE = () => { console.log('SSE connection established') setError(null) lastHeartbeatRef.current = Date.now() + showAlert({ + type: 'success', + color: 'success', + message: 'You are now receiving real-time as they happen.', + }) break case 'error': console.error('SSE error event:', eventData.data) - setError(eventData.data.message || 'SSE error occurred') + showError({ + title: 'Real-time Error', + message: + eventData.data.message || + 'An error occurred with real-time updates', + }) break default: @@ -123,11 +193,14 @@ export const useSSE = () => { } } catch (err) { console.error('Failed to parse SSE message:', err) - setError('Failed to parse server message') + showError({ + title: 'Message Error', + message: 'Failed to parse server message', + }) return // Stop processing if JSON parsing fails } }, - [queryClient], + [queryClient, showNotification, showError], ) const stopHeartbeatMonitor = useCallback(() => { @@ -141,9 +214,11 @@ export const useSSE = () => { const connect = useCallback(() => { if (isCircuitBreakerOpen) { console.log('SSE: Circuit breaker is open, preventing connection attempt') - setError( - 'Connection blocked due to repeated failures. Please try again later.', - ) + showError({ + title: 'Connection Temporarily Disabled', + message: + 'Connection blocked due to repeated failures. Please try again later.', + }) return } @@ -152,9 +227,11 @@ export const useSSE = () => { 'SSE: Maximum reconnection attempts reached, opening circuit breaker', ) setIsCircuitBreakerOpen(true) - setError( - 'Maximum connection attempts reached. SSE disabled for 5 minutes.', - ) + showError({ + title: 'Connection Failed', + message: + 'Maximum connection attempts reached. SSE disabled for 10 minutes.', + }) // Reset circuit breaker after timeout setTimeout(() => { @@ -308,10 +385,19 @@ export const useSSE = () => { } } catch (err) { console.error('Failed to create SSE connection:', err) - setError('Failed to establish connection') + showError({ + title: 'Connection Error', + message: 'Failed to establish real-time connection. Please try again.', + }) setConnectionState(SSE_STATES.CLOSED) } - }, [getSSEUrl, handleSSEMessage, stopHeartbeatMonitor, isCircuitBreakerOpen]) + }, [ + getSSEUrl, + handleSSEMessage, + stopHeartbeatMonitor, + isCircuitBreakerOpen, + showError, + ]) const disconnect = useCallback(() => { isManuallyClosedRef.current = true diff --git a/src/main.jsx b/src/main.jsx index baf76fe..c9aa258 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +1,6 @@ +import { QueryClient } from '@tanstack/react-query' import React from 'react' import ReactDOM from 'react-dom/client' -import { QueryClient } from '@tanstack/react-query' import App from './App.jsx' import Contexts from './contexts/Contexts.jsx' import './index.css' diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index be7310f..6af620e 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -13,7 +13,7 @@ import { localStore } from '../utils/LocalStore' export const useChores = includeArchive => { return useQuery({ - queryKey: ['chores'], + queryKey: ['chores', includeArchive], queryFn: async () => { const onlineChores = await GetChoresNew(includeArchive) diff --git a/src/service/AlertsProvider.jsx b/src/service/AlertsProvider.jsx new file mode 100644 index 0000000..6f29486 --- /dev/null +++ b/src/service/AlertsProvider.jsx @@ -0,0 +1,84 @@ +import { Alert, Box } from '@mui/joy' +import PropTypes from 'prop-types' +import { createContext, useCallback, useContext, useState } from 'react' + +const FADE_DURATION = 400 // ms +const ALERT_DURATION = 5000 // ms + +const AlertsContext = createContext() + +// Helper function to create a delay +const delay = ms => new Promise(res => setTimeout(res, ms)) + +export const AlertsProvider = ({ children }) => { + const [show, setShow] = useState(false) + const [visibleAlert, setVisibleAlert] = useState(null) + + const showAlert = useCallback(async alertObj => { + setVisibleAlert(alertObj) + setShow(false) + + await delay(10) + + setShow(true) + await delay(ALERT_DURATION) + + setShow(false) + await delay(FADE_DURATION) + + setVisibleAlert(null) + }, []) + + const hideAlert = useCallback(() => { + setShow(false) + // Wait for the fade out transition to complete before unmounting + setTimeout(() => { + setVisibleAlert(null) + }, FADE_DURATION) + }, []) + + return ( + + {children} + {visibleAlert && ( + + + {visibleAlert.message} + + + )} + + ) +} + +AlertsProvider.propTypes = { + children: PropTypes.node.isRequired, +} + +export const useAlerts = () => useContext(AlertsContext) diff --git a/src/utils/Chores.jsx b/src/utils/Chores.jsx index b8d3833..82fd69e 100644 --- a/src/utils/Chores.jsx +++ b/src/utils/Chores.jsx @@ -2,6 +2,13 @@ import moment from 'moment' import { TASK_COLOR } from './Colors.jsx' const priorityOrder = [1, 2, 3, 4, 0] +// ChoreGrouperOptions enum: +export const GROUPING_OPTIONS = { + SMART: 'default', + DUE_DATE: 'due_date', + PRIORITY: 'priority', + LABELS: 'labels', +} export const ChoresGrouper = (groupBy, chores, filter) => { if (filter) { @@ -12,6 +19,110 @@ export const ChoresGrouper = (groupBy, chores, filter) => { chores.sort(ChoreSorter) var groups = [] switch (groupBy) { + case 'default': + // same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started: + var groupRaw = { + Started: [], + Today: [], + Tomorrow: [], + 'Next 7 Days': [], + 'Later This Month': [], + Future: [], + Overdue: [], + Anytime: [], + } + chores.forEach(chore => { + if (chore.status === 1 || chore.status === 2) { + groupRaw['Started'].push(chore) + } else if (chore.nextDueDate === null) { + groupRaw['Anytime'].push(chore) + } else if (new Date(chore.nextDueDate) < new Date()) { + groupRaw['Overdue'].push(chore) + } else if ( + new Date(chore.nextDueDate).toDateString() === + new Date().toDateString() + ) { + groupRaw['Today'].push(chore) + } else if ( + new Date(chore.nextDueDate).toDateString() === + new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString() + ) { + groupRaw['Tomorrow'].push(chore) + } else if ( + new Date(chore.nextDueDate) < + new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) && + new Date(chore.nextDueDate) > + new Date(Date.now() + 24 * 60 * 60 * 1000) + ) { + groupRaw['Next 7 Days'].push(chore) + } else if ( + new Date(chore.nextDueDate).getMonth() === new Date().getMonth() && + new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear() + ) { + groupRaw['Later This Month'].push(chore) + } else { + groupRaw['Future'].push(chore) + } + }) + groups = [] + if (groupRaw['Started'].length > 0) { + groups.push({ + name: 'Started', + content: groupRaw['Started'], + color: TASK_COLOR.STARTED, + }) + } + if (groupRaw['Overdue'].length > 0) { + groups.push({ + name: 'Overdue', + content: groupRaw['Overdue'], + color: TASK_COLOR.OVERDUE, + }) + } + if (groupRaw['Today'].length > 0) { + groups.push({ + name: 'Today', + content: groupRaw['Today'], + color: TASK_COLOR.TODAY, + }) + } + if (groupRaw['Tomorrow'].length > 0) { + groups.push({ + name: 'Tomorrow', + content: groupRaw['Tomorrow'], + color: TASK_COLOR.TOMORROW, + }) + } + if (groupRaw['Next 7 Days'].length > 0) { + groups.push({ + name: 'Next 7 Days', + content: groupRaw['Next 7 Days'], + color: TASK_COLOR.NEXT_7_DAYS, + }) + } + if (groupRaw['Later This Month'].length > 0) { + groups.push({ + name: 'Later This Month', + content: groupRaw['Later This Month'], + color: TASK_COLOR.LATER_THIS_MONTH, + }) + } + if (groupRaw['Future'].length > 0) { + groups.push({ + name: 'Future', + content: groupRaw['Future'], + color: TASK_COLOR.FUTURE, + }) + } + if (groupRaw['Anytime'].length > 0) { + groups.push({ + name: 'Anytime', + content: groupRaw['Anytime'], + color: TASK_COLOR.ANYTIME, + }) + } + break + case 'due_date': var groupRaw = { Today: [], diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index bf6c92f..ca4d8eb 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -123,6 +123,20 @@ const MarkChoreComplete = (id, body, completedDate, performer) => { }) } +const StartChore = id => { + return Fetch(`/chores/${id}/start`, { + method: 'PUT', + headers: HEADERS(), + }) +} + +const PauseChore = id => { + return Fetch(`/chores/${id}/pause`, { + method: 'PUT', + headers: HEADERS(), + }) +} + const CompleteSubTask = (id, choreId, completedAt) => { var markChoreURL = `/chores/${choreId}/subtask` return Fetch(markChoreURL, { @@ -204,14 +218,6 @@ const UpdateChoreHistory = (choreId, id, choreHistory) => { }) } -const UpdateChoreStatus = (choreId, status) => { - return Fetch(`/chores/${choreId}/status`, { - method: 'PUT', - headers: HEADERS(), - body: JSON.stringify({ status }), - }) -} - const GetAllCircleMembers = async () => { const resp = await Fetch(`/circles/members`, { method: 'GET', @@ -553,11 +559,49 @@ const GetStorageUsage = () => { }) } +// Timer/TimeSession API functions +const GetChoreTimer = choreId => { + return Fetch(`/chores/${choreId}/timer`, { + method: 'GET', + headers: HEADERS(), + }) +} + +const UpdateTimeSession = (choreId, sessionId, sessionData) => { + return Fetch(`/chores/${choreId}/timer/${sessionId}`, { + method: 'PUT', + headers: HEADERS(), + body: JSON.stringify(sessionData), + }) +} + +const DeleteTimeSession = (choreId, sessionId) => { + return Fetch(`/chores/${choreId}/timer/${sessionId}`, { + method: 'DELETE', + headers: HEADERS(), + }) +} + +const ResetChoreTimer = choreId => { + return Fetch(`/chores/${choreId}/timer/reset`, { + method: 'PUT', + headers: HEADERS(), + }) +} + +const ClearChoreTimer = choreId => { + return Fetch(`/chores/${choreId}/timer`, { + method: 'DELETE', + headers: HEADERS(), + }) +} + export { AcceptCircleMemberRequest, ArchiveChore, CancelSubscription, ChangePassword, + ClearChoreTimer, CompleteSubTask, ConfirmMFA, CreateChore, @@ -570,6 +614,7 @@ export { DeleteLabel, DeleteLongLiveToken, DeleteThing, + DeleteTimeSession, DisableMFA, GetAllCircleMembers, GetAllUsers, @@ -577,6 +622,7 @@ export { GetChoreByID, GetChoreDetailById, GetChoreHistory, + GetChoreTimer, GetChores, GetChoresHistory, GetChoresNew, @@ -594,27 +640,30 @@ export { JoinCircle, LeaveCircle, MarkChoreComplete, + PauseChore, PutNotificationTarget, PutWebhookURL, RedeemPoints, RefreshToken, RegenerateBackupCodes, + ResetChoreTimer, ResetPassword, SaveChore, SaveThing, SetupMFA, SkipChore, + StartChore, UnArchiveChore, UpdateChoreAssignee, UpdateChoreHistory, UpdateChorePriority, - UpdateChoreStatus, UpdateDueDate, UpdateLabel, UpdateMemberRole, UpdateNotificationTarget, UpdatePassword, UpdateThingState, + UpdateTimeSession, UpdateUserDetails, VerifyMFA, createChore, diff --git a/src/utils/PlatformUtils.js b/src/utils/PlatformUtils.js new file mode 100644 index 0000000..b421617 --- /dev/null +++ b/src/utils/PlatformUtils.js @@ -0,0 +1,63 @@ +/** + * Utility functions for platform detection + */ + +/** + * Detects if the current platform is macOS using modern APIs with fallback + * @returns {boolean} True if running on macOS, false otherwise + */ +export const isMacOS = () => { + // Modern approach using User-Agent Client Hints API + if (navigator.userAgentData) { + return navigator.userAgentData.platform === 'macOS' + } + + // Fallback for older browsers + return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) +} + +/** + * Gets the appropriate keyboard shortcut text for the current platform + * @param {string} key - The key combination (e.g., 'F', 'K', 'S') + * @param {boolean} withCtrl - Whether to include Ctrl/Cmd modifier + * @param {boolean} withShift - Whether to include Shift modifier + * @returns {string} Platform-appropriate keyboard shortcut text + */ +export const getKeyboardShortcut = ( + key, + withCtrl = true, + withShift = false, +) => { + let shortcut = '' + + if (withCtrl) { + const modifier = isMacOS() ? '⌘' : 'Ctrl+' + shortcut += modifier + } + + if (withShift) { + if (isMacOS()) { + shortcut += '⇧' + } else { + shortcut += 'Shift+' + } + } + + shortcut += key + return shortcut +} + +/** + * Gets common keyboard shortcuts for the current platform + */ +export const getCommonShortcuts = () => ({ + search: getKeyboardShortcut('F'), + newTask: getKeyboardShortcut('K'), + selectAll: getKeyboardShortcut('A'), + multiSelect: getKeyboardShortcut('S'), + save: getKeyboardShortcut('S'), + copy: getKeyboardShortcut('C'), + paste: getKeyboardShortcut('V'), + undo: getKeyboardShortcut('Z'), + redo: getKeyboardShortcut('Z', true, true), // Ctrl/Cmd + Shift + Z +}) diff --git a/src/utils/TokenManager.jsx b/src/utils/TokenManager.jsx index 3344d74..2109364 100644 --- a/src/utils/TokenManager.jsx +++ b/src/utils/TokenManager.jsx @@ -1,4 +1,3 @@ -import { Network } from '@capacitor/network' import { Preferences } from '@capacitor/preferences' import Cookies from 'js-cookie' import murmurhash from 'murmurhash' @@ -82,11 +81,11 @@ export async function Fetch(url, options) { const baseURL = apiManager.getApiURL() const fullURL = `${baseURL}${url}` - const networkStatus = await Network.getStatus() + // const networkStatus = await Network.getStatus() - if (!networkStatus.connected) { - return handleOfflineRequest(fullURL, options) - } + // if (!networkStatus.connected) { + // return handleOfflineRequest(fullURL, options) + // } // Online: Perform the fetch try { diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index dc13c76..9c0958a 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -260,6 +260,7 @@ const ChoreEdit = () => { useEffect(() => { if (isChoreLoading === false && choreData && choreId) { const data = choreData + const isCloneMode = searchParams.get('clone') === 'true' setChore(data.res) setName(data.res.name ? data.res.name : '') @@ -280,7 +281,7 @@ const ChoreEdit = () => { ) setLabelsV2(data.res.labelsV2) - setSubTasks(data.res.subTasks) + setPriority(data.res.priority) setAssignStrategy( data.res.assignStrategy @@ -289,23 +290,30 @@ const ChoreEdit = () => { ) setIsRolling(data.res.isRolling) setIsActive(data.res.isActive) - // parse the due date to a string from this format "2021-10-10T00:00:00.000Z" - // use moment.js or date-fns to format the date for to be usable in the input field: - setDueDate( - data.res.nextDueDate - ? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:ss') - : null, - ) - setUpdatedBy(data.res.updatedBy) - setCreatedBy(data.res.createdBy) + if (isCloneMode) { + if (data.res.subTasks) { + const clonedSubTasks = data.res.subTasks.map(subTask => ({ + ...subTask, + id: -subTask.id, // Negate ID to indicate new sub task + parentId: subTask.parentId ? -subTask.parentId : null, // Negate parent ID if exists + completed: false, // Reset completion status + completedAt: null, // Reset completion date + })) + setSubTasks(clonedSubTasks) + } + if (data.res.name) { + setName(`Copy of ${data.res.name}`) + } + } + setIsNotificable(data.res.notification) setThingTrigger(data.res.thingChore) // setDueDate(data.res.dueDate) // setCompleted(data.res.completed) // setCompletedDate(data.res.completedDate) } - }, [choreData, isChoreLoading]) + }, [choreData, isChoreLoading, searchParams]) // useEffect(() => { // if (userLabels && userLabels.length == 0 && labelsV2.length == 0) { diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 9292126..7a7f89c 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -10,6 +10,7 @@ import { OpenInFull, PeopleAlt, Person, + PlayArrow, SwitchAccessShortcut, } from '@mui/icons-material' import { @@ -44,9 +45,14 @@ import { useCircleMembers } from '../../queries/UserQueries.jsx' import { notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { + DeleteTimeSession, GetChoreDetailById, + GetChoreTimer, MarkChoreComplete, + PauseChore, + ResetChoreTimer, SkipChore, + StartChore, UpdateChorePriority, } from '../../utils/Fetcher' import Priorities from '../../utils/Priorities' @@ -54,6 +60,8 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import LoadingComponent from '../components/Loading.jsx' import RichTextEditor from '../components/RichTextEditor.jsx' import SubTasks from '../components/SubTask.jsx' +import TimePassedCard from './TimePassedCard.jsx' +import TimerSplitButton from './TimerSplitButton.jsx' const ChoreView = () => { const [chore, setChore] = useState({}) @@ -73,6 +81,7 @@ const ChoreView = () => { const [confirmModelConfig, setConfirmModelConfig] = useState({}) const [chorePriority, setChorePriority] = useState(null) const [isDescriptionOpen, setIsDescriptionOpen] = useState(false) + const [timerActionConfig, setTimerActionConfig] = useState({}) const { data: circleMembersData, isLoading: isCircleMembersLoading } = useCircleMembers() const { impersonatedUser } = useImpersonateUser() @@ -222,6 +231,95 @@ const ChoreView = () => { } }) } + const handleChoreStart = () => { + StartChore(choreId).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + setChore(newChore) + }) + } + }) + } + + const handleChorePause = () => { + PauseChore(choreId).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + setChore(newChore) + }) + } + }) + } + + const handleResetTimer = () => { + setTimerActionConfig({ + isOpen: true, + title: 'Reset Timer', + message: + 'Are you sure you want to reset the timer? This will clear all time records since you started the task.', + confirmText: 'Reset Timer', + cancelText: 'Cancel', + onClose: confirmed => { + if (confirmed) { + ResetChoreTimer(choreId).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + setChore(newChore) + queryClient.invalidateQueries(['chores']) + }) + } + }) + } + setTimerActionConfig({}) + }, + }) + } + + const handleClearAllTime = () => { + setTimerActionConfig({ + isOpen: true, + title: 'Clear All Time Records', + message: + 'This will permanently delete all timers for this task and set it back to "not started".', + confirmText: 'Clear All Time', + cancelText: 'Cancel', + onClose: async confirmed => { + if (confirmed) { + const resp = await GetChoreTimer(choreId) + if (resp.ok) { + const data = await resp.json() + const sessionId = data?.res?.id + DeleteTimeSession(choreId, sessionId).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + setChore(newChore) + queryClient.invalidateQueries(['chores']) + }) + } + }) + } + } + setTimerActionConfig({}) + }, + }) + } + if (isChoreLoading || isCircleMembersLoading) { // while loading the chore or circle members, return a loading state return @@ -298,6 +396,21 @@ const ChoreView = () => { mb: 1, }} > + {chore.status !== 0 && ( + + { + if (action === 'pause') { + handleChorePause() + } else if (action === 'resume') { + handleChoreStart() + } + }} + onShowDetails={() => navigate(`/chores/${choreId}/timer`)} + /> + + )} {infoCards.map((card, index) => ( { px: 2, py: 1, minHeight: 90, + height: '100%', // change from space-between to start: justifyContent: 'start', }} @@ -551,7 +665,7 @@ const ChoreView = () => { variant='soft' > - Complete the task + Completion options @@ -574,7 +688,7 @@ const ChoreView = () => { alignItems: 'center', }} > - Add Additional Notes + Add a note } /> @@ -584,7 +698,7 @@ const ChoreView = () => { fullWidth multiline label='Additional Notes' - placeholder='note or information about the task' + placeholder='Add any additional notes here...' value={note || ''} onChange={e => { if (e.target.value.trim() === '') { @@ -627,7 +741,7 @@ const ChoreView = () => { alignItems: 'center', }} > - Specify completion date + Set custom completion time } /> @@ -646,61 +760,113 @@ const ChoreView = () => { - + - + confirmText: 'Skip', + cancelText: 'Cancel', + onClose: confirmed => { + if (confirmed) { + handleSkippingTask() + } + setConfirmModelConfig({}) + }, + }) + }} + disabled={ + chore.lastCompletedDate !== null && + chore.frequencyType === 'once' + } + startDecorator={} + sx={{ + flex: 1, + }} + > + Skip + + + {/* Timer Button - Show split button when timer is active, regular button otherwise */} + {chore.status !== 0 ? ( + { + if (action === 'pause') { + handleChorePause() + } else if (action === 'resume') { + handleChoreStart() + } + }} + onShowDetails={() => navigate(`/chores/${choreId}/timer`)} + onResetTimer={handleResetTimer} + onClearAllTime={handleClearAllTime} + fullWidth + /> + ) : ( + + )} { + ) diff --git a/src/views/ChoreEdit/TimePassedCard.jsx b/src/views/ChoreEdit/TimePassedCard.jsx index 5799166..ae2a195 100644 --- a/src/views/ChoreEdit/TimePassedCard.jsx +++ b/src/views/ChoreEdit/TimePassedCard.jsx @@ -1,8 +1,8 @@ -import { Flag, Schedule } from '@mui/icons-material' +import { Flag, Pause, PlayArrow, Schedule } from '@mui/icons-material' import { Box, Card, Chip, Typography } from '@mui/joy' import { useEffect, useRef, useState } from 'react' -const TimePassedCard = ({ chore }) => { +const TimePassedCard = ({ chore, handleAction, onShowDetails }) => { const [time, setTime] = useState(0) const [shouldAnimate, setShouldAnimate] = useState(false) const [prevStatus, setPrevStatus] = useState(null) // Initialize as null @@ -26,16 +26,22 @@ const TimePassedCard = ({ chore }) => { const calculateCurrentTime = () => { if (chore.timerUpdatedAt && chore.status === 1) { // Active session: base duration + time since start - return ( - Math.floor( - (Date.now() - new Date(chore.timerUpdatedAt).getTime()) / 1000, - ) + (chore.duration || 0) + const timeSinceStart = Math.floor( + (Date.now() - new Date(chore.timerUpdatedAt).getTime()) / 1000, ) + + return timeSinceStart + (chore.duration || 0) } // Not active: just return accumulated duration return chore.duration || 0 } + // Clear any existing timer first + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + // Set initial time const currentTime = calculateCurrentTime() setTime(currentTime) @@ -44,14 +50,9 @@ const TimePassedCard = ({ chore }) => { if (chore.status === 1) { // Active: start interval timer intervalRef.current = setInterval(() => { - setTime(calculateCurrentTime()) + const newTime = calculateCurrentTime() + setTime(newTime) }, 1000) - } else { - // Not active: clear any existing timer - if (intervalRef.current) { - clearInterval(intervalRef.current) - intervalRef.current = null - } } // Cleanup function @@ -61,7 +62,7 @@ const TimePassedCard = ({ chore }) => { intervalRef.current = null } } - }, [chore.status, chore.timerUpdatedAt, chore.duration]) + }, [chore.status, chore.duration, chore.timerUpdatedAt]) const formatTime = seconds => { const hours = Math.floor(seconds / 3600) @@ -76,8 +77,10 @@ const TimePassedCard = ({ chore }) => { sx={{ borderRadius: 'md', boxShadow: 1, + gap: 0, px: 2, py: 1, + height: '75px', alignItems: 'center', ...(shouldAnimate && { animation: 'slideInUp 0.3s ease-out', @@ -99,46 +102,51 @@ const TimePassedCard = ({ chore }) => { level='h4' sx={{ fontWeight: 600, - pt: 1, color: chore.status === 1 ? 'success.main' : 'text.primary', + // mb: 0.5, mb: 0.5, transition: 'all 0.3s ease', transform: chore.status === 1 ? 'scale(1.40)' : 'scale(1)', + cursor: 'pointer', + '&:hover': { + textDecoration: 'underline', + }, }} + onClick={() => onShowDetails?.()} > {formatTime(time)} {/* Status and info section */} - - {/* - ) : chore.status === 2 ? ( - - ) : ( - - ) - } - > - {chore.status === 1 - ? 'Active' - : chore.status === 2 - ? 'Paused' - : 'Idle'} - */} - + {/* Show start time and user if active */} + {chore.status === 1 ? ( + } + onClick={() => { + handleAction('pause') + }} + > + Pause + + ) : ( + } + onClick={() => { + handleAction('resume') + }} + > + Resume + + )} + + {/* Chips for start time and current session */} {chore.status === 1 && chore.timerUpdatedAt && ( <> {/* Original start time */} @@ -146,10 +154,9 @@ const TimePassedCard = ({ chore }) => { } > - {'Started '} {new Date(chore.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', @@ -162,10 +169,9 @@ const TimePassedCard = ({ chore }) => { } > - {'Session '} {new Date(chore.timerUpdatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', @@ -175,29 +181,19 @@ const TimePassedCard = ({ chore }) => { )} - {/* Chips FOr paused : */} + {/* Chips for paused state */} {chore.status === 2 && ( - <> - } - > - Paused - - } - > - {new Date(chore.timerUpdatedAt).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - + } + > + {new Date(chore.timerUpdatedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + )} diff --git a/src/views/ChoreEdit/TimerSplitButton.jsx b/src/views/ChoreEdit/TimerSplitButton.jsx new file mode 100644 index 0000000..88f2bec --- /dev/null +++ b/src/views/ChoreEdit/TimerSplitButton.jsx @@ -0,0 +1,162 @@ +import { + ArrowDropDown, + DeleteSweep, + Info, + Pause, + PlayArrow, + RestartAlt, +} from '@mui/icons-material' +import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy' +import { useEffect, useRef, useState } from 'react' + +const TimerSplitButton = ({ + chore, + onAction, + onShowDetails, + onResetTimer, + onClearAllTime, + disabled = false, + fullWidth = false, +}) => { + const [anchorEl, setAnchorEl] = useState(null) + const isMenuOpen = Boolean(anchorEl) + const menuRef = useRef(null) + + const handleMainAction = () => { + if (chore.status === 1) { + onAction('pause') + } else if (chore.status === 2) { + onAction('resume') + } + } + + const handleMenuOpen = event => { + setAnchorEl(event.currentTarget) + } + + const handleMenuClose = () => { + setAnchorEl(null) + } + + const handleShowDetails = () => { + onShowDetails() + handleMenuClose() + } + + const handleResetTimer = () => { + onResetTimer() + handleMenuClose() + } + + const handleClearAllTime = () => { + onClearAllTime() + handleMenuClose() + } + + // Handle outside clicks to close menu + useEffect(() => { + const handleMenuOutsideClick = event => { + if ( + anchorEl && + !anchorEl.contains(event.target) && + menuRef.current && + !menuRef.current.contains(event.target) + ) { + handleMenuClose() + } + } + + document.addEventListener('mousedown', handleMenuOutsideClick) + return () => { + document.removeEventListener('mousedown', handleMenuOutsideClick) + } + }, [anchorEl]) + + // Only show the split button when there's an active timer (status 1 or 2) + if (chore.status === 0) { + return null + } + + return ( + + + {/* Main action button */} + + {chore.status === 1 ? : } + {chore.status === 1 ? 'Pause' : 'Resume'} + + + {/* Dropdown arrow button */} + + + + + + {/* Dropdown menu */} + + + + Timer Details + + + + Restart timer + + + + Clear & Reset + + + + ) +} + +export default TimerSplitButton diff --git a/src/views/Chores/ActivitesCard.jsx b/src/views/Chores/ActivitesCard.jsx index 229b7dc..3dfc107 100644 --- a/src/views/Chores/ActivitesCard.jsx +++ b/src/views/Chores/ActivitesCard.jsx @@ -5,6 +5,7 @@ import { Person, Redo, Refresh, + Timelapse, Toll, WatchLater, } from '@mui/icons-material' @@ -32,9 +33,9 @@ const ActivityItem = ({ activity, members }) => { member => member.userId === activity.completedBy, ) - const getTimeDisplay = performedAt => { + const getTimeDisplay = dateToDisplay => { const now = moment() - const completed = moment(performedAt) + const completed = moment(dateToDisplay) const diffInHours = now.diff(completed, 'hours') const diffInDays = now.diff(completed, 'days') @@ -50,6 +51,13 @@ const ActivityItem = ({ activity, members }) => { } const getStatusInfo = activity => { + if (activity.status === 0) { + return { + color: 'primary', + text: 'Started', + icon: , + } + } if (!activity.status === 1) { return { color: 'neutral', @@ -105,7 +113,11 @@ const ActivityItem = ({ activity, members }) => { {activity.choreName} - {getTimeDisplay(activity.performedAt)} + {getTimeDisplay( + activity.performedAt || + activity.updatedAt || + activity.createdAt, + )} @@ -127,18 +139,6 @@ const ActivityItem = ({ activity, members }) => { completedByMember?.name || 'Unknown'} - - - {/* Status, Points, and Notes */} - {/* Points chip */} {activity.points && activity.points > 0 && ( { )} + {/* Status, Points, and Notes */} + + {/* Notes */} {activity.notes && ( @@ -180,7 +191,9 @@ const groupActivitiesByDate = activities => { const groups = {} activities.forEach(activity => { - const date = moment(activity.performedAt).format('YYYY-MM-DD') + const date = moment( + activity.performedAt || activity.updatedAt || activity.createdAt, + ).format('YYYY-MM-DD') if (!groups[date]) { groups[date] = [] } @@ -270,7 +283,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => { const sortedHistory = enrichedHistory .sort( (a, b) => - moment(b.performedAt).valueOf() - moment(a.performedAt).valueOf(), + moment(b.performedAt || b.updatedAt).valueOf() - + moment(a.performedAt || a.updatedAt).valueOf(), ) .slice(0, 10) // Show only latest 10 activities diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 058e11c..41e98bc 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -1,7 +1,12 @@ import { CancelScheduleSend, Check, + Delete, + Edit, + Pause, + PlayArrow, Repeat, + Schedule, TimesOneMobiledata, Toll, Webhook, @@ -30,6 +35,8 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { DeleteChore, MarkChoreComplete, + PauseChore, + StartChore, UpdateChoreAssignee, UpdateDueDate, } from '../../utils/Fetcher' @@ -74,6 +81,25 @@ const ChoreCard = ({ const { showError } = useNotification() + // Swipe functionality state + const [swipeTranslateX, setSwipeTranslateX] = React.useState(0) + const [isDragging, setIsDragging] = React.useState(false) + const [isSwipeRevealed, setIsSwipeRevealed] = React.useState(false) + const [hoverTimer, setHoverTimer] = React.useState(null) + const [isTouchDevice, setIsTouchDevice] = React.useState(false) + const swipeThreshold = 80 // Minimum swipe distance to reveal actions + const maxSwipeDistance = 220 // Maximum swipe distance + const dragStartX = React.useRef(0) + const cardRef = React.useRef(null) + + // Detect if device supports touch + React.useEffect(() => { + const checkTouchDevice = () => { + setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0) + } + checkTouchDevice() + }, []) + const handleDelete = () => { setConfirmModelConfig({ isOpen: true, @@ -207,6 +233,207 @@ const ChoreCard = ({ } }) } + + // Swipe gesture handlers + const handleTouchStart = e => { + if (isMultiSelectMode || viewOnly) return + + dragStartX.current = e.touches[0].clientX + setIsDragging(true) + } + + const handleTouchMove = e => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + const currentX = e.touches[0].clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + // When actions are revealed, allow right swipe to hide + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + // When actions are hidden, allow left swipe to reveal + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleTouchEnd = () => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + setIsDragging(false) + + if (isSwipeRevealed) { + // When actions are revealed, check if user swiped right enough to hide + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + // Snap back to revealed position + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + // When actions are hidden, check if user swiped left enough to reveal + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const handleMouseDown = e => { + if (isMultiSelectMode || viewOnly) return + + dragStartX.current = e.clientX + setIsDragging(true) + } + + const handleMouseMove = e => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + const currentX = e.clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + // When actions are revealed, allow right swipe to hide + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + // When actions are hidden, allow left swipe to reveal + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleMouseUp = () => { + if (isMultiSelectMode || viewOnly || !isDragging) return + + setIsDragging(false) + + if (isSwipeRevealed) { + // When actions are revealed, check if user swiped right enough to hide + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + // Snap back to revealed position + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + // When actions are hidden, check if user swiped left enough to reveal + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const resetSwipe = () => { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + + // Hover functionality for desktop - only trigger from action menu + const handleMouseEnter = () => { + if (isMultiSelectMode || viewOnly || isSwipeRevealed || isTouchDevice) + return + const timer = setTimeout(() => { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + setHoverTimer(null) + }, 1500) // Match CompactChoreCard delay + setHoverTimer(timer) + } + + const handleMouseLeave = () => { + if (isTouchDevice) return + + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + + // Add a small delay before hiding to allow moving to action area + if (isSwipeRevealed) { + const hideTimer = setTimeout(() => { + resetSwipe() + }, 300) // Match CompactChoreCard delay + setHoverTimer(hideTimer) + } + } + + const handleActionAreaMouseEnter = () => { + if (isTouchDevice) return + + // Clear any pending timer when entering action area (both show and hide timers) + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + } + + const handleActionAreaMouseLeave = () => { + if (isTouchDevice) return + + // Hide immediately when leaving action area (like CompactChoreCard) + if (isSwipeRevealed) { + resetSwipe() + } + } + + // Clean up timer on unmount + React.useEffect(() => { + return () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + } + } + }, [hoverTimer]) + + // Handlers for start/pause/complete functionality + const handleChorePause = () => { + PauseChore(chore.id).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + status: data.res.status, + } + onChoreUpdate(newChore, 'paused') + }) + } + }) + } + + const handleChoreStart = () => { + StartChore(chore.id).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + status: data.res.status, + } + onChoreUpdate(newChore, 'started') + }) + } + }) + } + const getDueDateChipText = nextDueDate => { if (chore.nextDueDate === null) return 'No Due Date' // if due in next 48 hours, we should it in this format : Tomorrow 11:00 AM @@ -358,7 +585,7 @@ const ChoreCard = ({ sx={{ position: 'relative', top: 10, - zIndex: 1, + zIndex: 3, left: 10, }} color={getDueDateChipColor(chore.nextDueDate)} @@ -371,7 +598,7 @@ const ChoreCard = ({ sx={{ position: 'relative', top: 10, - zIndex: 1, + zIndex: 3, ml: 0.4, left: 10, }} @@ -388,333 +615,509 @@ const ChoreCard = ({ - - {/* Multi-select checkbox */} - {isMultiSelectMode && ( - e.stopPropagation()} - /> - )} - - { - if (isMultiSelectMode) { - onSelectionToggle() + {/* Action buttons underneath (revealed on swipe) */} + + { + e.stopPropagation() + resetSwipe() + + if (chore.status !== 0) { + handleTaskCompletion() } else { - navigate(`/chores/${chore.id}`) + handleChoreStart() } }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} > - {/* Box in top right with Chip showing next due date */} - - - {Array.from(chore.name)[0]} - - - {getName(chore.name)} - {userProfile && chore.assignedTo !== userProfile.id && ( - - - Assigned to - - - { - performers.find(p => p.userId === chore.assignedTo) - ?.displayName - } - - - )} - - {chore.priority > 0 && ( - p.value === chore.priority)?.icon - } - onClick={e => { - e.stopPropagation() - onChipClick({ priority: chore.priority }) - }} - > - P{chore.priority} - + {chore.status !== 0 ? ( + + ) : ( + + )} + + + { + e.stopPropagation() + resetSwipe() + setIsChangeDueDateModalOpen(true) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + navigate(`/chores/${chore.id}/edit`) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + handleDelete() + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + + + + {/* Multi-select checkbox */} + {isMultiSelectMode && ( + e.stopPropagation()} + /> + )} + + { + if (isMultiSelectMode) { + onSelectionToggle() + } else { + navigate(`/chores/${chore.id}`) + } + }} + > + {/* Box in top right with Chip showing next due date */} + + + {Array.from(chore.name)[0]} + + + + {getName(chore.name)} + + {userProfile && chore.assignedTo !== userProfile.id && ( + + p.userId === chore.assignedTo, + )?.image + } + /> + } + > + { + performers.find(p => p.userId === chore.assignedTo) + ?.displayName + } + + )} - {/* show points chip if there is points assigned */} - {chore.points > 0 && ( - } - > - {chore.points} - - )} - {chore.labelsV2?.map((l, index) => { - return ( -
+ {chore.priority > 0 && ( + p.value === chore.priority)?.icon + } onClick={e => { e.stopPropagation() - onChipClick({ label: l }) + onChipClick({ priority: chore.priority }) }} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { + > + P{chore.priority} + + )} + {/* show points chip if there is points assigned */} + {chore.points > 0 && ( + } + > + {chore.points} + + )} + {chore.labelsV2?.map((l, index) => { + return ( +
{ e.stopPropagation() onChipClick({ label: l }) - } - }} - style={{ display: 'inline-block', cursor: 'pointer' }} // Make the wrapper clickable - key={`chorecard-${chore.id}-label-${l.id}`} - > - { - // e.stopPropagation() - // onChipClick({ label: l }) - // }} - - // startDecorator={getIconForLabel(label)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + onChipClick({ label: l }) + } + }} + style={{ display: 'inline-block', cursor: 'pointer' }} // Make the wrapper clickable + key={`chorecard-${chore.id}-label-${l.id}`} > - {l?.name} - -
- ) - })} + { + // e.stopPropagation() + // onChipClick({ label: l }) + // }} + + // startDecorator={getIconForLabel(label)} + > + {l?.name} + +
+ ) + })} +
- - {/* + {/* {chore.nextDueDate === null ? '--' : 'Due ' + moment(chore.nextDueDate).fromNow()} */} -
- - - {/* */} - -
- - {isPendingCompletion && ( - - )} -
-
- setIsCompleteWithNoteModalOpen(true)} - onCompleteWithPastDate={() => - setIsCompleteWithPastDateModalOpen(true) - } - onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)} - onChangeDueDate={() => setIsChangeDueDateModalOpen(true)} - onWriteNFC={() => setIsNFCModalOpen(true)} - onDelete={handleDelete} - /> -
-
- - { - setIsChangeDueDateModalOpen(false) - }} - onSave={handleChangeDueDate} - /> - { - setIsCompleteWithPastDateModalOpen(false) - }} - onSave={handleCompleteWithPastDate} - /> - { - setIsChangeAssigneeModalOpen(false) - }} - onSave={selected => { - handleAssigneChange(selected.id) - }} - /> - {confirmModelConfig?.isOpen && ( - - )} - { - setIsCompleteWithNoteModalOpen(false) - }} - okText={'Complete'} - onSave={handleCompleteWithNote} - /> - { - setIsNFCModalOpen(false) - }, - }} - /> - - { - if (timeoutId) { - clearTimeout(timeoutId) - setIsPendingCompletion(false) - setTimeoutId(null) - setSecondsLeftToCancel(null) // Reset or adjust as needed - } + + } > - Cancel - - } - > - - Task will be marked as completed in {secondsLeftToCancel} seconds - - - + + {/* */} + { + e.stopPropagation() + switch (chore.status) { + case 0: // Not started + handleTaskCompletion() + break + case 1: // In progress + handleChorePause() + break + case 2: // Paused + handleChoreStart() + break + default: + break + } + }} + disabled={isPendingCompletion || notInCompletionWindow(chore)} + sx={{ + borderRadius: '50%', + minWidth: 50, + height: 50, + zIndex: 1, + transition: 'all 0.2s ease', + '&:hover': { + transform: 'scale(1.05)', + }, + '&:active': { + transform: 'scale(0.95)', + }, + '&:disabled': { + opacity: 0.5, + transform: 'none', + }, + }} + > +
+ {isPendingCompletion ? ( + + ) : chore.status === 0 ? ( + + ) : chore.status === 1 ? ( + + ) : ( + + )} + {isPendingCompletion && ( + + )} +
+
+ + setIsCompleteWithNoteModalOpen(true) + } + onCompleteWithPastDate={() => + setIsCompleteWithPastDateModalOpen(true) + } + onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)} + onChangeDueDate={() => setIsChangeDueDateModalOpen(true)} + onWriteNFC={() => setIsNFCModalOpen(true)} + onDelete={handleDelete} + onMouseEnter={handleMouseEnter} + onOpen={() => { + // Clear any pending hide timer when menu opens + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + }} + /> +
+ + + { + setIsChangeDueDateModalOpen(false) + }} + onSave={handleChangeDueDate} + /> + { + setIsCompleteWithPastDateModalOpen(false) + }} + onSave={handleCompleteWithPastDate} + /> + { + setIsChangeAssigneeModalOpen(false) + }} + onSave={selected => { + handleAssigneChange(selected.id) + }} + /> + {confirmModelConfig?.isOpen && ( + + )} + { + setIsCompleteWithNoteModalOpen(false) + }} + okText={'Complete'} + onSave={handleCompleteWithNote} + /> + { + setIsNFCModalOpen(false) + }, + }} + /> + + + { + if (timeoutId) { + clearTimeout(timeoutId) + setIsPendingCompletion(false) + setTimeoutId(null) + setSecondsLeftToCancel(null) // Reset or adjust as needed + } + }} + size='md' + variant='outlined' + color='primary' + startDecorator={} + > + Cancel + + } + > + + Task will be marked as completed in {secondsLeftToCancel} seconds + + ) } diff --git a/src/views/Chores/LocalNotificationScheduler.js b/src/views/Chores/LocalNotificationScheduler.js index 3cb2c77..735f0c7 100644 --- a/src/views/Chores/LocalNotificationScheduler.js +++ b/src/views/Chores/LocalNotificationScheduler.js @@ -1,128 +1,222 @@ -import { Capacitor } from '@capacitor/core'; -import { LocalNotifications } from '@capacitor/local-notifications'; -import { Preferences } from '@capacitor/preferences'; +import { Capacitor } from '@capacitor/core' +import { LocalNotifications } from '@capacitor/local-notifications' +import { Preferences } from '@capacitor/preferences' +import murmurhash from 'murmurhash' const getNotificationPreferences = async () => { - const ret = await Preferences.get({ key: 'notificationPreferences' }); - return JSON.parse(ret.value); - }; - -const canScheduleNotification = () => { - if (Capacitor.isNativePlatform() === false) { - return false; - } - const notificationPreferences = getNotificationPreferences(); - if (notificationPreferences["granted"] === false) { - return false; - } - return true; + const ret = await Preferences.get({ key: 'notificationPreferences' }) + return JSON.parse(ret.value) } +const canScheduleNotification = async () => { + if (Capacitor.isNativePlatform() === false) { + return false + } + const notificationPreferences = await getNotificationPreferences() + console.log('Notification preferences:', notificationPreferences) -const scheduleChoreNotification = async (chores, userProfile,allPerformers) => { - // for each chore will create local notification: - const notifications = []; + if (notificationPreferences['granted'] === false) { + return false + } + return true +} + +const getIdFromTemplate = (choreId, template) => { + // convert to base 32 int for notification id using murmurhash : + return murmurhash.v3(`${choreId}-${template.value}-${template.unit}`) +} + +const getTimeFromTemplate = (template, relativeTime) => { + let time = relativeTime + switch (template.unit) { + case 'm': + time = new Date(relativeTime.getTime() + template.value * 60 * 1000) + break + case 'h': + time = new Date(relativeTime.getTime() + template.value * 60 * 60 * 1000) + break + case 'd': + time = new Date( + relativeTime.getTime() + template.value * 24 * 60 * 60 * 1000, + ) + break + default: + time = relativeTime + } + return time +} +const scheduleNotificationFromTemplate = ( + chore, + userProfile, + allPerformers, + notifications, +) => { + for (const template of chore.notificationMetadata?.templates || []) { + // convert the template to time: + console.log( + 'Scheduling notification for chore:', + chore.id, + 'with template:', + template, + ) + const dueDate = new Date(chore.nextDueDate) const now = new Date() - - const devicePreferences = await getNotificationPreferences(); - - for (let i = 0; i < chores.length; i++) { + const time = getTimeFromTemplate(template, dueDate) + const notificationId = getIdFromTemplate(chore.id, template) + const { title, body } = getNotificationText(chore.name, template) + if (time > now) { + notifications.push({ + title, + body: `${body} at ${time.toLocaleTimeString()}`, + id: notificationId, + allowWhileIdle: true, + schedule: { + at: time, + }, + extra: { + choreId: chore.id, + }, + }) + } + } +} - const chore = chores[i]; - const chorePreferences = JSON.parse(chore.notificationMetadata) - if ( chore.notification ===false || chore.nextDueDate === null) { - continue; +const getNotificationText = (choreName, template = {}) => { + // Determine notification type based on template value + const getNotificationType = () => { + if (!template || template.value === undefined) { + return 'due' + } + + if (template.value < 0) { + return 'reminder' // Before due date + } else if (template.value === 0) { + return 'due' // Due now + } else { + return 'overdue' // After due date + } + } + + const notificationType = getNotificationType() + + // Truncate chore name if too long for better readability + const maxChoreNameLength = 25 + const truncatedName = + choreName.length > maxChoreNameLength + ? `${choreName.substring(0, maxChoreNameLength)}...` + : choreName + + // Generate time-based descriptive text + const getTimeDescription = () => { + if (!template || !template.value || !template.unit) { + return 'soon' + } + + const { value, unit } = template + const absValue = Math.abs(value) + + switch (unit) { + case 'm': + if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago' + if (absValue < 60) + return value < 0 + ? `in ${absValue} minutes` + : `${absValue} minutes ago` + break + case 'h': + if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago' + if (absValue < 24) + return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago` + break + case 'd': + if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday' + if (absValue === 7) return value < 0 ? 'next week' : 'last week' + if (absValue < 7) + return value < 0 ? `in ${absValue} days` : `${absValue} days ago` + if (absValue < 30) { + const weeks = Math.round(absValue / 7) + return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago` } - scheduleDueNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) - schedulePreDueNotification(chore, userProfile, allPerformers,chorePreferences, devicePreferences,notifications) - scheduleNaggingNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) - - + break + default: + return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago` } - LocalNotifications.schedule({ + + return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago` + } + + const messages = { + reminder: { + title: `📋 ${truncatedName}`, + body: `Reminder: Due ${getTimeDescription()}`, + }, + due: { + title: `🔔 ${truncatedName}`, + body: 'Due now - Time to get started!', + }, + overdue: { + title: `❗ ${truncatedName}`, + body: `Overdue ${getTimeDescription()} - Complete when you can`, + }, + } + + // Fallback to due if type not found + const messageTemplate = messages[notificationType] || messages.due + + return { + title: messageTemplate.title, + body: messageTemplate.body, + } +} +const cancelPendingNotifications = async () => { + try { + const pending = await LocalNotifications.getPending() + if (pending.notifications.length > 0) { + await LocalNotifications.cancel({ notifications: pending.notifications }) + console.log('Cancelled pending notifications:', pending.notifications) + } else { + console.log('No pending notifications to cancel.') + } + } catch (error) { + console.error('Error cancelling pending notifications:', error) + } +} +const scheduleChoreNotification = async ( + chores, + userProfile, + allPerformers, +) => { + await cancelPendingNotifications() + const notifications = [] + + const devicePreferences = await getNotificationPreferences() + + for (let i = 0; i < chores.length; i++) { + const chore = chores[i] + try { + if (chore.notification === false || chore.nextDueDate === null) { + continue + } + scheduleNotificationFromTemplate( + chore, + userProfile, + allPerformers, notifications, - }); + ) + } catch (error) { + console.error( + 'Error parsing notification metadata for chore:', + chore.id, + error, + ) + continue + } + } + + LocalNotifications.schedule({ + notifications, + }) + console.log('Scheduled notifications:', notifications) } -const scheduleDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => { - - if (devicePreferences['dueNotification'] !== true || chorePreferences['dueDate'] !== true){ - return - } - - const nextDueDate = new Date(chore.nextDueDate) - const diff = nextDueDate - now - - if (diff < 0) { - return - } - - const notification = { - title: `${chore.name} is due! 🕒`, - body: userProfile.id === chore.assignedTo ? `It's assigned to you!` : `It is ${allPerformers[chore.assignedTo].name}'s turn`, - id: chore.id, - allowWhileIdle: true, - schedule: { - at: new Date(chore.nextDueDate), - }, - extra: { - choreId: chore.id, - }, - }; - notifications.push(notification); -} - -const schedulePreDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => { - if (devicePreferences['preDueNotification'] !== true || chorePreferences['preDue'] !== true){ - return - } - - const nextDueDate = new Date(chore.nextDueDate) - const diff = nextDueDate - now - - if (diff < 0 || userProfile.id !== chore.assignedTo) { - return - } - - const notification = { - title: `${chore.name} is due soon! 🕒`, - body: `is due at ${nextDueDate.toLocaleTimeString()}`, - id: chore.id, - allowWhileIdle: true, - schedule: { - // 1 hour before - at: new Date(nextDueDate - 60 * 60 * 1000), - }, - extra: { - choreId: chore.id, - }, - }; - notifications.push(notification); -} -const scheduleNaggingNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => { - if (devicePreferences['naggingNotification'] === false || chorePreferences.nagging !== true){ - return - } - const nextDueDate = new Date(chore.nextDueDate) - const diff = nextDueDate - now - - if (diff > 0 || userProfile.id !== chore.assignedTo) { - return - } - - const notification = { - title: `${chore.name} is overdue! 🕒`, - body: `❗ It was due at ${nextDueDate.toLocaleTimeString()}`, - id: chore.id, - allowWhileIdle: true, - schedule: { - at: new Date(chore.nextDueDate), - }, - extra: { - choreId: chore.id, - }, - }; - notifications.push(notification); -} - -export{ scheduleChoreNotification, canScheduleNotification } \ No newline at end of file +export { canScheduleNotification, scheduleChoreNotification } diff --git a/src/views/Chores/MultiSelectHelp.jsx b/src/views/Chores/MultiSelectHelp.jsx index 11df46e..b902e84 100644 --- a/src/views/Chores/MultiSelectHelp.jsx +++ b/src/views/Chores/MultiSelectHelp.jsx @@ -1,15 +1,7 @@ import { Close, HelpOutline, Keyboard } from '@mui/icons-material' -import { - Box, - Button, - Card, - Divider, - IconButton, - Modal, - ModalDialog, - Typography, -} from '@mui/joy' +import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy' import { useState } from 'react' +import FadeModal from '../../components/common/FadeModal' const MultiSelectHelp = ({ isVisible = true }) => { const [isHelpOpen, setIsHelpOpen] = useState(false) @@ -40,112 +32,90 @@ const MultiSelectHelp = ({ isVisible = true }) => { {/* Help Modal */} - setIsHelpOpen(false)}> - setIsHelpOpen(false)}> + - + + Multi-select Mode + + setIsHelpOpen(false)} > - - - Multi-select Mode + + + + + Use these keyboard shortcuts to work more efficiently with multiple + tasks: + + + {/* Selection shortcuts */} + + + Selection + + + + - setIsHelpOpen(false)} - > - - - + - - Use these keyboard shortcuts to work more efficiently with multiple - tasks: - + {/* Action shortcuts */} + + + Actions + + + + + + - - {/* Selection shortcuts */} - - - Selection - - - - - - - - {/* Action shortcuts */} - - - Actions - - - - - - - - {/* Interface shortcuts */} - - - Interface - - - - - - - - - - - - - - + {/* Interface shortcuts */} + + + Interface + + + + + + + + + + + ) } @@ -159,9 +129,9 @@ const ShortcutItem = ({ keys, description }) => ( gap: 2, }} > - - {description} - + + {description} + {keys.map((key, index) => ( { const { data: userProfile, isLoading: isUserProfileLoading } = useUserProfile() - const { showSuccess, showError } = useNotification() + const { showSuccess, showError, showWarning } = useNotification() const { impersonatedUser } = useImpersonateUser() const [chores, setChores] = useState([]) const [archivedChores, setArchivedChores] = useState(null) @@ -130,17 +133,14 @@ const MyChores = () => { }, {}), ) } - console.log( - 'Checking if can schedule notification', - canScheduleNotification(), - ) if (await canScheduleNotification()) { - // scheduleChoreNotification( - // choresData.res, - // userProfile, - // membersData.res, - // ) + console.log('Scheduling chore notifications...') + scheduleChoreNotification( + choresData.res, + userProfile, + membersData.res, + ) } } })() @@ -172,6 +172,8 @@ const MyChores = () => { // Keyboard shortcuts for multi-select and other actions useEffect(() => { const handleKeyDown = event => { + // if the modal open we don't want anything here to trigger + if (addTaskModalOpen) return // if Ctrl/Cmd + / then show keyboard shortcuts modal if (event.ctrlKey || event.metaKey) { setShowKeyboardShortcuts(true) @@ -183,6 +185,19 @@ const MyChores = () => { setAddTaskModalOpen(true) return } + console.log('addTaskModalOpen', addTaskModalOpen) + + if (addTaskModalOpen) { + // we want to ignore anything in here until the modal close + return + } + + // Ctrl/Cmd + J to navigate to create chore page + if ((event.ctrlKey || event.metaKey) && event.key === 'j') { + event.preventDefault() + Navigate(`/chores/create`) + return + } // Ctrl/Cmd + F to focus search input: else if ((event.ctrlKey || event.metaKey) && event.key === 'f') { @@ -315,6 +330,102 @@ const MyChores = () => { handleBulkComplete() return } + + // "/" key for bulk skip + if (event.key === '/' && selectedChores.size > 0) { + event.preventDefault() + handleBulkSkip() + return + } + + // "x" key for bulk archive (without shift or modifiers) + if ( + event.key === 'x' && + !event.shiftKey && + !event.ctrlKey && + !event.metaKey && + selectedChores.size > 0 && + !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) + ) { + event.preventDefault() + handleBulkArchive() + return + } + + // "X" key (Shift + x) for bulk delete - without Ctrl/Cmd modifiers + if ( + event.shiftKey && + (event.key === 'X' || event.key === 'x') && + !event.ctrlKey && + !event.metaKey && + selectedChores.size > 0 && + !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) + ) { + event.preventDefault() + handleBulkDelete() + return + } + } + + // Global shortcuts (work outside multi-select mode) + // "o" key to show archived chores (when not in multi-select and archived chores not shown) + if ( + event.key === 'o' && + !isMultiSelectMode && + archivedChores === null && + !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) + ) { + event.preventDefault() + GetArchivedChores() + .then(response => response.json()) + .then(data => { + setArchivedChores(data.res) + }) + return + } + + // Ctrl/Cmd + X for bulk archive (works in both multi-select and normal mode) + if ( + (event.ctrlKey || event.metaKey) && + event.key === 'x' && + !event.shiftKey && + !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) + ) { + event.preventDefault() + if (isMultiSelectMode && selectedChores.size > 0) { + handleBulkArchive() + } else if (!isMultiSelectMode) { + // Enable multi-select mode first, then show a message + setIsMultiSelectMode(true) + showSuccess({ + title: '📦 Archive Mode', + message: + 'Multi-select enabled. Select tasks to archive, or use Cmd+X again.', + }) + } + return + } + + // Ctrl/Cmd + Shift + X for bulk delete (works in both multi-select and normal mode) + if ( + (event.ctrlKey || event.metaKey) && + event.shiftKey && + event.key === 'X' && + !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) + ) { + event.preventDefault() + if (isMultiSelectMode && selectedChores.size > 0) { + handleBulkDelete() + } else if (!isMultiSelectMode) { + // Enable multi-select mode first, then show a message + setIsMultiSelectMode(true) + showSuccess({ + title: '🗑️ Delete Mode', + message: + 'Multi-select enabled. Select tasks to delete, or use Cmd+Shift+X again.', + }) + } + return } } const handleKeyUp = event => { @@ -329,7 +440,7 @@ const MyChores = () => { document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keyup', handleKeyUp) } - }, [isMultiSelectMode, selectedChores.size]) + }, [isMultiSelectMode, selectedChores.size, addTaskModalOpen]) const setSelectedChoreSectionWithCache = value => { setSelectedChoreSection(value) localStorage.setItem('selectedChoreSection', value) @@ -496,6 +607,19 @@ const MyChores = () => { 'The task has been archived and hidden from the active list.', }) break + case 'started': + showSuccess({ + title: 'Task Started', + message: 'The task has been marked as started.', + }) + break + case 'paused': + showWarning({ + title: 'Task Paused', + message: 'The task has been paused.', + }) + break + case 'deleted': default: showSuccess({ title: 'Task Updated', @@ -775,9 +899,18 @@ const MyChores = () => { }) const deletedIds = new Set(deletedTasks.map(c => c.id)) - setChores(chores.filter(c => !deletedIds.has(c.id))) - setFilteredChores( - filteredChores.filter(c => !deletedIds.has(c.id)), + const newChores = chores.filter(c => !deletedIds.has(c.id)) + const newFilteredChores = filteredChores.filter( + c => !deletedIds.has(c.id), + ) + setChores(newChores) + setFilteredChores(newFilteredChores) + setChoreSections( + ChoresGrouper( + selectedChoreSection, + newChores, + ChoreFilters(userProfile)[selectedChoreFilter], + ), ) } @@ -1000,25 +1133,36 @@ const MyChores = () => { {/* Multi-select Toggle Button */} - - {isMultiSelectMode ? : } - - + + + {isMultiSelectMode ? : } + + + {/* Search Filter with animation */} @@ -1239,15 +1383,22 @@ const MyChores = () => { sx={{ minWidth: 'auto', '--Button-paddingInline': '0.75rem', + position: 'relative', }} - endDecorator={ - 0} - /> - } + title='Select all visible tasks (Ctrl+A)' > All + {showKeyboardShortcuts && ( + + )} @@ -1302,15 +1460,22 @@ const MyChores = () => { disabled={selectedChores.size === 0} sx={{ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, + position: 'relative', }} - endDecorator={ - 0} - /> - } + title='Complete selected tasks (Enter)' > Complete + {showKeyboardShortcuts && selectedChores.size > 0 && ( + + )} {/* @@ -1551,7 +1737,7 @@ const MyChores = () => { startDecorator={} endDecorator={ } @@ -1604,12 +1790,24 @@ const MyChores = () => { width: 50, height: 50, zIndex: 101, + position: 'relative', }} onClick={() => { Navigate(`/chores/create`) }} + title='Create new chore (Cmd+C)' > + { const [open, setOpen] = useState(false) - if (!Capacitor.isNativePlatform()) { - return null - } + // Define the function outside of useEffect const getNotificationPreferences = async () => { const ret = await Preferences.get({ key: 'notificationPreferences' }) - return JSON.parse(ret.value) + return JSON.parse(ret.value) || {} } useEffect(() => { - getNotificationPreferences().then(data => { - // if optOut is true then don't show the snackbar - if (data?.optOut === true || data?.granted === true) { - return - } - setOpen(true) - }) + // Only run the effect on native platforms + if (Capacitor.isNativePlatform()) { + getNotificationPreferences().then(data => { + // if optOut is true then don't show the snackbar + if (data?.optOut === true || data?.granted === true) { + return + } + setOpen(true) + }) + } }, []) + // Return early if not on a native platform + if (!Capacitor.isNativePlatform()) { + return null + } + return ( {[ + { name: 'Smart', value: 'default' }, { name: 'Due Date', value: 'due_date' }, { name: 'Priority', value: 'priority' }, { name: 'Labels', value: 'labels' }, diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 4f7595d..195b744 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -104,12 +104,16 @@ const ChoreHistory = () => { { icon: , text: 'Usually Within', - subtext: moment.duration(averageDelayMoment).humanize(), + subtext: moment.duration(averageDelayMoment).isValid() + ? moment.duration(averageDelayMoment).humanize() + : '--', }, { icon: , text: 'Maximum Delay', - subtext: moment.duration(maxDelayMoment).humanize(), + subtext: moment.duration(maxDelayMoment).isValid() + ? moment.duration(maxDelayMoment).humanize() + : '--', }, { icon: , @@ -215,7 +219,7 @@ const ChoreHistory = () => { History: - + {/* Chore History List (Updated Style) */} diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index 53bb7b9..cd2e1fe 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -1,11 +1,13 @@ import { AccessTime, - Assignment, - CalendarViewDay, + CalendarMonth, Check, + CheckCircle, EventNote, Person, + Redo, Timelapse, + Toll, } from '@mui/icons-material' import { Avatar, @@ -19,28 +21,25 @@ import { } from '@mui/joy' import moment from 'moment' -/** - * Enhanced completion status chip with better logic and visual design - */ const getCompletedChip = historyEntry => { if (historyEntry.status === 0) { return null } if (!historyEntry.dueDate) { - return ( - } - > - No Due Date - - ) + return null + // } + // > + // No Due Date + // } const performedAt = moment(historyEntry.performedAt) const dueDate = moment(historyEntry.dueDate) + // TODO: make this a config at some point const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds if (Math.abs(performedAt - dueDate) <= gracePeriod) { @@ -74,6 +73,16 @@ const getCompletedChip = historyEntry => { } } +const formatTime = seconds => { + if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) { + return null + } + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` +} + /** * Compact HistoryCard component with improved UX and 2-row height design */ @@ -111,7 +120,7 @@ const HistoryCard = ({ const statusMap = { 0: { icon: , color: 'primary' }, // Started 1: { icon: , color: 'success' }, // Completed - 2: { icon: , color: 'danger' }, // Skipped + 2: { icon: , color: 'warning' }, // Skipped } const config = statusMap[historyEntry.status] || statusMap[1] @@ -119,7 +128,7 @@ const HistoryCard = ({ - + }> {moment( historyEntry.performedAt || historyEntry.updatedAt, ).format('MMM DD, h:mm A')} - + {getCompletedChip(historyEntry)} @@ -202,12 +208,9 @@ const HistoryCard = ({ }} > {historyEntry.dueDate && ( - - Due: {moment(historyEntry.dueDate).format('MMM DD')} - + }> + {moment(historyEntry.dueDate).format('MMM DD h:mm A')} + )} @@ -240,7 +243,7 @@ const HistoryCard = ({ size='sm' variant='soft' color='neutral' - startDecorator={} + startDecorator={} > {assignedTo.displayName} @@ -258,6 +261,28 @@ const HistoryCard = ({ Note )} + {/* add a duration chip if we have duration */} + {historyEntry?.duration > 0 && ( + } + > + {formatTime(historyEntry.duration)} + + )} + {historyEntry?.points > 0 && ( + } + > + {historyEntry.points} pt + {historyEntry.points > 1 ? 's' : ''} + + )} diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 10d8278..52d1441 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -13,15 +13,17 @@ import { useEffect, useRef, useState } from 'react' import LabelModal from '../Modals/Inputs/LabelModal' // import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Add } from '@mui/icons-material' +import { Add, ColorLens } from '@mui/icons-material' import { useQueryClient } from '@tanstack/react-query' -import { getTextColorFromBackgroundColor } from '../../utils/Colors' -import LABEL_COLORS from '../../utils/Colors' +import { useUserProfile } from '../../queries/UserQueries' +import LABEL_COLORS, { + getTextColorFromBackgroundColor, +} from '../../utils/Colors' import { DeleteLabel } from '../../utils/Fetcher' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import { useLabels } from './LabelQueries' -const LabelCard = ({ label, onEditClick, onDeleteClick }) => { +const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => { // Helper function to get color name from hex value const getColorName = hexValue => { const colorObj = LABEL_COLORS.find( @@ -30,6 +32,9 @@ const LabelCard = ({ label, onEditClick, onDeleteClick }) => { return colorObj ? colorObj.name : hexValue } + // Check if current user owns this label + const isOwnedByCurrentUser = label.created_by === currentUserId + // Swipe functionality state const [swipeTranslateX, setSwipeTranslateX] = useState(0) const [isDragging, setIsDragging] = useState(false) @@ -138,14 +143,14 @@ const LabelCard = ({ label, onEditClick, onDeleteClick }) => { setIsSwipeRevealed(false) } - // Hover functionality for desktop + // Hover functionality for desktop - only trigger from drag area const handleMouseEnter = () => { if (isSwipeRevealed) return const timer = setTimeout(() => { setSwipeTranslateX(-maxSwipeDistance) setIsSwipeRevealed(true) setHoverTimer(null) - }, 1500) + }, 800) // Shorter delay for drag area setHoverTimer(timer) } @@ -154,18 +159,32 @@ const LabelCard = ({ label, onEditClick, onDeleteClick }) => { clearTimeout(hoverTimer) setHoverTimer(null) } - if (isSwipeRevealed) { - resetSwipe() + // Only add hide timer if we're leaving the drag area and actions are NOT revealed + // If actions are revealed, let the action area handle the hiding + if (!isSwipeRevealed) { + // Actions are not revealed, so we can safely hide after delay + const hideTimer = setTimeout(() => { + resetSwipe() + }, 300) + setHoverTimer(hideTimer) } } const handleActionAreaMouseEnter = () => { + // Clear any pending timer when entering action area if (hoverTimer) { clearTimeout(hoverTimer) setHoverTimer(null) } } + const handleActionAreaMouseLeave = () => { + // Hide immediately when leaving action area + if (isSwipeRevealed) { + resetSwipe() + } + } + // Clean up timer on unmount useEffect(() => { return () => { @@ -187,7 +206,13 @@ const LabelCard = ({ label, onEditClick, onDeleteClick }) => { borderBottom: 'none', }, }} - onMouseLeave={handleMouseLeave} + onMouseLeave={() => { + // Only clear timers, don't auto-hide + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + }} > {/* Action buttons underneath (revealed on swipe) */} { zIndex: 0, }} onMouseEnter={handleActionAreaMouseEnter} + onMouseLeave={handleActionAreaMouseLeave} > { onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} - onMouseEnter={handleMouseEnter} > + {/* Right drag area - only triggers reveal on hover */} + + {/* Drag indicator dots */} + + {[...Array(3)].map((_, i) => ( + + ))} + + {/* Color Avatar */} { height: 32, bgcolor: label.color, border: '2px solid', - borderColor: 'background.surface', - boxShadow: 'sm', + borderColor: isOwnedByCurrentUser + ? 'background.surface' + : 'warning.300', + boxShadow: isOwnedByCurrentUser + ? 'sm' + : '0 0 0 1px var(--joy-palette-warning-300)', }} > { {/* Color Info */} - - {getColorName(label.color)} - + {label.color && ( + } + sx={{ + fontSize: 10, + height: 18, + px: 0.75, + bgcolor: `${label.color}20`, + color: label.color, + border: `1px solid ${label.color}30`, + }} + > + {getColorName(label.color)} + + )} + {!isOwnedByCurrentUser && ( + + Shared + + )} @@ -372,6 +466,7 @@ const LabelCard = ({ label, onEditClick, onDeleteClick }) => { const LabelView = () => { const { data: labels, isLabelsLoading, isError } = useLabels() + const { data: userProfile } = useUserProfile() const [userLabels, setUserLabels] = useState([]) const [modalOpen, setModalOpen] = useState(false) @@ -459,10 +554,10 @@ const LabelView = () => { @@ -487,6 +582,7 @@ const LabelView = () => { label={label} onEditClick={handleEditLabel} onDeleteClick={handleDeleteClicked} + currentUserId={userProfile?.id} /> ))} diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx index f81a303..2e0b318 100644 --- a/src/views/Modals/Inputs/ConfirmationModal.jsx +++ b/src/views/Modals/Inputs/ConfirmationModal.jsx @@ -1,10 +1,73 @@ import { Box, Button, Typography } from '@mui/joy' +import { useCallback, useEffect, useState } from 'react' import FadeModal from '../../../components/common/FadeModal' +import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' function ConfirmationModal({ config }) { - const handleAction = isConfirmed => { - config.onClose(isConfirmed) - } + const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) + + const handleAction = useCallback( + isConfirmed => { + config.onClose(isConfirmed) + }, + [config], + ) + + // Keyboard shortcuts for confirmation modal + useEffect(() => { + const handleKeyDown = event => { + if (!config?.isOpen) return + + // Show keyboard shortcuts when Ctrl/Cmd is pressed + if (event.ctrlKey || event.metaKey) { + setShowKeyboardShortcuts(true) + } + + // Ctrl/Cmd + Y for confirm + if ((event.ctrlKey || event.metaKey) && event.key === 'y') { + event.preventDefault() + handleAction(true) + return + } + + // Ctrl/Cmd + X for cancel + if ((event.ctrlKey || event.metaKey) && event.key === 'x') { + event.preventDefault() + handleAction(false) + return + } + + // Escape key for cancel + if (event.key === 'Escape') { + event.preventDefault() + handleAction(false) + return + } + + // Enter key for confirm + if (event.key === 'Enter') { + event.preventDefault() + handleAction(true) + return + } + } + + const handleKeyUp = event => { + if (!event.ctrlKey && !event.metaKey) { + setShowKeyboardShortcuts(false) + } + } + + if (config?.isOpen) { + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keyup', handleKeyUp) + } + + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keyup', handleKeyUp) + } + }, [config?.isOpen, handleAction]) return ( - + + diff --git a/src/views/Modals/Inputs/DateModal.jsx b/src/views/Modals/Inputs/DateModal.jsx index 34319c3..27dbf6e 100644 --- a/src/views/Modals/Inputs/DateModal.jsx +++ b/src/views/Modals/Inputs/DateModal.jsx @@ -1,13 +1,6 @@ -import React, { useState } from 'react' -import { - Modal, - Button, - Input, - ModalDialog, - ModalClose, - Box, - Typography, -} from '@mui/joy' +import { Box, Button, Input, Typography } from '@mui/joy' +import { useState } from 'react' +import FadeModal from '../../../components/common/FadeModal' function DateModal({ isOpen, onClose, onSave, current, title }) { const [date, setDate] = useState( @@ -20,26 +13,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) { } return ( - - - {/* */} - {title} - setDate(e.target.value)} - /> - - - - - - + + {title} + setDate(e.target.value)} + /> + + + + + ) } export default DateModal diff --git a/src/views/Modals/Inputs/EditThingState.jsx b/src/views/Modals/Inputs/EditThingState.jsx index 26d333e..d520718 100644 --- a/src/views/Modals/Inputs/EditThingState.jsx +++ b/src/views/Modals/Inputs/EditThingState.jsx @@ -4,11 +4,10 @@ import { FormControl, FormHelperText, Input, - Modal, - ModalDialog, Typography, } from '@mui/joy' import { useState } from 'react' +import FadeModal from '../../../components/common/FadeModal' function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) { const [state, setState] = useState(currentThing?.state || '') @@ -39,31 +38,29 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) { } return ( - - - Update state + + Update state - - Value - setState(e.target.value)} - sx={{ minWidth: 300 }} - /> - {errors.state} - + + Value + setState(e.target.value)} + sx={{ minWidth: 300 }} + /> + {errors.state} + - - - - - - + + + + + ) } export default EditThingStateModal diff --git a/src/views/Modals/Inputs/TimerEditModal.jsx b/src/views/Modals/Inputs/TimerEditModal.jsx index eb395ad..40b85ea 100644 --- a/src/views/Modals/Inputs/TimerEditModal.jsx +++ b/src/views/Modals/Inputs/TimerEditModal.jsx @@ -345,104 +345,220 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => { }} > {/* Active Time */} - - - {formatDuration(calculateCurrentActiveDuration())} - - - Active Work - - + + + Active Work + + + + + {formatDuration(calculateCurrentActiveDuration())} + + + {/* Idle Time */} - - - {formatDuration(calculateIdleTime())} - - - Break Time - - + + + Break Time + + + + + {formatDuration(calculateIdleTime())} + + + {/* Total Sessions */} - - - {timerData.pauseLog?.length || 0} - - - Work Sessions - - + + + Work Sessions + + + + + {timerData.pauseLog?.length || 0} + + + {/* Total Session Time */} - - - {formatTime(calculateTotalDuration())} - - - Total Time - - + + + Total Time + + + + + {formatTime(calculateTotalDuration())} + + + {/* Progress Bar */} diff --git a/src/views/Modals/RedeemPointsModal.jsx b/src/views/Modals/RedeemPointsModal.jsx index 59527ac..dd92d6a 100644 --- a/src/views/Modals/RedeemPointsModal.jsx +++ b/src/views/Modals/RedeemPointsModal.jsx @@ -1,80 +1,249 @@ -import { Box, Button, FormLabel, IconButton, Input, Typography } from '@mui/joy' +import { CreditCard, Person, Toll } from '@mui/icons-material' +import { + Avatar, + Box, + Button, + Card, + Chip, + Divider, + FormControl, + FormLabel, + IconButton, + Input, + Stack, + Typography, +} from '@mui/joy' import { useEffect, useState } from 'react' import FadeModal from '../../components/common/FadeModal' +import { resolvePhotoURL } from '../../utils/Helpers.jsx' function RedeemPointsModal({ config }) { + const [points, setPoints] = useState(0) + const predefinedPoints = [1, 5, 10, 25, 50] + useEffect(() => { setPoints(0) }, [config]) - const [points, setPoints] = useState(0) + const handlePointsChange = value => { + const numValue = Number(value) + if (numValue > config.available) { + setPoints(config.available) + return + } + if (numValue < 0) { + setPoints(0) + return + } + setPoints(numValue) + } - const predefinedPoints = [1, 5, 10, 25] + const addPredefinedPoints = point => { + const newPoints = points + point + if (newPoints > config.available) { + setPoints(config.available) + return + } + setPoints(newPoints) + } + + const canRedeem = points > 0 && points <= config.available return ( - - - Redeem Points - - - Points to Redeem ({config.available ? config.available : 0} points - available) - - { - if (e.target.value > config.available) { - setPoints(config.available) - return - } - setPoints(e.target.value) - }} - /> - Or select from predefined points: - - {predefinedPoints.map(point => ( - + {/* Header Section */} + + + + + Redeem Points + + + + + + {/* User Info Card */} + + + + + + + + {config?.user?.displayName || 'User'} + + } + sx={{ mt: 0.5 }} + > + {config?.available || 0} points available + + + + + + {/* Points Input Section */} + + + Points to Redeem + + config.available} - sx={{ borderRadius: '50%' }} - key={point} - onClick={() => { - const newPoints = points + point - if (newPoints > config.available) { - setPoints(config.available) - return - } - setPoints(newPoints) + startDecorator={} + slotProps={{ + input: { + min: 0, + max: config?.available || 0, + placeholder: 'Enter points...', + }, + }} + onChange={e => handlePointsChange(e.target.value)} + sx={{ + '--Input-decoratorChildHeight': '45px', + fontSize: 'lg', + fontWeight: 500, + '&:focus-within': { + borderColor: 'warning.500', + boxShadow: '0 0 0 2px rgba(255, 193, 7, 0.2)', + }, + }} + /> + {points > config?.available && ( + + Cannot exceed available points + + )} + + + {/* Quick Selection Buttons */} + + + Quick Add: + + + {predefinedPoints.map(point => ( + config?.available} + onClick={() => addPredefinedPoints(point)} + sx={{ + borderRadius: '50%', + minWidth: 45, + minHeight: 45, + fontWeight: 600, + fontSize: 'sm', + '&:hover:not(:disabled)': { + transform: 'scale(1.05)', + boxShadow: 'sm', + }, + '&:disabled': { + opacity: 0.3, + }, + transition: 'all 0.2s ease', + }} + > + +{point} + + ))} + + + + {/* Summary Section */} + {points > 0 && ( + - {point} - - ))} - + + You are about to redeem + + + {points} points + + + Remaining: {(config?.available || 0) - points} points + + + )} - {/* 3 button save , cancel and delete */} - - - - + + + {/* Action Buttons */} + + + + + ) } + export default RedeemPointsModal diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx index 01d95df..463f957 100644 --- a/src/views/Settings/NotificationSetting.jsx +++ b/src/views/Settings/NotificationSetting.jsx @@ -199,7 +199,7 @@ const NotificationSetting = () => { set: setPreDueNotification, label: 'Notification a few hours before the task is due', property: 'preDueNotification', - disabled: true, + disabled: false, }, { title: 'Overdue Notification', @@ -207,7 +207,7 @@ const NotificationSetting = () => { set: setNaggingNotification, label: 'Notification when the task is overdue', property: 'naggingNotification', - disabled: true, + disabled: false, }, ].map(item => ( { const { data: userProfile } = useUserProfile() @@ -163,6 +164,9 @@ const Settings = () => { ) } + if (!userProfile) { + return + } return ( diff --git a/src/views/TestView/TimerCard.jsx b/src/views/TestView/TimerCard.jsx new file mode 100644 index 0000000..d8429ce --- /dev/null +++ b/src/views/TestView/TimerCard.jsx @@ -0,0 +1,490 @@ +import { Pause, PlayArrow, Stop, WatchLater } from '@mui/icons-material' +import { Box, Card, CardContent, IconButton, Typography } from '@mui/joy' +import { useEffect, useRef, useState } from 'react' + +const TimerCard = ({ + variant = 'standalone', // 'standalone' | 'infoCard' | 'floating' + sx = {}, + onTimeUpdate = () => {}, + title = 'Timer', +}) => { + const [time, setTime] = useState(0) // Time in seconds + const [isRunning, setIsRunning] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const intervalRef = useRef(null) + + // Format time as HH:MM:SS + const formatTime = seconds => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + // Handle timer logic + useEffect(() => { + if (isRunning && !isPaused) { + intervalRef.current = setInterval(() => { + setTime(prevTime => { + const newTime = prevTime + 1 + onTimeUpdate(newTime) + return newTime + }) + }, 1000) + } else { + clearInterval(intervalRef.current) + } + + return () => clearInterval(intervalRef.current) + }, [isRunning, isPaused, onTimeUpdate]) + + const startTimer = () => { + setIsRunning(true) + setIsPaused(false) + } + + const pauseTimer = () => { + setIsPaused(true) + } + + const stopTimer = () => { + setIsRunning(false) + setIsPaused(false) + setTime(0) + onTimeUpdate(0) + } + + const resumeTimer = () => { + setIsPaused(false) + } + + // Info Card variant - fits in ChoreView grid + if (variant === 'infoCard') { + return ( + + + + + + {title} + + + + + {formatTime(time)} + + {!isRunning ? ( + + + + ) : ( + + + {isPaused ? ( + + ) : ( + + )} + + + + + + )} + + {time > 0 && ( + + {Math.floor(time / 60)}m {time % 60}s + + )} + + + ) + } + + // Floating variant - position fixed + if (variant === 'floating') { + return ( + + + + + {title} + + + + + + {formatTime(time)} + + + {isRunning && !isPaused ? 'Running' : isPaused ? 'Paused' : 'Ready'} + + + + + {!isRunning ? ( + + + + ) : ( + <> + + {isPaused ? : } + + + + + + )} + + + ) + } + + // Default standalone variant + return ( + + {/* Header */} + + + + + + + {title} + + + + + {/* Timer Display */} + + {/* Circular Background */} + + {/* Timer Text */} + + + {formatTime(time)} + + + {isRunning && !isPaused + ? 'Running' + : isPaused + ? 'Paused' + : 'Ready'} + + + + + {/* Pulse effect for running state */} + {isRunning && !isPaused && ( + + )} + + + {/* Control Buttons */} + + {!isRunning ? ( + + + + ) : ( + <> + + {isPaused ? ( + + ) : ( + + )} + + + + + + + )} + + + {/* Session Info */} + {time > 0 && ( + + + Session: {Math.floor(time / 60)}m {time % 60}s + + + )} + + ) +} + +export default TimerCard diff --git a/src/views/Things/ThingsHistory.jsx b/src/views/Things/ThingsHistory.jsx index 0b91828..fe8b97a 100644 --- a/src/views/Things/ThingsHistory.jsx +++ b/src/views/Things/ThingsHistory.jsx @@ -1,9 +1,11 @@ -import { EventBusy } from '@mui/icons-material' +import { EventBusy, Schedule, TrendingUp } from '@mui/icons-material' import { + Avatar, Box, Button, Chip, Container, + Grid, List, ListDivider, ListItem, @@ -42,7 +44,7 @@ const ThingsHistory = () => { setErrLoading(true) } }) - }, []) + }, [id]) const handleLoadMore = () => { GetThingHistory(id, thingsHistory.length).then(resp => { @@ -107,7 +109,7 @@ const ThingsHistory = () => { No history found - It's look like there is no history for this thing yet. + It looks like there is no history for this thing yet. - - { - e.stopPropagation() - onEditClick(thing) - }} - sx={{ - borderRadius: '50%', - width: 32, - height: 32, - transition: 'all 0.2s', - '&:hover': { - backgroundColor: 'primary.softBg', - borderColor: 'primary.300', - }, - }} - > - - - - { - e.stopPropagation() - onDeleteClick(thing) - }} - sx={{ - borderRadius: '50%', - width: 32, - height: 32, - transition: 'all 0.2s', - '&:hover': { - backgroundColor: 'danger.softBg', - borderColor: 'danger.300', - }, - }} - > - - - - - - + + + ) } @@ -408,38 +659,47 @@ const ThingsView = () => { } return ( - - {things.length === 0 && ( - - + + {things.length === 0 && ( + + + + No things has been created/found + + + )} + {things.map(thing => ( + - - No things has been created/found - - - )} - {things.map(thing => ( - - ))} + ))} + { + const { choreId } = useParams() + const navigate = useNavigate() + const [timerData, setTimerData] = useState(null) + const [loading, setLoading] = useState(false) + const [editingSessions, setEditingSessions] = useState({}) + const [confirmDeleteConfig, setConfirmDeleteConfig] = useState({}) + const [currentTime, setCurrentTime] = useState(new Date()) + const { showError, showSuccess } = useNotification() + + // Fetch timer data when component mounts + useEffect(() => { + if (choreId) { + fetchTimerData() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [choreId]) + + // Real-time update interval for active timers + useEffect(() => { + let interval + if (timerData && !timerData.endTime) { + // Update every second if timer is active + interval = setInterval(() => { + setCurrentTime(new Date()) + }, 1000) + } + return () => { + if (interval) clearInterval(interval) + } + }, [timerData]) + + const fetchTimerData = async () => { + setLoading(true) + try { + const response = await GetChoreTimer(choreId) + if (response.ok) { + const data = await response.json() + setTimerData(data.res) + } else { + showError({ + title: 'Failed to fetch timer data', + message: 'Please try again.', + }) + } + } catch (error) { + showError({ + title: 'Error fetching timer data', + message: error.message, + }) + } finally { + setLoading(false) + } + } + + const formatTime = seconds => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + return `${hours.toString().padStart(2, '0')}:${minutes + .toString() + .padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + const formatDuration = seconds => { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return `${hours}h ${minutes}m` + } + + const startEditingSession = () => { + if (timerData) { + setEditingSessions(prev => ({ + ...prev, + [timerData.id]: { + startTime: moment(timerData.startTime).format('YYYY-MM-DDTHH:mm:ss'), + endTime: timerData.endTime + ? moment(timerData.endTime).format('YYYY-MM-DDTHH:mm:ss') + : '', + duration: timerData.duration, + formattedDuration: formatTime(timerData.duration), + pauseLog: timerData.pauseLog || [], + }, + })) + } + } + + const addPauseLogEntry = sessionId => { + setEditingSessions(prev => ({ + ...prev, + [sessionId]: { + ...prev[sessionId], + pauseLog: [ + ...prev[sessionId].pauseLog, + { + start: new Date().toISOString(), + end: null, + duration: 0, + updatedBy: 0, // This should be current user ID + }, + ], + }, + })) + } + + const updatePauseLogEntry = (sessionId, pauseIndex, field, value) => { + setEditingSessions(prev => { + const updatedPauseLog = prev[sessionId].pauseLog.map((pause, index) => { + if (index === pauseIndex) { + const updatedPause = { ...pause, [field]: value } + + // Auto-calculate duration if both start and end are present + if (updatedPause.start && updatedPause.end) { + const startTime = new Date(updatedPause.start) + const endTime = new Date(updatedPause.end) + updatedPause.duration = Math.floor((endTime - startTime) / 1000) + } + + return updatedPause + } + return pause + }) + + return { + ...prev, + [sessionId]: { + ...prev[sessionId], + pauseLog: updatedPauseLog, + }, + } + }) + } + + const deletePauseLogEntry = (sessionId, pauseIndex) => { + setEditingSessions(prev => ({ + ...prev, + [sessionId]: { + ...prev[sessionId], + pauseLog: prev[sessionId].pauseLog.filter( + (_, index) => index !== pauseIndex, + ), + }, + })) + } + + const cancelEditingSession = sessionId => { + setEditingSessions(prev => { + // eslint-disable-next-line no-unused-vars + const { [sessionId]: removed, ...rest } = prev + return rest + }) + } + + const saveSession = async sessionId => { + const editingData = editingSessions[sessionId] + if (!editingData) return + + setLoading(true) + try { + // Use the auto-calculated duration from the editing session + const updateData = { + startTime: new Date(editingData.startTime).toISOString(), + endTime: editingData.endTime + ? new Date(editingData.endTime).toISOString() + : null, + duration: editingData.duration, + pauseLog: editingData.pauseLog, + } + + const response = await UpdateTimeSession(choreId, sessionId, updateData) + if (response.ok) { + showSuccess({ + title: 'Session updated', + message: 'Timer session has been updated successfully.', + }) + await fetchTimerData() + cancelEditingSession(sessionId) + } else { + showError({ + title: 'Failed to update session', + message: 'Please try again.', + }) + } + } catch (error) { + showError({ + title: 'Error updating session', + message: error.message, + }) + } finally { + setLoading(false) + } + } + + const deleteSession = async sessionId => { + setLoading(true) + try { + const response = await DeleteTimeSession(choreId, sessionId) + if (response.ok) { + showSuccess({ + title: 'Session deleted', + message: 'Timer session has been deleted successfully.', + }) + await fetchTimerData() + // Navigate back after successful deletion + navigate(`/chores/${choreId}`) + } else { + showError({ + title: 'Failed to delete session', + message: 'Please try again.', + }) + } + } catch (error) { + showError({ + title: 'Error deleting session', + message: error.message, + }) + } finally { + setLoading(false) + } + } + + const confirmDeleteSession = sessionId => { + setConfirmDeleteConfig({ + isOpen: true, + title: 'Delete Timer Session', + message: 'Are you sure you want to delete this timer session?', + confirmText: 'Delete', + cancelText: 'Cancel', + color: 'danger', + onClose: isConfirmed => { + if (isConfirmed) { + deleteSession(sessionId) + } + setConfirmDeleteConfig({}) + }, + }) + } + + const handleGoBack = () => { + navigate(`/chores/${choreId}`) + } + + // Calculate total duration from start to now/end (real-time) + const calculateTotalDuration = () => { + if (!timerData) return 0 + + const startTime = new Date(timerData.startTime) + const endTime = timerData.endTime + ? new Date(timerData.endTime) + : currentTime + + return Math.floor((endTime - startTime) / 1000) // in seconds + } + + // Calculate current active duration (including ongoing session) (real-time) + const calculateCurrentActiveDuration = () => { + if (!timerData || !timerData.pauseLog) return 0 + + let totalActive = 0 + const now = currentTime + + timerData.pauseLog.forEach(session => { + if (session.start && session.end) { + // Completed session + totalActive += Math.floor( + (new Date(session.end) - new Date(session.start)) / 1000, + ) + } else if (session.start && !session.end) { + // Ongoing session - real-time calculation + totalActive += Math.floor((now - new Date(session.start)) / 1000) + } + }) + + return totalActive + } + + // Calculate idle time (total time minus active time) (real-time) + const calculateIdleTime = () => { + const totalDuration = calculateTotalDuration() + const activeDuration = calculateCurrentActiveDuration() + + return Math.max(0, totalDuration - activeDuration) + } + + return ( + + {/* Header */} + + {loading && ( + + Loading timer data... + + )} + + {!loading && !timerData && ( + + No timer data found for this chore. + + )} + + {!loading && timerData && ( + + {/* Timer Summary */} + + {/* Stats Grid */} + + {/* Active Time */} + + + + + + + Active Work + + + + + {formatDuration(calculateCurrentActiveDuration())} + + + + + + + {/* Idle Time */} + + + + + + + Break Time + + + + + {formatDuration(calculateIdleTime())} + + + + + + + {/* Total Sessions */} + + + + + + + Sessions + + + + + {timerData.pauseLog?.length || 0} + + + + + + + {/* Total Session Time */} + + + + + + + Total Time + + + + + {formatTime(calculateTotalDuration())} + + + + + + + + {/* Progress Bar */} + + + + Work vs Break Distribution + + + {calculateCurrentActiveDuration() > 0 + ? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active` + : 'No active time yet'} + + + + + + + + + {/* Session Breakdown */} + + + Session Breakdown + + + {!editingSessions[timerData.id] ? ( + + {/* Read-only view */} + {timerData.pauseLog && timerData.pauseLog.length > 0 && ( + + + Work Sessions ({timerData.pauseLog.length}) + + + + {timerData.pauseLog + .sort((a, b) => moment(b.start) - moment(a.start)) + .map((pause, pauseIndex) => { + const isOngoing = !pause.end + const sessionDate = moment(pause.start).format( + 'MMM DD', + ) + const startTime = moment(pause.start).format('HH:mm') + const endTime = pause.end + ? moment(pause.end).format('HH:mm') + : null + + const realTimeDuration = isOngoing + ? Math.max( + 0, + Math.floor( + (currentTime - new Date(pause.start)) / 1000, + ), + ) + : pause.duration + + return ( + + {/* Session indicator */} + + + {/* Duration - Main focus */} + + + {formatDuration(realTimeDuration)} + + {isOngoing && ( + + Live + + )} + + + {/* Session details */} + + + Session #{pauseIndex + 1} • {sessionDate} + + + {startTime}{' '} + {endTime ? `→ ${endTime}` : '→ ongoing'} + + + + ) + })} + + + )} + + {(!timerData.pauseLog || timerData.pauseLog.length === 0) && ( + + No work sessions found for this timer. + + )} + + ) : ( + + {/* Editing view */} + + {/* Session Editor */} + + + + Sessions + + + + + {editingSessions[timerData.id].pauseLog.map( + (pause, pauseIndex) => ( + + + + Session #{pauseIndex + 1} + + + + + + + + Start Time + + + updatePauseLogEntry( + timerData.id, + pauseIndex, + 'start', + new Date(e.target.value).toISOString(), + ) + } + /> + + + + + End Time + + + updatePauseLogEntry( + timerData.id, + pauseIndex, + 'end', + e.target.value + ? new Date(e.target.value).toISOString() + : null, + ) + } + /> + + Leave empty if session is ongoing + + + + + + Duration (Auto-calculated) + + + {formatDuration(pause.duration)} ( + {pause.duration}s) + + + + + ), + )} + + + + )} + + + )} + + {/* Sticky Bottom Actions */} + + + + {/* */} + + {/* Right side - Action buttons */} + {!loading && timerData && !editingSessions[timerData.id] && ( + + + + + )} + + {/* Save/Cancel buttons when editing */} + {!loading && timerData && editingSessions[timerData.id] && ( + + + + + )} + + + + + + + ) +} + +export default TimerDetails diff --git a/src/views/User/UserActivities.jsx b/src/views/User/UserActivities.jsx index d6c1eb0..598f2f7 100644 --- a/src/views/User/UserActivities.jsx +++ b/src/views/User/UserActivities.jsx @@ -3,7 +3,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CircleIcon from '@mui/icons-material/Circle' import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts' -import { EventBusy, Toll } from '@mui/icons-material' +import { EventBusy, Group, Toll } from '@mui/icons-material' import { Avatar, Box, @@ -27,7 +27,7 @@ import React, { useEffect, useState } from 'react' import { useChores, useChoresHistory } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { ChoresGrouper } from '../../utils/Chores' -import { TASK_COLOR } from '../../utils/Colors.jsx' +import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx' import { resolvePhotoURL } from '../../utils/Helpers.jsx' import LoadingComponent from '../components/Loading' @@ -131,7 +131,7 @@ const ChoreHistoryTimeline = ({ history }) => { ) } -const renderPieChart = (data, size, isPrimary) => ( +const renderPieChart = (data, size, isPrimary, chartType = null) => ( ( ))} - {isPrimary && } + {isPrimary && ( + { + if (chartType === 'tasksTime' && props.payload.count) { + return [`${value}h (${props.payload.count} times)`, name] + } + return [`${value}`, name] + }} + /> + )} {isPrimary && ( ( ) const USER_FILTER = (history, userId) => { - if (userId === undefined) return true + if (userId === undefined || userId === 'all') return true return history.completedBy === userId } @@ -172,7 +181,6 @@ const UserActivites = () => { const [tabValue, setTabValue] = React.useState(30) const [selectedHistory, setSelectedHistory] = React.useState([]) const [enrichedHistory, setEnrichedHistory] = React.useState([]) - const [selectedFilter, setSelectedFilter] = React.useState('Anyone') const [selectedChart, setSelectedChart] = React.useState('history') const [historyPieChartData, setHistoryPieChartData] = React.useState([]) @@ -183,18 +191,22 @@ const UserActivites = () => { const [choresPriorityChartData, setChoresPriorityChartData] = React.useState( [], ) + const [choresLabelsChartData, setChoresLabelsChartData] = React.useState([]) + const [choresLabelsDurationChartData, setChoresLabelsDurationChartData] = + React.useState([]) + const [tasksTimeChartData, setTasksTimeChartData] = React.useState([]) + const [ + choresAssigneeBreakdownChartData, + setChoresAssigneeBreakdownChartData, + ] = React.useState([]) const { data: choresData, isLoading: isChoresLoading } = useChores(true) const { data: choresHistory, isChoresHistoryLoading, handleLimitChange: refetchHistory, } = useChoresHistory(tabValue ? tabValue : 30, true) - const { - data: circleMembersData, - isLoading: isCircleMembersLoading, - handleRefetch: handleCircleMembersRefetch, - } = useCircleMembers() - const [selectedUser, setSelectedUser] = React.useState(userProfile?.id) + const { data: circleMembersData } = useCircleMembers() + const [selectedUser, setSelectedUser] = React.useState('all') const [circleUsers, setCircleUsers] = useState([]) useEffect(() => { @@ -204,7 +216,12 @@ const UserActivites = () => { }, [circleMembersData]) useEffect(() => { - if (!isChoresHistoryLoading && !isChoresLoading && choresHistory) { + if ( + !isChoresHistoryLoading && + !isChoresLoading && + choresHistory && + choresData?.res + ) { const enrichedHistory = choresHistory.map(item => { const chore = choresData.res.find(chore => chore.id === item.choreId) return { @@ -214,51 +231,276 @@ const UserActivites = () => { }) setEnrichedHistory(enrichedHistory) - setSelectedHistory( - enrichedHistory.filter(h => USER_FILTER(h, selectedUser)), + const filteredHistory = enrichedHistory.filter(h => + USER_FILTER(h, selectedUser), ) - setHistoryPieChartData(generateHistoryPieChartData(enrichedHistory)) + setSelectedHistory(filteredHistory) + setHistoryPieChartData(generateHistoryPieChartData(filteredHistory)) + + // Generate labels duration chart data when both chores and history are available + setChoresLabelsDurationChartData( + generateChoreLabelsWithDurationChartData( + choresData.res, + filteredHistory, + ), + ) + + // Generate tasks time chart data + setTasksTimeChartData(generateTasksTimeChartData(filteredHistory)) } - }, [isChoresHistoryLoading, isChoresLoading, choresHistory]) + }, [ + isChoresHistoryLoading, + isChoresLoading, + choresHistory, + choresData?.res, + selectedUser, + ]) useEffect(() => { if (!isChoresLoading && choresData) { - const choreDuePieChartData = generateChoreDuePieChartData(choresData.res) + // Filter chores based on selected user + const filteredChores = + selectedUser === 'all' || selectedUser === undefined + ? choresData.res + : choresData.res.filter(chore => chore.assignedTo === selectedUser) + + const generateChoreAssignedChartData = chores => { + var assignedToMe = 0 + var assignedToOthers = 0 + chores.forEach(chore => { + if (chore.assignedTo === userProfile?.id) { + assignedToMe++ + } else assignedToOthers++ + }) + + const group = [] + if (assignedToMe > 0) { + group.push({ + label: `Assigned to me`, + value: assignedToMe, + color: TASK_COLOR.ASSIGNED_TO_ME, + id: 1, + }) + } + if (assignedToOthers > 0) { + group.push({ + label: `Assigned to others`, + value: assignedToOthers, + color: TASK_COLOR.ASSIGNED_TO_OTHERS, + id: 2, + }) + } + return group + } + + const generateChorePriorityPieChartData = chores => { + const groups = ChoresGrouper('priority', chores, null) + return groups + .map(group => { + return { + label: group.name, + value: group.content.length, + color: group.color, + id: group.name, + } + }) + .filter(item => item.value > 0) + } + + const generateChoreLabelsChartData = chores => { + const labelCounts = {} + let unlabeledCount = 0 + + chores.forEach(chore => { + if (chore.labelsV2 && chore.labelsV2.length > 0) { + chore.labelsV2.forEach(label => { + if (labelCounts[label.id]) { + labelCounts[label.id].count++ + } else { + labelCounts[label.id] = { + label: label.name, + count: 1, + color: label.color || TASK_COLOR.ANYTIME, + id: label.id, + } + } + }) + } else { + unlabeledCount++ + } + }) + + const result = Object.values(labelCounts) + .map(item => ({ + label: item.label, + value: item.count, + color: item.color, + id: item.id, + })) + .filter(item => item.value > 0) + .sort((a, b) => b.value - a.value) // Sort by count descending + + // Add unlabeled tasks if there are any + if (unlabeledCount > 0) { + result.push({ + label: 'No Labels', + value: unlabeledCount, + color: TASK_COLOR.ANYTIME, + id: 'unlabeled', + }) + } + + return result + } + + const generateChoreAssigneeBreakdownChartData = chores => { + const assigneeCounts = {} + + // Define a set of distinct colors for different assignees + + const assigneeColors = Object.values(COLORS) + + let colorIndex = 0 + + chores.forEach(chore => { + const assignee = circleUsers.find( + user => user.userId === chore.assignedTo, + ) + const assigneeName = assignee ? assignee.displayName : 'Unassigned' + const assigneeId = chore.assignedTo || 'unassigned' + + if (assigneeCounts[assigneeId]) { + assigneeCounts[assigneeId].count++ + } else { + assigneeCounts[assigneeId] = { + label: assigneeName, + count: 1, + color: + assigneeId === 'unassigned' + ? TASK_COLOR.ANYTIME + : assigneeColors[colorIndex % assigneeColors.length], + id: assigneeId, + } + if (assigneeId !== 'unassigned') { + colorIndex++ + } + } + }) + + return Object.values(assigneeCounts) + .map(item => ({ + label: item.label, + value: item.count, + color: item.color, + id: item.id, + })) + .filter(item => item.value > 0) + .sort((a, b) => b.value - a.value) // Sort by count descending + } + + const choreDuePieChartData = generateChoreDuePieChartData(filteredChores) setChoreDuePieChartData(choreDuePieChartData) - setChoresAssignedChartData(generateChoreAssignedChartData(choresData.res)) + setChoresAssignedChartData(generateChoreAssignedChartData(filteredChores)) setChoresPriorityChartData( - generateChorePriorityPieChartData(choresData.res), + generateChorePriorityPieChartData(filteredChores), + ) + setChoresLabelsChartData(generateChoreLabelsChartData(filteredChores)) + setChoresAssigneeBreakdownChartData( + generateChoreAssigneeBreakdownChartData(filteredChores), ) } - }, [isChoresLoading, choresData]) + }, [isChoresLoading, choresData, userProfile?.id, circleUsers, selectedUser]) - const generateChoreAssignedChartData = chores => { - var assignedToMe = 0 - var assignedToOthers = 0 - chores.forEach(chore => { - if (chore.assignedTo === userProfile?.id) { - assignedToMe++ - } else assignedToOthers++ + const generateChoreLabelsWithDurationChartData = (chores, history) => { + const labelDurations = {} + let unlabeledDuration = 0 + + // Iterate through ChoreHistory to get actual time spent + history.forEach(historyItem => { + const duration = historyItem.duration || 0 // duration in seconds from ChoreHistory + + // Find the corresponding chore to get its labels + const chore = chores.find(c => c.id === historyItem.choreId) + + if (chore && chore.labelsV2 && chore.labelsV2.length > 0) { + // If chore has labels, add duration to each label + chore.labelsV2.forEach(label => { + if (labelDurations[label.id]) { + labelDurations[label.id].duration += duration + } else { + labelDurations[label.id] = { + label: label.name, + duration: duration, + color: label.color || TASK_COLOR.ANYTIME, + id: label.id, + } + } + }) + } else { + // If chore has no labels or chore not found, add to unlabeled + unlabeledDuration += duration + } }) - const group = [] - if (assignedToMe > 0) { - group.push({ - label: `Assigned to me`, - value: assignedToMe, - color: TASK_COLOR.ASSIGNED_TO_ME, - id: 1, + // Convert seconds to hours for better readability + const result = Object.values(labelDurations) + .map(item => ({ + label: item.label, + value: Math.round((item.duration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal + color: item.color, + id: item.id, + })) + .filter(item => item.value > 0) + .sort((a, b) => b.value - a.value) // Sort by duration descending + + // Add unlabeled tasks duration if there is any + if (unlabeledDuration > 0) { + result.push({ + label: 'No Labels', + value: Math.round((unlabeledDuration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal + color: TASK_COLOR.ANYTIME, + id: 'unlabeled', }) } - if (assignedToOthers > 0) { - group.push({ - label: `Assigned to others`, - value: assignedToOthers, - color: TASK_COLOR.ASSIGNED_TO_OTHERS, - id: 2, - }) - } - return group + + return result + } + + const generateTasksTimeChartData = history => { + const taskDurations = {} + const colorValues = Object.values(COLORS) + + // Iterate through ChoreHistory to get actual time spent per task + history.forEach(historyItem => { + const duration = historyItem.duration || 0 // duration in seconds from ChoreHistory + const taskName = historyItem.choreName || 'Unknown Task' + + if (taskDurations[taskName]) { + taskDurations[taskName].duration += duration + taskDurations[taskName].count += 1 + } else { + taskDurations[taskName] = { + taskName: taskName, + duration: duration, + count: 1, + } + } + }) + + // Convert seconds to hours and prepare chart data + const result = Object.values(taskDurations) + .map((item, index) => ({ + label: item.taskName, + value: Math.round((item.duration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal + count: item.count, + color: colorValues[index % colorValues.length], + id: item.taskName, + })) + .filter(item => item.value > 0) + .sort((a, b) => b.value - a.value) // Sort by time spent descending + .slice(0, 10) // Show top 10 tasks only + + return result } const generateChoreDuePieChartData = chores => { @@ -274,19 +516,6 @@ const UserActivites = () => { }) .filter(item => item.value > 0) } - const generateChorePriorityPieChartData = chores => { - const groups = ChoresGrouper('priority', chores, null) - return groups - .map(group => { - return { - label: group.name, - value: group.content.length, - color: group.color, - id: group.name, - } - }) - .filter(item => item.value > 0) - } const generateHistoryPieChartData = history => { const totalCompleted = @@ -319,7 +548,6 @@ const UserActivites = () => { if (isChoresHistoryLoading || isChoresLoading) { return } - const COLORS = historyPieChartData.map(item => item.color) const chartData = { history: { data: historyPieChartData, @@ -331,18 +559,40 @@ const UserActivites = () => { title: 'Due Date', description: 'Current tasks due date', }, - assigned: { - data: choresAssignedChartData, - title: 'Assignee', - description: 'Tasks assigned to you vs others', - }, + // assigned: { + // data: choresAssignedChartData, + // title: 'Assigned to me', + // description: 'Tasks assigned to you vs others', + // }, priority: { data: choresPriorityChartData, title: 'Priority', description: 'Tasks by priority', }, + labels: { + data: choresLabelsChartData, + title: 'Labels', + description: 'Tasks by labels', + }, + labelsDuration: { + data: choresLabelsDurationChartData, + title: 'Labels (time)', + description: 'Time spent by labels (hours)', + }, + tasksTime: { + data: tasksTimeChartData, + title: 'Tasks (time)', + description: 'Time spent by individual tasks (hours)', + }, + assigneeBreakdown: { + data: choresAssigneeBreakdownChartData, + title: 'by Assignee', + description: 'Tasks grouped by assignee', + }, + } + if (!userProfile) { + return } - if (!choresData.res?.length > 0 || !choresHistory?.length > 0) { return ( { return ( - - - Points Overview - - - - - { - setTabValue(tabValue) - refetchHistory(tabValue) - }} - defaultValue={7} - sx={{ - py: 0.5, - borderRadius: 16, - maxWidth: 400, - mb: 1, - }} - > - + Activities Overview + + + {/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */} + + {/* Left Side - Timeline with Filters (Mobile: Full width, Desktop: Flexible) */} + + {/* Improved Filter Bar - Now above timeline */} + - {[ - { label: '7 Days', value: 7 }, - { label: '30 Days', value: 30 }, - { label: '90 Days', value: 90 }, - ].map((tab, index) => ( - + + Filter Activities + + + + {/* User Filter */} + + + Show activities for: + + + + + {/* Time Period Filter */} + + + Time period: + + { + setTabValue(tabValue) + refetchHistory(tabValue) + }} + value={tabValue} + sx={{ + borderRadius: 8, + backgroundColor: 'background.surface', + border: '1px solid', + borderColor: 'divider', + }} + > + + {[ + { label: '7 Days', value: 7 }, + { label: '30 Days', value: 30 }, + { label: '90 Days', value: 90 }, + { label: 'All Time', value: 365 }, + ].map((tab, index) => ( + + {tab.label} + + ))} + + + + + + + + {/* Current Filter Summary */} + + + Showing activities for{' '} + + {selectedUser === undefined || selectedUser === 'all' + ? 'All Users' + : circleUsers.find(user => user.userId === selectedUser) + ?.displayName || 'Unknown User'} + {' '} + over the{' '} + + {tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`} + + + + + + + + {/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */} + + {/* Charts Container */} + + + {/* Main Chart */} + - {tab.label} - - ))} - - - - - - {chartData[selectedChart].title} - - - {chartData[selectedChart].description} - - {renderPieChart(chartData[selectedChart].data, 250, true)} - - - {Object.entries(chartData) - .filter(([key]) => key !== selectedChart) - .map(([key, { data, title }]) => ( - - setSelectedChart(key)} - sx={{ cursor: 'pointer', p: 1 }} - > - - {title} + + {chartData[selectedChart].title} - {renderPieChart(data, 75, false)} - - - ))} - - + + {chartData[selectedChart].description} + + + {renderPieChart( + chartData[selectedChart].data, + 240, + true, + selectedChart, + )} + + + + + + {/* Chart Selection Grid */} + + + {Object.entries(chartData) + .filter(([key]) => key !== selectedChart) + .map(([key, { data, title }]) => ( + + setSelectedChart(key)} + variant='plain' + sx={{ + cursor: 'pointer', + p: 1, + transition: 'all 0.2s ease-in-out', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: 80, + maxWidth: 90, + '&:hover': { + transform: 'scale(1.02)', + boxShadow: 'sm', + }, + }} + > + + {title} + + + {renderPieChart(data, 70, false)} + + + + ))} + + + + + + ) } diff --git a/src/views/User/UserPoints.jsx b/src/views/User/UserPoints.jsx index 7ac5b64..cab716d 100644 --- a/src/views/User/UserPoints.jsx +++ b/src/views/User/UserPoints.jsx @@ -17,6 +17,7 @@ import { Container, Option, Select, + Stack, Tab, TabList, Tabs, @@ -50,16 +51,15 @@ const UserPoints = () => { const [selectedUser, setSelectedUser] = useState(userProfile?.id) const [circleUsers, setCircleUsers] = useState([]) const [selectedHistory, setSelectedHistory] = useState([]) - const [userPointsBarChartData, setUserPointsBarChartData] = useState([]) - - const [choresHistory, setChoresHistory] = useState([]) useEffect(() => { if (circleMembersData && choresHistoryData && userProfile) { setCircleUsers(circleMembersData.res) - setSelectedHistory(generateWeeklySummary(choresHistory, userProfile?.id)) + setSelectedHistory( + generateWeeklySummary(choresHistoryData, userProfile?.id), + ) } - }, [circleMembersData, choresHistoryData]) + }, [circleMembersData, choresHistoryData, userProfile]) useEffect(() => { if (choresHistoryData) { @@ -75,25 +75,12 @@ const UserPoints = () => { } setSelectedHistory(history) } - }, [selectedUser, choresHistoryData]) + }, [selectedUser, choresHistoryData, tabValue]) useEffect(() => { setSelectedUser(userProfile?.id) }, [userProfile]) - const generateUserPointsHistory = history => { - const userPoints = {} - for (let i = 0; i < history.length; i++) { - const chore = history[i] - if (!userPoints[chore.completedBy]) { - userPoints[chore.completedBy] = chore.points ? chore.points : 0 - } else { - userPoints[chore.completedBy] += chore.points ? chore.points : 0 - } - } - return userPoints - } - const generateWeeklySummary = (history, userId) => { const daysAggregated = [] for (let i = 6; i > -1; i--) { @@ -221,103 +208,229 @@ const UserPoints = () => { return ( + + Points Overview + + + {/* Improved Filter Bar */} + + + + Filter Points + + + + {/* User Filter */} + + + Show points for: + + + + + {/* Time Period Filter */} + + + Time period: + + { + setTabValue(tabValue) + handleChoresHistoryLimitChange(tabValue) + }} + value={tabValue} + sx={{ + borderRadius: 8, + backgroundColor: 'background.surface', + border: '1px solid', + borderColor: 'divider', + }} + > + + {[ + { label: '7 Days', value: 7 }, + { label: '6 Months', value: 6 * 30 }, + { label: 'All Time', value: 24 * 30 }, + ].map((tab, index) => ( + + {tab.label} + + ))} + + + + + {/* Redeem Points Button */} + {circleUsers.find(user => user.userId === userProfile.id)?.role === + 'admin' && ( + + + + )} + + + + + {/* Current Filter Summary */} + + + Showing points for{' '} + + {circleUsers.find(user => user.userId === selectedUser) + ?.displayName || 'Unknown User'} + {' '} + over the{' '} + + {tabValue === 24 * 30 + ? 'All Time' + : tabValue === 6 * 30 + ? 'Last 6 Months' + : `Last ${tabValue} Days`} + + + + - Points Overview - - - {circleUsers.find(user => user.userId === userProfile.id)?.role === - 'admin' && ( - - )} - - + {/* Points Cards */} { if (!user) return 0 return user.points - user.pointsRedeemed })(), - color: 'success', }, { @@ -374,63 +486,11 @@ const UserPoints = () => { ))} - Points History - - { - setTabValue(tabValue) - handleChoresHistoryLimitChange(tabValue) - }} - defaultValue={tabValue} - sx={{ - py: 0.5, - borderRadius: 16, - maxWidth: 400, - mb: 1, - }} - > - - {[ - { label: '7 Days', value: 7 }, - // { label: '3 Month', value: 30 }, - { label: '6 Months', value: 6 * 30 }, - { label: 'All Time', value: 24 * 30 }, - ].map((tab, index) => ( - - {tab.label} - - ))} - - - + {/* Points History Section */} + + Points History + { display: 'flex', justifyContent: 'left', gap: 1, + mb: 3, }} > {[ @@ -471,7 +532,8 @@ const UserPoints = () => { ))} - {/* Bar Chart for points overtime : */} + + {/* Bar Chart for points overtime */} { > - - - {/* Rounded top corners, blue fill, set bar width */} - {/* Add a slightly darker top section to the 'Jul' bar */} - + /> + { @@ -507,7 +565,7 @@ const UserPoints = () => { user: circleUsers.find(user => user.userId === selectedUser), onSave: ({ userId, points }) => { RedeemPoints(userId, points, userProfile.circleID) - .then(res => { + .then(() => { setIsRedeemModalOpen(false) handleCircleMembersRefetch() }) diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 3d5ddb5..651621e 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -17,6 +17,7 @@ import { } from './CustomParsers' import SmartTaskTitleInput from './SmartTaskTitleInput' +import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import NotificationTemplate from '../../components/NotificationTemplate' import LearnMoreButton from './LearnMore' import RichTextEditor from './RichTextEditor' @@ -53,6 +54,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const textareaRef = useRef(null) const mainInputRef = useRef(null) + const richTextEditorRef = useRef(null) const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) @@ -67,6 +69,82 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) const [hasNotifications, setHasNotifications] = useState(false) + const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(true) + + // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: + useEffect(() => { + if (hasDescription && richTextEditorRef.current) { + // Small delay to ensure the component is fully rendered + setTimeout(() => { + richTextEditorRef.current.focus() + }, 100) + } + }, [hasDescription]) + + // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: + useEffect(() => { + const handleKeyDown = event => { + const isHoldingCmd = event.ctrlKey || event.metaKey + if (isHoldingCmd) { + // event.preventDefault() + setShowKeyboardShortcuts(true) + } + if ( + isHoldingCmd && + event.key.toLowerCase() === 'e' && + isModalOpen && + !hasDescription + ) { + setHasDescription(true) + setShowKeyboardShortcuts(false) + } + if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) { + // add subtask: + setHasSubTasks(true) + setShowKeyboardShortcuts(false) + // set focus on the first subtask input: + } + if ( + isHoldingCmd && + event.key.toLowerCase() === 'b' && + isModalOpen && + !dueDate + ) { + // add due date: + setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00')) + setShowKeyboardShortcuts(false) + } + // Enter key to create task + if ( + event.key === 'Enter' && + (event.ctrlKey || event.metaKey) && + isModalOpen + ) { + event.preventDefault() + createChore() + return + } + // Escape key to cancel/close modal + if (event.key === 'Escape' && isModalOpen) { + event.preventDefault() + handleCloseModal() + return + } + } + + const handleKeyUp = event => { + if (event.key === 'Control' || event.key === 'Meta') { + setShowKeyboardShortcuts(false) + } + } + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, []) + useEffect(() => { if (isModalOpen && textareaRef.current) { textareaRef.current.focus() @@ -319,14 +397,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setAssignees([]) } - const handleSubmit = () => { - console.log('Submitting task:', isPlusAccount(userProfile)) - - // createChore() - // handleCloseModal() - // setTaskText('') - } - const createChore = () => { const chore = { name: taskTitle, @@ -376,6 +446,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { handleCloseModal(false) } + handleCloseModal() + setTaskText('') }) }) .catch(error => { @@ -490,23 +562,36 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { sx={{ width: '100%', fontSize: '16px' }} /> */} + {!hasDescription && ( )} + {!hasSubTasks && ( @@ -519,6 +604,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { onClick={() => { setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00')) }} + endDecorator={ + showKeyboardShortcuts && + } > Due Date @@ -545,6 +633,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { Description:
@@ -558,6 +647,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { editMode={true} tasks={subTasks ? subTasks : []} setTasks={setSubTasks} + shouldFocus={true} /> )} @@ -570,20 +660,22 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { gap: 2, }} > - - Priority - - + {priority > 0 && ( + + Priority + + + )} {dueDate && ( Due Date @@ -665,9 +757,19 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { > - diff --git a/src/views/components/ChoreActionMenu.jsx b/src/views/components/ChoreActionMenu.jsx index e95f349..2d795bf 100644 --- a/src/views/components/ChoreActionMenu.jsx +++ b/src/views/components/ChoreActionMenu.jsx @@ -35,6 +35,9 @@ const ChoreActionMenu = ({ onChangeDueDate, onWriteNFC, onDelete, + onOpen, + onMouseEnter, + onMouseLeave, sx = {}, variant = 'soft', }) => { @@ -55,6 +58,9 @@ const ChoreActionMenu = ({ } document.addEventListener('mousedown', handleMenuOutsideClick) + if (anchorEl) { + onOpen() + } return () => { document.removeEventListener('mousedown', handleMenuOutsideClick) } @@ -158,6 +164,8 @@ const ChoreActionMenu = ({ variant={variant} color='success' onClick={handleMenuOpen} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} sx={{ borderRadius: '50%', width: 25, @@ -171,11 +179,16 @@ const ChoreActionMenu = ({ { diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx index 4365ed6..680ab6a 100644 --- a/src/views/components/RichTextEditor.jsx +++ b/src/views/components/RichTextEditor.jsx @@ -2,217 +2,253 @@ import imageCompression from 'browser-image-compression' import Quill from 'quill' import 'quill/dist/quill.snow.css' import QuillMarkdown from 'quilljs-markdown' -import { useCallback, useEffect, useRef } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react' import { useUserProfile } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers' import { UploadFile } from '../../utils/TokenManager' import './RichTextEditor.css' -const RichTextEditor = ({ - value = '', - onChange, - isEditable = true, - placeholder = 'Enter description...', - variant = 'outlined', - entityId, - entityType, -}) => { - const { showError } = useNotification() - const { data: userProfile } = useUserProfile() - const quillRef = useRef(null) - const editorRef = useRef(null) +const RichTextEditor = forwardRef( + ( + { + value = '', + onChange, + isEditable = true, + placeholder = 'Enter description...', + variant = 'outlined', + entityId, + entityType, + }, + ref, + ) => { + const { showError } = useNotification() + const { data: userProfile } = useUserProfile() + const quillRef = useRef(null) + const editorRef = useRef(null) - // Image upload handler - wrapped in useCallback to avoid recreating on every render - const handleImageUpload = useCallback(() => { - // Check if user has plus account - if (!isPlusAccount(userProfile)) { - showError({ - title: 'Plus Feature', - message: - 'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.', - }) - return - } + // Expose focus method to parent components + useImperativeHandle( + ref, + () => ({ + focus: () => { + if (editorRef.current) { + editorRef.current.focus() + } + }, + blur: () => { + if (editorRef.current) { + editorRef.current.blur() + } + }, + }), + [], + ) - const input = document.createElement('input') - input.setAttribute('type', 'file') - input.setAttribute('accept', 'image/*') - input.click() - input.onchange = async () => { - const file = input.files[0] - if (!file) return - - try { - // Define compression options based on entity type ( this need a revist later) - const compressionOptions = { - maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images - maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images - useWebWorker: true, - fileType: 'image/jpeg', - } - - // Compress the image - const compressedFile = await imageCompression(file, compressionOptions) - - // Create new file with .jpg extension to ensure it's treated as JPEG - const compressedJpegFile = new File( - [compressedFile], - `${file.name.split('.')[0]}.jpg`, - { type: 'image/jpeg' }, - ) - - console.log(`Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`) - console.log( - `Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`, - ) - - // Upload compressed image to backend - const formData = new FormData() - formData.append('file', compressedJpegFile) - formData.append('entityId', entityId) - formData.append('entityType', entityType) - - const response = await UploadFile('/assets/chore', { - method: 'POST', - body: formData, + // Image upload handler - wrapped in useCallback to avoid recreating on every render + const handleImageUpload = useCallback(() => { + // Check if user has plus account + if (!isPlusAccount(userProfile)) { + showError({ + title: 'Plus Feature', + message: + 'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.', }) + return + } - if (response.status === 507) { - showError({ - title: 'Storage Quota Exceeded', - message: 'You have exceeded your quota for uploading files.', + const input = document.createElement('input') + input.setAttribute('type', 'file') + input.setAttribute('accept', 'image/*') + input.click() + input.onchange = async () => { + const file = input.files[0] + if (!file) return + + try { + // Define compression options based on entity type ( this need a revist later) + const compressionOptions = { + maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images + maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images + useWebWorker: true, + fileType: 'image/jpeg', + } + + // Compress the image + const compressedFile = await imageCompression( + file, + compressionOptions, + ) + + // Create new file with .jpg extension to ensure it's treated as JPEG + const compressedJpegFile = new File( + [compressedFile], + `${file.name.split('.')[0]}.jpg`, + { type: 'image/jpeg' }, + ) + + console.log( + `Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`, + ) + console.log( + `Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`, + ) + + // Upload compressed image to backend + const formData = new FormData() + formData.append('file', compressedJpegFile) + formData.append('entityId', entityId) + formData.append('entityType', entityType) + + const response = await UploadFile('/assets/chore', { + method: 'POST', + body: formData, }) - return - } else if (response.status === 413) { - showError({ - title: 'File Too Large', - message: 'The file you are trying to upload is too large.', - }) - return - } else if (response.status === 403 && !isPlusAccount()) { - showError({ - title: 'Upgrade Required', - message: - 'Image uploads are only available for Plus accounts. Please ', - }) - return - } else if (response.status === 403) { - showError({ - title: 'Permission Denied', - message: 'You do not have permission to upload files.', - }) - return - } else if (!response.ok) { + + if (response.status === 507) { + showError({ + title: 'Storage Quota Exceeded', + message: 'You have exceeded your quota for uploading files.', + }) + return + } else if (response.status === 413) { + showError({ + title: 'File Too Large', + message: 'The file you are trying to upload is too large.', + }) + return + } else if (response.status === 403 && !isPlusAccount()) { + showError({ + title: 'Upgrade Required', + message: + 'Image uploads are only available for Plus accounts. Please ', + }) + return + } else if (response.status === 403) { + showError({ + title: 'Permission Denied', + message: 'You do not have permission to upload files.', + }) + return + } else if (!response.ok) { + showError({ + title: 'Upload Failed', + message: 'Failed to upload image.', + }) + return + } + const data = await response.json() + const url = resolvePhotoURL(data.url || data.sign) + // Insert image into Quill + const quill = editorRef.current + const range = quill.getSelection() + quill.insertEmbed(range ? range.index : 0, 'image', url) + } catch (error) { + console.error('Error during image processing or upload:', error) showError({ title: 'Upload Failed', - message: 'Failed to upload image.', + message: 'An error occurred while processing the image.', }) - return } - const data = await response.json() - const url = resolvePhotoURL(data.url || data.sign) - // Insert image into Quill - const quill = editorRef.current - const range = quill.getSelection() - quill.insertEmbed(range ? range.index : 0, 'image', url) - } catch (error) { - console.error('Error during image processing or upload:', error) - showError({ - title: 'Upload Failed', - message: 'An error occurred while processing the image.', - }) } - } - }, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback + }, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback - useEffect(() => { - if (!quillRef.current) return - if (!editorRef.current && isEditable) { - editorRef.current = new Quill(quillRef.current, { - theme: variant === 'bubble' ? 'bubble' : 'snow', - modules: { - toolbar: { - container: [ - [{ header: [1, 2, 3, 4, false] }], - ['bold', 'italic', 'underline', 'strike'], - ['blockquote', 'code-block'], - [{ list: 'ordered' }, { list: 'bullet' }], - ['link', 'image'], - ['clean'], - ], - handlers: { - image: handleImageUpload, + useEffect(() => { + if (!quillRef.current) return + if (!editorRef.current && isEditable) { + editorRef.current = new Quill(quillRef.current, { + theme: variant === 'bubble' ? 'bubble' : 'snow', + modules: { + toolbar: { + container: [ + [{ header: [1, 2, 3, 4, false] }], + ['bold', 'italic', 'underline', 'strike'], + ['blockquote', 'code-block'], + [{ list: 'ordered' }, { list: 'bullet' }], + ['link', 'image'], + ['clean'], + ], + handlers: { + image: handleImageUpload, + }, }, }, - }, - placeholder: placeholder, - }) - new QuillMarkdown(editorRef.current, {}) - editorRef.current.root.innerHTML = value - editorRef.current.on('text-change', () => { - if (onChange) { - onChange(editorRef.current.root.innerHTML) + placeholder: placeholder, + }) + new QuillMarkdown(editorRef.current, {}) + editorRef.current.root.innerHTML = value + editorRef.current.on('text-change', () => { + if (onChange) { + onChange(editorRef.current.root.innerHTML) + } + }) + } + // If switching to read-only mode, disable Quill instance + if (editorRef.current && !isEditable) { + // editorRef.current.disable() + editorRef.current.readOnly = true + + // If switching back to editable, enable Quill + if (editorRef.current && isEditable) { + // editorRef.current.enable() + editorRef.current.readOnly = false } - }) - } - // If switching to read-only mode, disable Quill instance - if (editorRef.current && !isEditable) { - // editorRef.current.disable() - editorRef.current.readOnly = true + } + }, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array - // If switching back to editable, enable Quill + useEffect(() => { if (editorRef.current && isEditable) { - // editorRef.current.enable() - editorRef.current.readOnly = false + if (editorRef.current.root.innerHTML !== value) { + editorRef.current.root.innerHTML = value || '' + } } - } - }, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array + }, [value, isEditable]) - useEffect(() => { - if (editorRef.current && isEditable) { - if (editorRef.current.root.innerHTML !== value) { - editorRef.current.root.innerHTML = value || '' - } + if (!isEditable) { + // Display-only mode: render HTML + return ( +
+ ) } - }, [value, isEditable]) - if (!isEditable) { - // Display-only mode: render HTML return ( -
+
+
+
) - } + }, +) - return ( -
-
-
- ) -} +RichTextEditor.displayName = 'RichTextEditor' export default RichTextEditor diff --git a/src/views/components/SubTask.jsx b/src/views/components/SubTask.jsx index 9bf13ec..7328944 100644 --- a/src/views/components/SubTask.jsx +++ b/src/views/components/SubTask.jsx @@ -184,8 +184,8 @@ function SortableItem({ value={editedText} onChange={e => setEditedText(e.target.value)} onBlur={handleSave} - onKeyPress={e => { - if (e.key === 'Enter') { + onKeyDown={e => { + if (!(e.metaKey || e.ctrlKey) && e.key === 'Enter') { handleSave() } }} @@ -308,6 +308,7 @@ const SubTasks = ({ tasks = [], setTasks, performers, + shouldFocus = false, }) => { const [newTask, setNewTask] = useState('') const { data: userProfile } = useUserProfile() @@ -501,6 +502,7 @@ const SubTasks = ({ {editMode && ( setNewTask(e.target.value)}