diff --git a/src/App.jsx b/src/App.jsx index 314097a..2587f7c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,20 +1,16 @@ import NavBar from '@/views/components/NavBar' import { Button, Typography, useColorScheme } from '@mui/joy' import Tracker from '@openreplay/tracker' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' import { Outlet, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' -import { useResource } from './queries/ResourceQueries' 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' + const add = className => { document.getElementById('root').classList.add(className) } @@ -22,9 +18,9 @@ const add = className => { const remove = className => { document.getElementById('root').classList.remove(className) } + // TODO: Update the interval to at 60 minutes const intervalMS = 5 * 60 * 1000 // 5 minutes -const queryClient = new QueryClient({}) const AppContent = () => { const { showNotification } = useNotification() @@ -85,14 +81,13 @@ const AppContent = () => { } function App() { - const resource = useResource() const navigate = useNavigate() startApiManager(navigate) startOpenReplay() const { mode, systemMode } = useColorScheme() - const setThemeClass = () => { + const setThemeClass = useCallback(() => { const value = JSON.parse(localStorage.getItem('themeMode')) || mode if (value === 'system') { @@ -107,11 +102,11 @@ function App() { } return remove('dark') - } + }, [mode, systemMode]) useEffect(() => { setThemeClass() - }, [mode, systemMode]) + }, [setThemeClass]) useEffect(() => { registerCapacitorListeners() @@ -121,12 +116,9 @@ function App() {
- - - - - - + + +
) } @@ -139,7 +131,6 @@ const startOpenReplay = () => { tracker.start() } -export default App const startApiManager = navigate => { apiManager.init() @@ -147,3 +138,5 @@ const startApiManager = navigate => { navigate('/login') }) } + +export default App diff --git a/src/components/common/FadeModal.jsx b/src/components/common/FadeModal.jsx new file mode 100644 index 0000000..745cada --- /dev/null +++ b/src/components/common/FadeModal.jsx @@ -0,0 +1,82 @@ +import { Modal, ModalDialog, ModalOverflow } from '@mui/joy' + +/** + * FadeModal component with consistent fade-in/out animations + * Can be used as a drop-in replacement for Joy UI's Modal component + */ +const FadeModal = ({ + open, + onClose, + children, + size = 'md', + fullWidth = false, + backdropBlur = true, + ...props +}) => { + return ( + + + *': { + opacity: 0, + animation: open + ? 'contentFadeIn 0.35s forwards' + : 'contentFadeOut 0.2s forwards', + }, + // Stagger child animations + '& > *:nth-of-type(1)': { animationDelay: '0.05s' }, + '& > *:nth-of-type(2)': { animationDelay: '0.1s' }, + '& > *:nth-of-type(3)': { animationDelay: '0.15s' }, + '& > *:nth-of-type(4)': { animationDelay: '0.2s' }, + '& > *:nth-of-type(5)': { animationDelay: '0.25s' }, + '@keyframes contentFadeIn': { + to: { opacity: 1 }, + }, + '@keyframes contentFadeOut': { + to: { opacity: 0 }, + }, + }} + > + {children} + + + + ) +} + +export default FadeModal diff --git a/src/components/common/KeyboardShortcutHint.jsx b/src/components/common/KeyboardShortcutHint.jsx new file mode 100644 index 0000000..9fcaef9 --- /dev/null +++ b/src/components/common/KeyboardShortcutHint.jsx @@ -0,0 +1,71 @@ +import { Chip } from '@mui/joy' +import PropTypes from 'prop-types' + +/** + * A component that displays keyboard shortcut hints as small chips + * Only visible on non-mobile devices + * Supports platform-specific shortcuts (Cmd on Mac, Ctrl on Windows) and Shift key + */ +function KeyboardShortcutHint({ + shortcut, + show = true, + withCmd = true, + withShift = false, + sx = {}, + ...props +}) { + if (!show) return null + + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 + const modifierKey = isMac ? '⌘' : 'Ctrl' + + // Build the shortcut display string + let displayShortcut = '' + if (withCmd) { + displayShortcut += modifierKey + } + if (withShift) { + displayShortcut += (displayShortcut ? ' + ' : '') + 'Shift' + } + if (shortcut) { + displayShortcut += (displayShortcut ? ' + ' : '') + shortcut + } + + return ( + + {displayShortcut} + + ) +} + +KeyboardShortcutHint.propTypes = { + shortcut: PropTypes.string.isRequired, + show: PropTypes.bool, + withCmd: PropTypes.bool, + withShift: PropTypes.bool, + sx: PropTypes.object, +} + +export default KeyboardShortcutHint 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 4e6fcdd..c9aa258 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,16 @@ +import { QueryClient } from '@tanstack/react-query' import React from 'react' import ReactDOM from 'react-dom/client' +import App from './App.jsx' import Contexts from './contexts/Contexts.jsx' import './index.css' +const queryClient = new QueryClient({}) + ReactDOM.createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index bdf9c71..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) @@ -178,7 +178,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => { export const useChoreDetails = choreId => { return useQuery({ - queryKey: ['chore', choreId], + queryKey: ['choreDetails', choreId], queryFn: async () => { var onlineChore = null 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/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 2ffcdaf..36e1655 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -14,6 +14,7 @@ import { Sheet, Typography, } from '@mui/joy' +import { useQueryClient } from '@tanstack/react-query' import Cookies from 'js-cookie' import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' @@ -27,8 +28,8 @@ import { apiManager, isTokenValid } from '../../utils/TokenManager' import MFAVerificationModal from './MFAVerificationModal' const LoginView = () => { - // Only fetch user profile if token is valid to prevent unnecessary queries - // const { data: userProfileData } = useUserProfile() + // Use React Query client directly to invalidate the user profile query + const queryClient = useQueryClient() const [userProfile, setUserProfile] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -78,11 +79,19 @@ const LoginView = () => { // Normal login without MFA localStorage.setItem('ca_token', data.token) localStorage.setItem('ca_expiration', data.expire) + + // Refetch user profile after successful login + queryClient.refetchQueries(['userProfile']) + const redirectUrl = Cookies.get('ca_redirect') - if (redirectUrl) { + + if (redirectUrl && redirectUrl !== '/') { + console.log('Redirecting to', redirectUrl) + Cookies.remove('ca_redirect') Navigate(redirectUrl) } else { + Cookies.remove('ca_redirect') Navigate('/my/chores') } }) @@ -143,6 +152,9 @@ const LoginView = () => { localStorage.setItem('ca_token', data.token) localStorage.setItem('ca_expiration', data.expire) + // Refetch user profile after successful OAuth login + queryClient.invalidateQueries(['userProfile']) + const redirectUrl = Cookies.get('ca_redirect') if (redirectUrl) { Cookies.remove('ca_redirect') @@ -161,17 +173,17 @@ const LoginView = () => { }) } const getUserProfileAndNavigateToHome = () => { - // Refetch user profile after login - // refetchUserProfile().then(() => { - // // check if redirect url is set in cookie: - const redirectUrl = Cookies.get('ca_redirect') - if (redirectUrl) { - Cookies.remove('ca_redirect') - Navigate(redirectUrl) - } else { - Navigate('/my/chores') - } - // }) + // Refetch user profile after login using React Query + queryClient.invalidateQueries(['userProfile']).then(() => { + // check if redirect url is set in cookie: + const redirectUrl = Cookies.get('ca_redirect') + if (redirectUrl) { + Cookies.remove('ca_redirect') + Navigate(redirectUrl) + } else { + Navigate('/my/chores') + } + }) } const handleMFASuccess = data => { @@ -180,6 +192,9 @@ const LoginView = () => { setMfaModalOpen(false) setMfaSessionToken('') + // Refetch user profile after MFA success + queryClient.invalidateQueries(['userProfile']) + const redirectUrl = Cookies.get('ca_redirect') if (redirectUrl) { Cookies.remove('ca_redirect') diff --git a/src/views/Authorization/MFAVerificationModal.jsx b/src/views/Authorization/MFAVerificationModal.jsx index 5f76e98..991df11 100644 --- a/src/views/Authorization/MFAVerificationModal.jsx +++ b/src/views/Authorization/MFAVerificationModal.jsx @@ -5,13 +5,12 @@ import { Button, Input, Link, - Modal, ModalClose, - ModalDialog, Stack, Typography, } from '@mui/joy' import { useState } from 'react' +import FadeModal from '../../components/common/FadeModal' import { VerifyMFA } from '../../utils/Fetcher' const MFAVerificationModal = ({ @@ -70,90 +69,88 @@ const MFAVerificationModal = ({ } return ( - - - + + - - - - Two-Factor Authentication - - - Enter the verification code from your authenticator app + + + + Two-Factor Authentication + + + Enter the verification code from your authenticator app + + + + + + + {isBackupCode ? 'Backup Code' : 'Verification Code'} + setVerificationCode(e.target.value)} + onKeyPress={handleKeyPress} + sx={{ + textAlign: 'center', + fontSize: '1.1em', + letterSpacing: isBackupCode ? 'normal' : '0.1em', + }} + slotProps={{ + input: { + maxLength: isBackupCode ? 50 : 6, + pattern: isBackupCode ? undefined : '[0-9]*', + }, + }} + startDecorator={} + autoFocus + /> - - - - {isBackupCode ? 'Backup Code' : 'Verification Code'} - - setVerificationCode(e.target.value)} - onKeyPress={handleKeyPress} - sx={{ - textAlign: 'center', - fontSize: '1.1em', - letterSpacing: isBackupCode ? 'normal' : '0.1em', - }} - slotProps={{ - input: { - maxLength: isBackupCode ? 50 : 6, - pattern: isBackupCode ? undefined : '[0-9]*', - }, - }} - startDecorator={} - autoFocus - /> - - - {error && ( - - {error} - - )} - - - - - { - setIsBackupCode(!isBackupCode) - setVerificationCode('') - setError('') - }} - sx={{ fontSize: 'sm' }} - > - {isBackupCode - ? 'Use authenticator app instead' - : "Can't access your authenticator? Use a backup code"} - - - - - - Having trouble? Make sure your authenticator app is synced and try - again. Each backup code can only be used once. - + {error && ( + + {error} - - - + )} + + + + + { + setIsBackupCode(!isBackupCode) + setVerificationCode('') + setError('') + }} + sx={{ fontSize: 'sm' }} + > + {isBackupCode + ? 'Use authenticator app instead' + : "Can't access your authenticator? Use a backup code"} + + + + + + Having trouble? Make sure your authenticator app is synced and try + again. Each backup code can only be used once. + + + + ) } 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 52dda97..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', }} @@ -527,6 +641,7 @@ const ChoreView = () => { > { setChore({ @@ -550,7 +665,7 @@ const ChoreView = () => { variant='soft' > - Complete the task + Completion options @@ -573,7 +688,7 @@ const ChoreView = () => { alignItems: 'center', }} > - Add Additional Notes + Add a note } /> @@ -583,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() === '') { @@ -626,7 +741,7 @@ const ChoreView = () => { alignItems: 'center', }} > - Specify completion date + Set custom completion time } /> @@ -645,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 new file mode 100644 index 0000000..ae2a195 --- /dev/null +++ b/src/views/ChoreEdit/TimePassedCard.jsx @@ -0,0 +1,203 @@ +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, handleAction, onShowDetails }) => { + const [time, setTime] = useState(0) + const [shouldAnimate, setShouldAnimate] = useState(false) + const [prevStatus, setPrevStatus] = useState(null) // Initialize as null + const intervalRef = useRef(null) + + // Track status changes to trigger animation + useEffect(() => { + // Only trigger animation if we have a previous status and it changed from 0 to 1 + if (prevStatus !== null && prevStatus === 0 && chore.status === 1) { + setShouldAnimate(true) + // Reset animation after it completes + const timer = setTimeout(() => setShouldAnimate(false), 300) + return () => clearTimeout(timer) + } + setPrevStatus(chore.status) + }, [chore.status, prevStatus]) + + // Single effect to handle both time calculation and timer + useEffect(() => { + // Calculate current time based on chore data + const calculateCurrentTime = () => { + if (chore.timerUpdatedAt && chore.status === 1) { + // Active session: base duration + time since start + 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) + + // Handle timer based on status + if (chore.status === 1) { + // Active: start interval timer + intervalRef.current = setInterval(() => { + const newTime = calculateCurrentTime() + setTime(newTime) + }, 1000) + } + + // Cleanup function + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [chore.status, chore.duration, chore.timerUpdatedAt]) + + 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')}` + } + + return ( + + onShowDetails?.()} + > + {formatTime(time)} + + + {/* Status and info section */} + + {/* 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 */} + {chore.startTime && ( + } + > + {new Date(chore.startTime).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} + + {/* Current session start time */} + {chore.timerUpdatedAt !== chore.startTime && ( + } + > + {new Date(chore.timerUpdatedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} + + )} + + {/* Chips for paused state */} + {chore.status === 2 && ( + } + > + {new Date(chore.timerUpdatedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} + + + ) +} + +export default TimePassedCard 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/CompactChoreCard.jsx b/src/views/Chores/CompactChoreCard.jsx index 016068d..24e130e 100644 --- a/src/views/Chores/CompactChoreCard.jsx +++ b/src/views/Chores/CompactChoreCard.jsx @@ -1,7 +1,12 @@ import { CancelScheduleSend, Check, + Delete, + Edit, + Pause, + PlayArrow, Repeat, + Schedule, TimesOneMobiledata, Webhook, } from '@mui/icons-material' @@ -29,6 +34,8 @@ import { import { DeleteChore, MarkChoreComplete, + PauseChore, + StartChore, UpdateChoreAssignee, UpdateDueDate, } from '../../utils/Fetcher' @@ -73,6 +80,196 @@ const CompactChoreCard = ({ 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() + }, []) + + // 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 + const handleMouseEnter = () => { + if (isMultiSelectMode || viewOnly || isSwipeRevealed || isTouchDevice) + return + const timer = setTimeout(() => { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + setHoverTimer(null) + }, 1500) + 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) + 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 + if (isSwipeRevealed) { + resetSwipe() + } + } + + // Clean up timer on unmount + React.useEffect(() => { + return () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + } + } + }, [hoverTimer]) + // All the existing handler methods (same as original ChoreCard) const handleDelete = () => { setConfirmModelConfig({ @@ -385,337 +582,519 @@ const CompactChoreCard = ({ return TASK_COLOR.NO_PRIORITY } } + const handleChorePause = () => { + PauseChore(chore.id).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + onChoreUpdate(newChore, 'paused') + }) + } + }) + } + const handleChoreStart = () => { + StartChore(chore.id).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + ...data.res, + } + onChoreUpdate(newChore, 'started') + }) + } + }) + } return ( + {/* Action buttons underneath (revealed on swipe) */} + { - if (isMultiSelectMode) { - onSelectionToggle() - } else { - navigate(`/chores/${chore.id}`) - } - }} - > - {/* Priority bar clickable area */} - {chore.priority > 0 && ( - + { + e.stopPropagation() + resetSwipe() + + if (chore.status === 0 || chore.status === 2) { + handleChoreStart() + } else { + // handleChorePause() + handleTaskCompletion() + } + }} sx={{ + width: 40, + height: 40, + mx: 1, + // bgcolor: 'success.100', + // color: 'success.600', + // '&:hover': { + // bgcolor: 'success.200', + // }, + }} + > + {chore.status !== 1 ? ( + + ) : ( + + )} + + + { + e.stopPropagation() + resetSwipe() + setIsChangeDueDateModalOpen(true) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + // bgcolor: 'warning.100', + // color: 'warning.600', + // '&:hover': { + // bgcolor: 'warning.200', + // }, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + navigate(`/chores/${chore.id}/edit`) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + // bgcolor: 'neutral.100', + // color: 'neutral.600', + // '&:hover': { + // bgcolor: 'neutral.200', + // }, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + handleDelete() + }} + sx={{ + width: 40, + height: 40, + mx: 1, + }} + > + + + + + {/* Main card content */} + { - e.stopPropagation() - onChipClick({ priority: chore.priority }) - }} - /> - )} - - {/* Animated transition container for Complete Button / Multi-select checkbox */} - { + if (isSwipeRevealed) { + resetSwipe() + return + } + if (isMultiSelectMode) { + onSelectionToggle() + } else { + navigate(`/chores/${chore.id}`) + } + }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + // onMouseEnter={handleMouseEnter} > - {/* Complete Button */} + {/* Priority bar clickable area */} + {chore.priority > 0 && ( + { + e.stopPropagation() + onChipClick({ priority: chore.priority }) + }} + /> + )} + + {/* Animated transition container for Complete Button / Multi-select checkbox */} + {/* Complete Button */} + + { + e.stopPropagation() + if (chore.status === 0) { + handleTaskCompletion() + } else if (chore.status === 1) { + handleChorePause() + } else { + handleChoreStart() + } + }} + disabled={isPendingCompletion || notInCompletionWindow(chore)} + sx={{ + width: 32, + height: 32, + borderRadius: '50%', + 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 ? ( + + ) : ( + + )} + + + + {/* Multi-select Checkbox */} + + e.stopPropagation()} + /> + + + + {/* Content - Center */} + + {/* Line 1: Name + Due Date */} + + {/* Chore Name */} + + {chore.name} + + + {/* Due Date - Inline with name */} + + {getDueDateText(chore.nextDueDate)} + + + + {/* Line 2: Metadata */} + + {getFrequencyIcon(chore)} + + {formatMetadata()} + + + {/* Labels - Priority chip removed, now shown as vertical bar */} + {chore.labelsV2?.map(l => ( +
{ + e.stopPropagation() + onChipClick({ label: l }) + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + onChipClick({ label: l }) + } + }} + style={{ + cursor: 'pointer', + padding: 0, + margin: 0, + display: 'flex', + alignItems: 'center', + }} + key={`compact-chorecard-${chore.id}-label-${l.id}`} + > + + {l?.name} + +
+ ))} +
+
+ + {/* Right side - Action Menu with animation */} + - { - e.stopPropagation() - handleTaskCompletion() - }} - disabled={isPendingCompletion || notInCompletionWindow(chore)} + setIsCompleteWithNoteModalOpen(true)} + onCompleteWithPastDate={() => + setIsCompleteWithPastDateModalOpen(true) + } + onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)} + onChangeDueDate={() => setIsChangeDueDateModalOpen(true)} + onWriteNFC={() => setIsNFCModalOpen(true)} + onDelete={handleDelete} + onMouseEnter={handleMouseEnter} + // onMouseLeave={handleMouseLeave} sx={{ width: 32, height: 32, - borderRadius: '50%', - transition: 'all 0.2s ease', - '&:hover': { - transform: 'scale(1.05)', - }, - - '&:active': { - transform: 'scale(0.95)', - }, - '&:disabled': { - opacity: 0.5, - transform: 'none', - }, - }} - > - {isPendingCompletion ? ( - - ) : ( - - )} - - - - {/* Multi-select Checkbox */} - - e.stopPropagation()} + onOpen={() => { + handleMouseLeave() + }} />
- - {/* Content - Center */} - - {/* Line 1: Name + Due Date */} - - {/* Chore Name */} - - {chore.name} - - - {/* Due Date - Inline with name */} - - {getDueDateText(chore.nextDueDate)} - - - - {/* Line 2: Metadata */} - - {getFrequencyIcon(chore)} - - {formatMetadata()} - - - {/* Labels - Priority chip removed, now shown as vertical bar */} - {chore.labelsV2?.map(l => ( -
{ - e.stopPropagation() - onChipClick({ label: l }) - }} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation() - onChipClick({ label: l }) - } - }} - style={{ - cursor: 'pointer', - padding: 0, - margin: 0, - display: 'flex', - alignItems: 'center', - }} - key={`compact-chorecard-${chore.id}-label-${l.id}`} - > - - {l?.name} - -
- ))} -
-
- - {/* Right side - Action Menu with animation */} - - setIsCompleteWithNoteModalOpen(true)} - onCompleteWithPastDate={() => - setIsCompleteWithPastDateModalOpen(true) - } - onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)} - onChangeDueDate={() => setIsChangeDueDateModalOpen(true)} - onWriteNFC={() => setIsNFCModalOpen(true)} - onDelete={handleDelete} - sx={{ - width: 32, - height: 32, - color: 'text.tertiary', - flexShrink: 0, - '&:hover': { - color: 'text.secondary', - bgcolor: 'background.level1', - }, - }} - /> -
{/* All modals (same as original) */} diff --git a/src/views/Chores/LocalNotificationScheduler.js b/src/views/Chores/LocalNotificationScheduler.js index 3cb2c77..12e6b89 100644 --- a/src/views/Chores/LocalNotificationScheduler.js +++ b/src/views/Chores/LocalNotificationScheduler.js @@ -1,128 +1,216 @@ -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) => { + const hash = murmurhash.v3(`${choreId}-${template.value}-${template.unit}`) + // Use Math.abs() with modulo to ensure positive ID within Java int range + // This guarantees the ID is always positive and within 1 to 2^31-1 + return Math.abs(hash) % 2147483647 +} + +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: + 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' + } else if (template.value === 0) { + return 'due' + } else { + return 'overdue' + } + } + + 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 = [] + + 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, + }) + return 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) @@ -102,40 +103,47 @@ const MyChores = () => { data: choresData, isLoading: choresLoading, refetch: refetchChores, - } = useChores() + } = useChores(false) const { data: membersData, isLoading: membersLoading } = useCircleMembers() // Multi-select state const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) const [selectedChores, setSelectedChores] = useState(new Set()) const [confirmModelConfig, setConfirmModelConfig] = useState({}) - + const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) useEffect(() => { - if (!choresLoading && !membersLoading && userProfile) { - setPerformers(membersData.res) - const sortedChores = choresData.res.sort(ChoreSorter) - setChores(sortedChores) - setFilteredChores(sortedChores) - const sections = ChoresGrouper( - selectedChoreSection, - sortedChores, - ChoreFilters(userProfile)[selectedChoreFilter], - ) - setChoreSections(sections) - if (localStorage.getItem('openChoreSections') === null) { - setSelectedChoreSectionWithCache(selectedChoreSection) - setOpenChoreSections( - Object.keys(sections).reduce((acc, key) => { - acc[key] = true - return acc - }, {}), + ;(async () => { + if (!choresLoading && !membersLoading && userProfile) { + setPerformers(membersData.res) + const sortedChores = choresData.res.sort(ChoreSorter) + setChores(sortedChores) + setFilteredChores(sortedChores) + const sections = ChoresGrouper( + selectedChoreSection, + sortedChores, + ChoreFilters(userProfile)[selectedChoreFilter], ) - } + setChoreSections(sections) + if (localStorage.getItem('openChoreSections') === null) { + setSelectedChoreSectionWithCache(selectedChoreSection) + setOpenChoreSections( + Object.keys(sections).reduce((acc, key) => { + acc[key] = true + return acc + }, {}), + ) + } - if (canScheduleNotification()) { - scheduleChoreNotification(choresData.res, userProfile, membersData.res) + if (await canScheduleNotification()) { + console.log('Scheduling chore notifications...') + scheduleChoreNotification( + choresData.res, + userProfile, + membersData.res, + ) + } } - } + })() }, [ membersLoading, choresLoading, @@ -164,20 +172,45 @@ 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) + } + // Ctrl/Cmd + K to open task modal if ((event.ctrlKey || event.metaKey) && event.key === 'k') { event.preventDefault() 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') { event.preventDefault() searchInputRef.current?.focus() return + // Ctrl/Cmd + X to close search input + } else if ((event.ctrlKey || event.metaKey) && event.key === 'x') { + event.preventDefault() + if (searchTerm?.length > 0) { + handleSearchClose() + } } - // Ctrl/Cmd + S Toggle Multi-select mode else if ((event.ctrlKey || event.metaKey) && event.key === 's') { event.preventDefault() @@ -297,14 +330,117 @@ 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 => { + if (!event.ctrlKey && !event.metaKey) { + setShowKeyboardShortcuts(false) } } document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keyup', handleKeyUp) return () => { document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keyup', handleKeyUp) } - }, [isMultiSelectMode, selectedChores.size]) + }, [isMultiSelectMode, selectedChores.size, addTaskModalOpen]) const setSelectedChoreSectionWithCache = value => { setSelectedChoreSection(value) localStorage.setItem('selectedChoreSection', value) @@ -471,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', @@ -506,7 +655,7 @@ const MyChores = () => { const fuse = new Fuse( chores.map(c => ({ ...c, - raw_label: c.labelsV2.map(c => c.name).join(' '), + raw_label: c.labelsV2?.map(c => c.name).join(' '), })), searchOptions, ) @@ -526,6 +675,12 @@ const MyChores = () => { setSearchTerm(term) setFilteredChores(fuse.search(term).map(result => result.item)) } + const handleSearchClose = () => { + setSearchTerm('') + setFilteredChores(chores) + // remove the focus from the search input: + setSearchInputFocus(0) + } // Multi-select helper functions const toggleMultiSelectMode = () => { @@ -744,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], + ), ) } @@ -870,15 +1034,21 @@ const MyChores = () => { padding: 1, }} onChange={handleSearchChange} + startDecorator={ + + } endDecorator={ - searchTerm && ( - { - setSearchTerm('') - setFilteredChores(chores) - }} - /> - ) + + {searchTerm && ( + <> + + + + )} + } /> @@ -963,24 +1133,36 @@ const MyChores = () => { {/* Multi-select Toggle Button */} - - {isMultiSelectMode ? : } - + + + {isMultiSelectMode ? : } + + + {/* Search Filter with animation */} @@ -1201,9 +1383,22 @@ const MyChores = () => { sx={{ minWidth: 'auto', '--Button-paddingInline': '0.75rem', + position: 'relative', }} + title='Select all visible tasks (Ctrl+A)' > All + {showKeyboardShortcuts && ( + + )}
@@ -1251,9 +1460,22 @@ const MyChores = () => { disabled={selectedChores.size === 0} sx={{ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, + position: 'relative', }} + title='Complete selected tasks (Enter)' > Complete + {showKeyboardShortcuts && selectedChores.size > 0 && ( + + )} {/* @@ -1473,6 +1735,12 @@ const MyChores = () => { variant='outlined' color='neutral' startDecorator={} + endDecorator={ + + } > Show Archived @@ -1522,12 +1790,24 @@ const MyChores = () => { width: 50, height: 50, zIndex: 101, + position: 'relative', }} onClick={() => { Navigate(`/chores/create`) }} + title='Create new chore (Cmd+C)' > + { }} /> + +
{addTaskModalOpen && ( diff --git a/src/views/Chores/NotificationAccessSnackbar.jsx b/src/views/Chores/NotificationAccessSnackbar.jsx index c042806..15304be 100644 --- a/src/views/Chores/NotificationAccessSnackbar.jsx +++ b/src/views/Chores/NotificationAccessSnackbar.jsx @@ -1,30 +1,36 @@ import { Capacitor } from '@capacitor/core' import { LocalNotifications } from '@capacitor/local-notifications' import { Preferences } from '@capacitor/preferences' -import { Button, Stack, Typography } from '@mui/joy' +import { Button, Snackbar, Stack, Typography } from '@mui/joy' import { useEffect, useState } from 'react' const NotificationAccessSnackbar = () => { 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 5203850..cd2e1fe 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -1,59 +1,91 @@ -import { CalendarViewDay, Check, Timelapse } from '@mui/icons-material' +import { + AccessTime, + CalendarMonth, + Check, + CheckCircle, + EventNote, + Person, + Redo, + Timelapse, + Toll, +} from '@mui/icons-material' import { Avatar, Box, Chip, + Grid, ListDivider, ListItem, ListItemContent, - ListItemDecorator, Typography, } from '@mui/joy' import moment from 'moment' -export const getCompletedChip = historyEntry => { - var text = 'No Due Date' - var color = 'info' - var icon = - // if completed few hours +-6 hours - if ( - historyEntry.dueDate && - historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 && - historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6 - ) { - text = 'On Time' - color = 'success' - icon = - } else if ( - historyEntry.dueDate && - historyEntry.performedAt < historyEntry.dueDate - ) { - text = 'On Time' - color = 'success' - icon = +const getCompletedChip = historyEntry => { + if (historyEntry.status === 0) { + return null + } + if (!historyEntry.dueDate) { + return null + // } + // > + // No Due Date + // } - // if completed after due date then it's late - else if ( - historyEntry.dueDate && - historyEntry.performedAt > historyEntry.dueDate - ) { - text = 'Late' - color = 'warning' - icon = + 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) { + return ( + } + > + On Time + + ) + } else if (performedAt.isBefore(dueDate)) { + return ( + }> + Early + + ) } else { - text = 'No Due Date' - color = 'neutral' - icon = + return ( + } + > + Late + + ) } - - return ( - - {text} - - ) } +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 + */ const HistoryCard = ({ allHistory, performers, @@ -61,7 +93,10 @@ const HistoryCard = ({ index, onClick, }) => { - function formatTimeDifference(startDate, endDate) { + const performer = performers.find(p => p.userId === historyEntry.completedBy) + const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo) + + const formatTimeDifference = (startDate, endDate) => { const diffInMinutes = moment(startDate).diff(endDate, 'minutes') let timeValue = diffInMinutes let unit = 'minute' @@ -81,86 +116,203 @@ const HistoryCard = ({ return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}` } + const getStatusAvatar = () => { + const statusMap = { + 0: { icon: , color: 'primary' }, // Started + 1: { icon: , color: 'success' }, // Completed + 2: { icon: , color: 'warning' }, // Skipped + } + + const config = statusMap[historyEntry.status] || statusMap[1] + return ( + + {config.icon} + + ) + } + return ( <> - - {' '} - {/* Adjusted spacing and alignment */} - - - {performers - .find(p => p.userId === historyEntry.completedBy) - ?.displayName?.charAt(0) || '?'} - - - - {' '} - {/* Removed vertical margin */} - - - {historyEntry.performedAt - ? moment(historyEntry.performedAt).format( - 'ddd MM/DD/yyyy HH:mm', - ) - : 'Skipped'} - - {getCompletedChip(historyEntry)} - - - - { - performers.find(p => p.userId === historyEntry.completedBy) - ?.displayName + {' '} - completed - {historyEntry.completedBy !== historyEntry.assignedTo && ( - <> - {', '} - assigned to{' '} - - { - performers.find(p => p.userId === historyEntry.assignedTo) - ?.displayName - } + : {}, + borderRadius: 'sm', + transition: 'background-color 0.2s', + }} + > + + + {/* First Row/Column: Status and Time Info */} + + + {getStatusAvatar()} + + + {historyEntry.status === 0 + ? 'In Progress' + : historyEntry.status === 1 + ? 'Completed' + : 'Skipped'} + + + }> + {moment( + historyEntry.performedAt || historyEntry.updatedAt, + ).format('MMM DD, h:mm A')} - - )} - - {historyEntry.dueDate && ( - - Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')} - - )} - {historyEntry.notes && ( - - Note: {historyEntry.notes} - - )} + + + {getCompletedChip(historyEntry)} + +
+ + + {/* Second Row/Column: Completion Status (right side on desktop) */} + + + {historyEntry.dueDate && ( + }> + {moment(historyEntry.dueDate).format('MMM DD h:mm A')} + + )} + + + + {/* Third Row: Performer and Assignment Info */} + + + }> + {performer?.displayName || 'Unknown'} + + + {historyEntry.completedBy !== historyEntry.assignedTo && + assignedTo && ( + <> + + → + + } + > + {assignedTo.displayName} + + + )} + + {historyEntry.notes && ( + } + sx={{ maxWidth: '120px', overflow: 'hidden' }} + > + 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' : ''} + + )} + + + - {index < allHistory.length - 1 && ( - <> - - {/* time between two completion: */} - {index < allHistory.length - 1 && - allHistory[index + 1].performedAt && ( - - {formatTimeDifference( - historyEntry.performedAt, - allHistory[index + 1].performedAt, - )}{' '} - before - - )} - - + + {/* Compact Divider with Time Difference */} + {index < allHistory.length - 1 && allHistory[index + 1].performedAt && ( + + + {formatTimeDifference( + historyEntry.performedAt || historyEntry.updatedAt, + allHistory[index + 1].performedAt, + )}{' '} + before + + )} ) diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 81c9b47..52d1441 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -1,27 +1,472 @@ import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { + Avatar, Box, - Button, Chip, CircularProgress, Container, IconButton, Typography, } from '@mui/joy' -import { useEffect, useState } from 'react' +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 { 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, currentUserId }) => { + // Helper function to get color name from hex value + const getColorName = hexValue => { + const colorObj = LABEL_COLORS.find( + color => color.value.toLowerCase() === hexValue.toLowerCase(), + ) + 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) + const [isSwipeRevealed, setIsSwipeRevealed] = useState(false) + const [hoverTimer, setHoverTimer] = useState(null) + const swipeThreshold = 80 + const maxSwipeDistance = 160 + const dragStartX = useRef(0) + const cardRef = useRef(null) + + // Swipe gesture handlers + const handleTouchStart = e => { + dragStartX.current = e.touches[0].clientX + setIsDragging(true) + } + + const handleTouchMove = e => { + if (!isDragging) return + + const currentX = e.touches[0].clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleTouchEnd = () => { + if (!isDragging) return + setIsDragging(false) + + if (isSwipeRevealed) { + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const handleMouseDown = e => { + dragStartX.current = e.clientX + setIsDragging(true) + } + + const handleMouseMove = e => { + if (!isDragging) return + + const currentX = e.clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleMouseUp = () => { + if (!isDragging) return + setIsDragging(false) + + if (isSwipeRevealed) { + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + 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 drag area + const handleMouseEnter = () => { + if (isSwipeRevealed) return + const timer = setTimeout(() => { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + setHoverTimer(null) + }, 800) // Shorter delay for drag area + setHoverTimer(timer) + } + + const handleMouseLeave = () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + // 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 () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + } + } + }, [hoverTimer]) + + return ( + + { + // Only clear timers, don't auto-hide + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + }} + > + {/* Action buttons underneath (revealed on swipe) */} + + { + e.stopPropagation() + resetSwipe() + onEditClick(label) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + bgcolor: 'primary.100', + color: 'primary.600', + '&:hover': { + bgcolor: 'primary.200', + }, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + onDeleteClick(label.id) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + bgcolor: 'danger.100', + color: 'danger.600', + '&:hover': { + bgcolor: 'danger.200', + }, + }} + > + + + + + {/* Main card content */} + { + if (isSwipeRevealed) { + resetSwipe() + return + } + // Optional: Navigate to label details or edit directly + onEditClick(label) + }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + > + {/* Right drag area - only triggers reveal on hover */} + + {/* Drag indicator dots */} + + {[...Array(3)].map((_, i) => ( + + ))} + + + {/* Color Avatar */} + + + + {label.name.charAt(0).toUpperCase()} + + + + + {/* Content - Center */} + + {/* Label Name */} + + {label.name} + + + {/* Color Info */} + + {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 + + )} + + + + + + ) +} + const LabelView = () => { const { data: labels, isLabelsLoading, isError } = useLabels() + const { data: userProfile } = useUserProfile() const [userLabels, setUserLabels] = useState([]) const [modalOpen, setModalOpen] = useState(false) @@ -61,7 +506,7 @@ const LabelView = () => { } const handleDeleteLabel = id => { - DeleteLabel(id).then(res => { + DeleteLabel(id).then(() => { const updatedLabels = userLabels.filter(label => label.id !== id) setUserLabels(updatedLabels) @@ -106,54 +551,41 @@ const LabelView = () => { } return ( - -
- {userLabels.map(label => ( -
+ + {userLabels.length === 0 && ( + - - {label.name} - - -
- - handleDeleteClicked(label.id)} - color='danger' - > - - -
-
+ + No labels available. Add a new label to get started. + + + )} + {userLabels.map(label => ( + ))} -
- - {userLabels.length === 0 && ( - - No labels available. Add a new label to get started. - - )} +
{modalOpen && ( - - - Edit History - - Due Date - { - setDueDate(e.target.value) - }} - /> - Completed Date - { - setCompletedDate(e.target.value) - }} - /> - Note - { - if (e.target.value.trim() === '') { - setNotes(null) - return - } - setNotes(e.target.value) - }} - size='md' - sx={{ - mb: 1, - }} - /> + + + Edit History + + Due Date + { + setDueDate(e.target.value) + }} + /> + Completed Date + { + setCompletedDate(e.target.value) + }} + /> + Note + { + if (e.target.value.trim() === '') { + setNotes(null) + return + } + setNotes(e.target.value) + }} + size='md' + sx={{ + mb: 1, + }} + /> - {/* 3 button save , cancel and delete */} - - - - - - { - if (isConfirm) { - config.onDelete(historyRecord.id) - } - setIsDeleteModalOpen(false) - }, - title: 'Delete History', - message: 'Are you sure you want to delete this history?', - confirmText: 'Delete', - cancelText: 'Cancel', + {/* 3 button save , cancel and delete */} + + + + + + { + if (isConfirm) { + config.onDelete(historyRecord.id) + } + setIsDeleteModalOpen(false) + }, + title: 'Delete History', + message: 'Are you sure you want to delete this history?', + confirmText: 'Delete', + cancelText: 'Cancel', + }} + /> + ) } export default EditHistoryModal diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx index 882522e..2e0b318 100644 --- a/src/views/Modals/Inputs/ConfirmationModal.jsx +++ b/src/views/Modals/Inputs/ConfirmationModal.jsx @@ -1,44 +1,116 @@ -import { Box, Button, Modal, ModalDialog, Typography } from '@mui/joy' -import React from 'react' +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 ( - - - - {config?.title} - + + + {config?.title} + - - {config?.message} - + + {config?.message} + - - - - - - + + + + + + ) } export default ConfirmationModal diff --git a/src/views/Modals/Inputs/CreateThingModal.jsx b/src/views/Modals/Inputs/CreateThingModal.jsx index 96b7954..a4863f7 100644 --- a/src/views/Modals/Inputs/CreateThingModal.jsx +++ b/src/views/Modals/Inputs/CreateThingModal.jsx @@ -4,14 +4,13 @@ import { FormControl, FormHelperText, Input, - Modal, - ModalDialog, Option, Select, Textarea, Typography, } from '@mui/joy' import { useEffect, useState } from 'react' +import FadeModal from '../../../components/common/FadeModal' function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { const [name, setName] = useState(currentThing?.name || '') @@ -59,87 +58,80 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { } return ( - - - {/* */} - - {currentThing?.id ? 'Edit' : 'Create'} Thing - + + + {currentThing?.id ? 'Edit' : 'Create'} Thing + + + Name +