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 = () => {
- }
+
- Mark as done
-
+ }
+ sx={{
+ flex: 4,
+ }}
+ >
+ Mark as done
+
-
+
+ {/* 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
+ />
+ ) : (
+ {
+ handleChoreStart()
+ }}
+ variant='soft'
+ color='success'
+ disabled={
+ chore.lastCompletedDate !== null &&
+ chore.frequencyType === 'once'
+ }
+ startDecorator={}
+ sx={{
+ flex: 1,
+ }}
+ >
+ Start
+
+ )}
{
+
)
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 */}
+
+
+ )
+}
+
+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
-
-
-
-
-
-
-
-
-
-
- setIsHelpOpen(false)}
- sx={{ minWidth: 120 }}
- >
- Got it!
-
-
-
-
+ {/* Interface shortcuts */}
+
+
+ Interface
+
+
+
+
+
+
+
+
+ setIsHelpOpen(false)}
+ sx={{ minWidth: 120 }}
+ >
+ Got it!
+
+
+
>
)
}
@@ -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 && (
+
+ )}
{
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
+ position: 'relative',
}}
+ title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
>
{selectedChores.size === 0 ? 'Close' : 'Clear'}
+ {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 && (
+
+ )}
{
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ position: 'relative',
}}
+ title='Skip selected tasks (/)'
>
Skip
+ {showKeyboardShortcuts && selectedChores.size > 0 && (
+
+ )}
{
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ position: 'relative',
}}
+ title='Archive selected tasks (X)'
>
Archive
+ {showKeyboardShortcuts && selectedChores.size > 0 && (
+
+ )}
{
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ position: 'relative',
}}
+ title='Delete selected tasks (Shift+X)'
>
Delete
+ {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}
-
-
-
- handleEditLabel(label)}
- startDecorator={}
- >
- Edit
-
- 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 */}
-
-
- config.onSave({
- id: historyRecord.id,
- performedAt: moment(completedDate).toISOString(),
- dueDate: moment(dueDate).toISOString(),
- notes,
- })
- }
- fullWidth
- sx={{ mr: 1 }}
- >
- Save
-
-
- Cancel
-
- {
- setIsDeleteModalOpen(true)
- }}
- variant='outlined'
- color='danger'
- >
- 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 */}
+
+
+ config.onSave({
+ id: historyRecord.id,
+ performedAt: moment(completedDate).toISOString(),
+ dueDate: moment(dueDate).toISOString(),
+ notes,
+ })
+ }
+ fullWidth
+ sx={{ mr: 1 }}
+ >
+ Save
+
+
+ Cancel
+
+ {
+ setIsDeleteModalOpen(true)
}}
- />
-
-
+ variant='outlined'
+ color='danger'
+ >
+ 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}
+
-
- {
- handleAction(true)
- }}
- fullWidth
- sx={{ mr: 1 }}
- color={config.color ? config.color : 'primary'}
- >
- {config?.confirmText}
-
- {
- handleAction(false)
- }}
- variant='outlined'
- >
- {config?.cancelText}
-
-
-
-
+
+ {
+ handleAction(true)
+ }}
+ fullWidth
+ color={config.color ? config.color : 'primary'}
+ endDecorator={
+
+ }
+ >
+ {config?.confirmText}
+
+
+ {
+ handleAction(false)
+ }}
+ variant='outlined'
+ endDecorator={
+
+ }
+ >
+ {config?.cancelText}
+
+
+
)
}
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
+
+
+ Type
+
+
+ {errors.type}
+
+ {type === 'text' && (
- Name
-
+ )}
+ {type === 'number' && (
- Type
-
+ )}
+ {type === 'boolean' && (
+
+ Value
+
-
- {errors.type}
- {type === 'text' && (
-
- Value
- setState(e.target.value)}
- sx={{ minWidth: 300 }}
- />
- {errors.state}
-
- )}
- {type === 'number' && (
-
- Value
- {
- setState(e.target.value)
- }}
- sx={{ minWidth: 300 }}
- />
-
- )}
- {type === 'boolean' && (
-
- Value
-
-
- )}
+ )}
-
-
- {currentThing?.id ? 'Update' : 'Create'}
-
-
- {currentThing?.id ? 'Cancel' : 'Close'}
-
-
-
-
+
+
+ {currentThing?.id ? 'Update' : 'Create'}
+
+
+ {currentThing?.id ? 'Cancel' : 'Close'}
+
+
+
)
}
export default CreateThingModal
diff --git a/src/views/Modals/Inputs/DateModal.jsx b/src/views/Modals/Inputs/DateModal.jsx
index 34319c3..27dbf6e 100644
--- a/src/views/Modals/Inputs/DateModal.jsx
+++ b/src/views/Modals/Inputs/DateModal.jsx
@@ -1,13 +1,6 @@
-import React, { useState } from 'react'
-import {
- Modal,
- Button,
- Input,
- ModalDialog,
- ModalClose,
- Box,
- Typography,
-} from '@mui/joy'
+import { Box, Button, Input, Typography } from '@mui/joy'
+import { useState } from 'react'
+import FadeModal from '../../../components/common/FadeModal'
function DateModal({ isOpen, onClose, onSave, current, title }) {
const [date, setDate] = useState(
@@ -20,26 +13,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
}
return (
-
-
- {/* */}
- {title}
- setDate(e.target.value)}
- />
-
-
- Save
-
-
- Cancel
-
-
-
-
+
+ {title}
+ setDate(e.target.value)}
+ />
+
+
+ Save
+
+
+ Cancel
+
+
+
)
}
export default DateModal
diff --git a/src/views/Modals/Inputs/EditThingState.jsx b/src/views/Modals/Inputs/EditThingState.jsx
index 26d333e..d520718 100644
--- a/src/views/Modals/Inputs/EditThingState.jsx
+++ b/src/views/Modals/Inputs/EditThingState.jsx
@@ -4,11 +4,10 @@ import {
FormControl,
FormHelperText,
Input,
- Modal,
- ModalDialog,
Typography,
} from '@mui/joy'
import { useState } from 'react'
+import FadeModal from '../../../components/common/FadeModal'
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
const [state, setState] = useState(currentThing?.state || '')
@@ -39,31 +38,29 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
}
return (
-
-
- Update state
+
+ Update state
-
- Value
- setState(e.target.value)}
- sx={{ minWidth: 300 }}
- />
- {errors.state}
-
+
+ Value
+ setState(e.target.value)}
+ sx={{ minWidth: 300 }}
+ />
+ {errors.state}
+
-
-
- {currentThing?.id ? 'Update' : 'Create'}
-
-
- {currentThing?.id ? 'Cancel' : 'Close'}
-
-
-
-
+
+
+ {currentThing?.id ? 'Update' : 'Create'}
+
+
+ {currentThing?.id ? 'Cancel' : 'Close'}
+
+
+
)
}
export default EditThingStateModal
diff --git a/src/views/Modals/Inputs/LabelModal.jsx b/src/views/Modals/Inputs/LabelModal.jsx
index 9430209..df631dd 100644
--- a/src/views/Modals/Inputs/LabelModal.jsx
+++ b/src/views/Modals/Inputs/LabelModal.jsx
@@ -3,13 +3,12 @@ import {
Button,
FormControl,
Input,
- Modal,
- ModalDialog,
Option,
Select,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
+import FadeModal from '../../../components/common/FadeModal'
import { useQueryClient } from '@tanstack/react-query'
import { useNotification } from '../../../service/NotificationProvider.jsx'
@@ -58,29 +57,9 @@ function LabelModal({ isOpen, onClose, label }) {
return true
}
- // Mutation for saving labels
- // const saveLabelMutation = useMutation(
- // newLabel =>
- // label
- // ? UpdateLabel({ id: label.id, ...newLabel })
- // : CreateLabel(newLabel),
- // {
- // onSuccess: () => {
- // queryClient.invalidateQueries('labels')
- // onClose()
- // },
- // onError: () => {
- // setError('Failed to save label. Please try again.')
- // },
- // },
- // )
-
const handleSave = () => {
if (!validateLabel()) return
const saveLabel = label?.id && label.id !== -1 ? UpdateLabel : CreateLabel
- // ? { id: label.id, name: labelName, color }
- // : { name: labelName, color }
- // saveLabelMutation.mutate({ name: labelName, color })
saveLabel({
id: label?.id,
name: labelName,
@@ -110,79 +89,77 @@ function LabelModal({ isOpen, onClose, label }) {
}
return (
-
-
-
- {label ? 'Edit Label' : 'Add Label'}
+
+
+ {label ? 'Edit Label' : 'Add Label'}
+
+
+
+
+ Name
+ setLabelName(e.target.value)}
+ />
+
-
-
- Name
-
- setLabelName(e.target.value)}
- />
-
+
+
+ Color
+
+
+ )}
+ >
+ {LABEL_COLORS.map(val => (
+
+ ))}
+
+
-
-
- Color
-
-
-
+ {error && (
+
+ {error}
+
+ )}
- {error && (
-
- {error}
-
- )}
-
-
-
- {label ? 'Save Changes' : 'Add Label'}
-
-
- Cancel
-
-
-
-
+
+
+ {label ? 'Save Changes' : 'Add Label'}
+
+
+ Cancel
+
+
+
)
}
diff --git a/src/views/Modals/Inputs/PasswordChangeModal.jsx b/src/views/Modals/Inputs/PasswordChangeModal.jsx
index 581b2f9..793cbdd 100644
--- a/src/views/Modals/Inputs/PasswordChangeModal.jsx
+++ b/src/views/Modals/Inputs/PasswordChangeModal.jsx
@@ -4,11 +4,10 @@ import {
FormControl,
FormHelperText,
Input,
- Modal,
- ModalDialog,
Typography,
} from '@mui/joy'
import React, { useEffect } from 'react'
+import FadeModal from '../../../components/common/FadeModal'
function PassowrdChangeModal({ isOpen, onClose }) {
const [password, setPassword] = React.useState('')
@@ -40,78 +39,76 @@ function PassowrdChangeModal({ isOpen, onClose }) {
}
return (
-
-
-
+
+
+ Change Password
+
+
+
+ Please enter your new password.
+
+
+
+ New Password
+
+ {
+ setPasswordTouched(true)
+ setPassword(e.target.value)
+ }}
+ />
+
+
+
+
+ Confirm Password
+
+ {
+ setConfirmPasswordTouched(true)
+ setConfirmPassword(e.target.value)
+ }}
+ />
+
+ {passwordError}
+
+
+ {
+ handleAction(true)
+ }}
+ fullWidth
+ sx={{ mr: 1 }}
+ >
Change Password
-
-
-
- Please enter your new password.
-
-
-
- New Password
-
- {
- setPasswordTouched(true)
- setPassword(e.target.value)
- }}
- />
-
-
-
-
- Confirm Password
-
- {
- setConfirmPasswordTouched(true)
- setConfirmPassword(e.target.value)
- }}
- />
-
- {passwordError}
-
-
- {
- handleAction(true)
- }}
- fullWidth
- sx={{ mr: 1 }}
- >
- Change Password
-
- {
- handleAction(false)
- }}
- variant='outlined'
- >
- Cancel
-
-
-
-
+
+ {
+ handleAction(false)
+ }}
+ variant='outlined'
+ >
+ Cancel
+
+
+
)
}
export default PassowrdChangeModal
diff --git a/src/views/Modals/Inputs/SelectModal.jsx b/src/views/Modals/Inputs/SelectModal.jsx
index f879bf0..7f5936d 100644
--- a/src/views/Modals/Inputs/SelectModal.jsx
+++ b/src/views/Modals/Inputs/SelectModal.jsx
@@ -1,15 +1,16 @@
-import {
- Box,
- Button,
- Modal,
- ModalDialog,
- Option,
- Select,
- Typography,
-} from '@mui/joy'
+import { Box, Button, Option, Select, Typography } from '@mui/joy'
import React from 'react'
+import FadeModal from '../../../components/common/FadeModal'
-function SelectModal({ isOpen, onClose, onSave, options, title, displayKey,placeholder }) {
+function SelectModal({
+ isOpen,
+ onClose,
+ onSave,
+ options,
+ title,
+ displayKey,
+ placeholder,
+}) {
const [selected, setSelected] = React.useState(null)
const handleSave = () => {
onSave(options.find(item => item.id === selected))
@@ -17,33 +18,31 @@ function SelectModal({ isOpen, onClose, onSave, options, title, displayKey,place
}
return (
-
-
- {title}
-
+
+ {title}
+
-
-
- Save
-
-
- Cancel
-
-
-
-
+
+
+ Save
+
+
+ Cancel
+
+
+
)
}
export default SelectModal
diff --git a/src/views/Modals/Inputs/TextModal.jsx b/src/views/Modals/Inputs/TextModal.jsx
index 2b44f78..6e2f739 100644
--- a/src/views/Modals/Inputs/TextModal.jsx
+++ b/src/views/Modals/Inputs/TextModal.jsx
@@ -1,5 +1,6 @@
-import { Box, Button, Modal, ModalDialog, Textarea, Typography } from '@mui/joy'
+import { Box, Button, Textarea, Typography } from '@mui/joy'
import { useState } from 'react'
+import FadeModal from '../../../components/common/FadeModal'
function TextModal({
isOpen,
@@ -18,29 +19,26 @@ function TextModal({
}
return (
-
-
- {/* */}
- {title}
-
-
+
+
+ {okText ? okText : 'Save'}
+
+
+ {cancelText ? cancelText : 'Cancel'}
+
+
+
)
}
export default TextModal
diff --git a/src/views/Modals/Inputs/TimerEditModal.jsx b/src/views/Modals/Inputs/TimerEditModal.jsx
new file mode 100644
index 0000000..40b85ea
--- /dev/null
+++ b/src/views/Modals/Inputs/TimerEditModal.jsx
@@ -0,0 +1,986 @@
+import { Add, Delete, Edit } from '@mui/icons-material'
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ Chip,
+ FormControl,
+ FormHelperText,
+ Input,
+ Typography,
+} from '@mui/joy'
+import moment from 'moment'
+import { useEffect, useState } from 'react'
+import FadeModal from '../../../components/common/FadeModal'
+import { useNotification } from '../../../service/NotificationProvider'
+import {
+ DeleteTimeSession,
+ GetChoreTimer,
+ UpdateTimeSession,
+} from '../../../utils/Fetcher'
+import ConfirmationModal from './ConfirmationModal'
+
+const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
+ const [timerData, setTimerData] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [editingSessions, setEditingSessions] = useState({})
+ const [confirmDeleteConfig, setConfirmDeleteConfig] = useState({})
+ const [currentTime, setCurrentTime] = useState(new Date())
+ const { showError, showSuccess } = useNotification()
+
+ // Fetch timer data when modal opens
+ useEffect(() => {
+ if (isOpen && choreId) {
+ fetchTimerData()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen, choreId])
+
+ // Real-time update interval for active timers
+ useEffect(() => {
+ let interval
+ if (isOpen && timerData && !timerData.endTime) {
+ // Update every second if timer is active
+ interval = setInterval(() => {
+ setCurrentTime(new Date())
+ }, 1000)
+ }
+ return () => {
+ if (interval) clearInterval(interval)
+ }
+ }, [isOpen, timerData])
+
+ const fetchTimerData = async () => {
+ setLoading(true)
+ try {
+ const response = await GetChoreTimer(choreId)
+ if (response.ok) {
+ const data = await response.json()
+ setTimerData(data.res) // data.res is the timer session object
+ } else {
+ showError({
+ title: 'Failed to fetch timer data',
+ message: 'Please try again.',
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Error fetching timer data',
+ message: error.message,
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatTime = seconds => {
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const secs = seconds % 60
+ return `${hours.toString().padStart(2, '0')}:${minutes
+ .toString()
+ .padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
+ }
+
+ const formatDuration = seconds => {
+ if (seconds < 60) return `${seconds}s`
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ return `${hours}h ${minutes}m`
+ }
+
+ const startEditingSession = () => {
+ if (timerData) {
+ setEditingSessions(prev => ({
+ ...prev,
+ [timerData.id]: {
+ startTime: moment(timerData.startTime).format('YYYY-MM-DDTHH:mm:ss'),
+ endTime: timerData.endTime
+ ? moment(timerData.endTime).format('YYYY-MM-DDTHH:mm:ss')
+ : '',
+ duration: timerData.duration,
+ formattedDuration: formatTime(timerData.duration),
+ pauseLog: timerData.pauseLog || [],
+ },
+ }))
+ }
+ }
+
+ const addPauseLogEntry = sessionId => {
+ setEditingSessions(prev => ({
+ ...prev,
+ [sessionId]: {
+ ...prev[sessionId],
+ pauseLog: [
+ ...prev[sessionId].pauseLog,
+ {
+ start: new Date().toISOString(),
+ end: null,
+ duration: 0,
+ updatedBy: 0, // This should be current user ID
+ },
+ ],
+ },
+ }))
+ }
+
+ const updatePauseLogEntry = (sessionId, pauseIndex, field, value) => {
+ setEditingSessions(prev => {
+ const updatedPauseLog = prev[sessionId].pauseLog.map((pause, index) => {
+ if (index === pauseIndex) {
+ const updatedPause = { ...pause, [field]: value }
+
+ // Auto-calculate duration if both start and end are present
+ if (updatedPause.start && updatedPause.end) {
+ const startTime = new Date(updatedPause.start)
+ const endTime = new Date(updatedPause.end)
+ updatedPause.duration = Math.floor((endTime - startTime) / 1000)
+ }
+
+ return updatedPause
+ }
+ return pause
+ })
+
+ return {
+ ...prev,
+ [sessionId]: {
+ ...prev[sessionId],
+ pauseLog: updatedPauseLog,
+ },
+ }
+ })
+ }
+
+ const deletePauseLogEntry = (sessionId, pauseIndex) => {
+ setEditingSessions(prev => ({
+ ...prev,
+ [sessionId]: {
+ ...prev[sessionId],
+ pauseLog: prev[sessionId].pauseLog.filter(
+ (_, index) => index !== pauseIndex,
+ ),
+ },
+ }))
+ }
+
+ const cancelEditingSession = sessionId => {
+ setEditingSessions(prev => {
+ // eslint-disable-next-line no-unused-vars
+ const { [sessionId]: removed, ...rest } = prev
+ return rest
+ })
+ }
+
+ const saveSession = async sessionId => {
+ const editingData = editingSessions[sessionId]
+ if (!editingData) return
+
+ setLoading(true)
+ try {
+ // Use the auto-calculated duration from the editing session
+ const updateData = {
+ startTime: new Date(editingData.startTime).toISOString(),
+ endTime: editingData.endTime
+ ? new Date(editingData.endTime).toISOString()
+ : null,
+ duration: editingData.duration,
+ pauseLog: editingData.pauseLog,
+ }
+
+ const response = await UpdateTimeSession(choreId, sessionId, updateData)
+ if (response.ok) {
+ showSuccess({
+ title: 'Session updated',
+ message: 'Timer session has been updated successfully.',
+ })
+ await fetchTimerData()
+ cancelEditingSession(sessionId)
+ onTimerUpdate?.()
+ } else {
+ showError({
+ title: 'Failed to update session',
+ message: 'Please try again.',
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Error updating session',
+ message: error.message,
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const deleteSession = async sessionId => {
+ setLoading(true)
+ try {
+ const response = await DeleteTimeSession(choreId, sessionId)
+ if (response.ok) {
+ showSuccess({
+ title: 'Session deleted',
+ message: 'Timer session has been deleted successfully.',
+ })
+ await fetchTimerData()
+ onTimerUpdate?.()
+ } else {
+ showError({
+ title: 'Failed to delete session',
+ message: 'Please try again.',
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Error deleting session',
+ message: error.message,
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const confirmDeleteSession = sessionId => {
+ setConfirmDeleteConfig({
+ isOpen: true,
+ title: 'Delete Timer Session',
+ message: 'Are you sure you want to delete this timer session?',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ color: 'danger',
+ onClose: isConfirmed => {
+ if (isConfirmed) {
+ deleteSession(sessionId)
+ }
+ setConfirmDeleteConfig({})
+ setEditingSessions({})
+ onClose?.()
+ },
+ })
+ }
+
+ const handleClose = () => {
+ setEditingSessions({})
+ onClose?.()
+ }
+
+ // Calculate total duration from start to now/end (real-time)
+ const calculateTotalDuration = () => {
+ if (!timerData) return 0
+
+ const startTime = new Date(timerData.startTime)
+ const endTime = timerData.endTime
+ ? new Date(timerData.endTime)
+ : currentTime
+
+ return Math.floor((endTime - startTime) / 1000) // in seconds
+ }
+
+ // Calculate current active duration (including ongoing session) (real-time)
+ const calculateCurrentActiveDuration = () => {
+ if (!timerData || !timerData.pauseLog) return 0
+
+ let totalActive = 0
+ const now = currentTime
+
+ timerData.pauseLog.forEach(session => {
+ if (session.start && session.end) {
+ // Completed session
+ totalActive += Math.floor(
+ (new Date(session.end) - new Date(session.start)) / 1000,
+ )
+ } else if (session.start && !session.end) {
+ // Ongoing session - real-time calculation
+ totalActive += Math.floor((now - new Date(session.start)) / 1000)
+ }
+ })
+
+ return totalActive
+ }
+
+ // Calculate idle time (total time minus active time) (real-time)
+ const calculateIdleTime = () => {
+ const totalDuration = calculateTotalDuration()
+ const activeDuration = calculateCurrentActiveDuration()
+
+ return Math.max(0, totalDuration - activeDuration)
+ }
+
+ return (
+ <>
+
+ Timer Details
+
+ {loading && (
+
+ Loading timer data...
+
+ )}
+
+ {!loading && !timerData && (
+
+ No timer data found for this chore.
+
+ )}
+
+ {!loading && timerData && (
+
+ {/* Timer Summary */}
+
+ {/* Header with timeline */}
+
+ {/* Stats Grid */}
+
+ {/* Active Time */}
+
+
+
+
+ Active Work
+
+
+
+
+ {formatDuration(calculateCurrentActiveDuration())}
+
+
+
+
+ {/* Idle Time */}
+
+
+
+
+ Break Time
+
+
+
+
+ {formatDuration(calculateIdleTime())}
+
+
+
+
+ {/* Total Sessions */}
+
+
+
+
+ Work Sessions
+
+
+
+
+ {timerData.pauseLog?.length || 0}
+
+
+
+
+ {/* Total Session Time */}
+
+
+
+
+ Total Time
+
+
+
+
+ {formatTime(calculateTotalDuration())}
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+
+ Work vs Break Distribution
+
+
+ {calculateCurrentActiveDuration() > 0
+ ? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
+ : 'No active time yet'}
+
+
+
+
+
+
+
+
+ {/* Time Session */}
+
+
+ Session Breakdown
+
+
+
+ {!editingSessions[timerData.id] ? (
+
+ {/* Read-only view */}
+ {/* Sessions */}
+ {timerData.pauseLog && timerData.pauseLog.length > 0 && (
+
+
+ Work Sessions ({timerData.pauseLog.length})
+
+
+
+ {timerData.pauseLog
+ .sort((a, b) => moment(b.start) - moment(a.start))
+ .map((pause, pauseIndex) => {
+ const isOngoing = !pause.end
+ const sessionDate = moment(pause.start).format(
+ 'MMM DD',
+ )
+ const startTime = moment(pause.start).format(
+ 'HH:mm',
+ )
+ const endTime = pause.end
+ ? moment(pause.end).format('HH:mm')
+ : null
+
+ const realTimeDuration = isOngoing
+ ? Math.max(
+ 0,
+ Math.floor(
+ (currentTime - new Date(pause.start)) /
+ 1000,
+ ),
+ )
+ : pause.duration
+
+ return (
+
+ {/* Session indicator */}
+
+
+ {/* Duration - Main focus */}
+
+
+ {formatDuration(realTimeDuration)}
+
+ {isOngoing && (
+
+ Live
+
+ )}
+
+
+ {/* Session details */}
+
+
+ Session #{pauseIndex + 1} • {sessionDate}
+
+
+ {startTime}{' '}
+ {endTime ? `→ ${endTime}` : '→ ongoing'}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ ) : (
+
+ {/* Editing view */}
+
+ {/* Session Editor */}
+
+
+
+ Sessions
+
+ }
+ onClick={() => addPauseLogEntry(timerData.id)}
+ >
+ Add Session
+
+
+
+ {editingSessions[timerData.id].pauseLog.map(
+ (pause, pauseIndex) => (
+
+
+
+ Session #{pauseIndex + 1}
+
+
+ deletePauseLogEntry(
+ timerData.id,
+ pauseIndex,
+ )
+ }
+ >
+
+
+
+
+
+
+
+ Start Time
+
+
+ updatePauseLogEntry(
+ timerData.id,
+ pauseIndex,
+ 'start',
+ new Date(e.target.value).toISOString(),
+ )
+ }
+ />
+
+
+
+
+ End Time
+
+
+ updatePauseLogEntry(
+ timerData.id,
+ pauseIndex,
+ 'end',
+ e.target.value
+ ? new Date(
+ e.target.value,
+ ).toISOString()
+ : null,
+ )
+ }
+ />
+
+ Leave empty if session is ongoing
+
+
+
+
+
+ Duration (Auto-calculated)
+
+
+ {formatDuration(pause.duration)} (
+ {pause.duration}s)
+
+
+
+
+ ),
+ )}
+
+
+
+ )}
+
+
+
+ {!timerData && (
+
+ No timer session found for this chore.
+
+ )}
+
+ )}
+
+
+
+
+ Cancel
+
+
+
+
+ {/* Action buttons on the right */}
+ {!loading && timerData && !editingSessions[timerData.id] && (
+ <>
+ confirmDeleteSession(timerData.id)}
+ >
+ Delete
+
+ }
+ onClick={() => startEditingSession()}
+ >
+ Edit
+
+ >
+ )}
+
+ {/* Save button when editing */}
+ {!loading && timerData && editingSessions[timerData.id] && (
+ saveSession(timerData.id)}
+ loading={loading}
+ >
+ Save
+
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+export default TimerEditModal
diff --git a/src/views/Modals/Inputs/UserModal.jsx b/src/views/Modals/Inputs/UserModal.jsx
index f617307..e37a28a 100644
--- a/src/views/Modals/Inputs/UserModal.jsx
+++ b/src/views/Modals/Inputs/UserModal.jsx
@@ -1,57 +1,44 @@
-import {
- Avatar,
- Box,
- Button,
- List,
- ListItem,
- Modal,
- ModalDialog,
- ModalOverflow,
- Typography,
-} from '@mui/joy'
+import { Avatar, Box, Button, List, ListItem, Typography } from '@mui/joy'
+import FadeModal from '../../../components/common/FadeModal'
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
return (
-
-
-
-
- Select User
-
-
- {performers.map(user => (
- {
- onSelect(user)
- onClose()
- }}
- >
-
-
- {user.displayName || user.name}
-
-
- ))}
-
-
-
- Cancel
-
-
-
-
-
+
+
+ Select User
+
+
+ {performers.map(user => (
+ {
+ onSelect(user)
+ onClose()
+ }}
+ >
+
+
+ {user.displayName || user.name}
+
+
+ ))}
+
+
+
+ Cancel
+
+
+
)
}
diff --git a/src/views/Modals/Inputs/WriteNFCModal.jsx b/src/views/Modals/Inputs/WriteNFCModal.jsx
index 2aad366..164cce9 100644
--- a/src/views/Modals/Inputs/WriteNFCModal.jsx
+++ b/src/views/Modals/Inputs/WriteNFCModal.jsx
@@ -1,15 +1,7 @@
import { CopyAll } from '@mui/icons-material'
-import {
- Box,
- Button,
- Checkbox,
- Input,
- ListItem,
- Modal,
- ModalDialog,
- Typography,
-} from '@mui/joy'
-import React, { useState } from 'react'
+import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
+import { useState } from 'react'
+import FadeModal from '../../../components/common/FadeModal'
function WriteNFCModal({ config }) {
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
@@ -60,63 +52,61 @@ function WriteNFCModal({ config }) {
return url
}
return (
-
-
-
- {nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
-
+
+
+ {nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
+
- {nfcStatus === 'success' ? (
+ {nfcStatus === 'success' ? (
+
+ URL written to NFC tag successfully!
+
+ ) : (
+ <>
- URL written to NFC tag successfully!
+ {nfcStatus === 'error'
+ ? errorMessage
+ : 'Press the button below to write to NFC.'}
- ) : (
- <>
-
- {nfcStatus === 'error'
- ? errorMessage
- : 'Press the button below to write to NFC.'}
-
- {
- navigator.clipboard.writeText(getURL())
- alert('URL copied to clipboard!')
- }}
- />
- }
- />
-
- setIsAutoCompleteWhenScan(e.target.checked)}
- label='Auto-complete when scanned'
+ {
+ navigator.clipboard.writeText(getURL())
+ alert('URL copied to clipboard!')
+ }}
/>
-
-
- writeToNFC(getURL())}
- fullWidth
- sx={{ mr: 1 }}
- disabled={nfcStatus === 'writing'}
- >
- Write NFC
-
-
- Request Access
-
-
- >
- )}
-
-
+ }
+ />
+
+ setIsAutoCompleteWhenScan(e.target.checked)}
+ label='Auto-complete when scanned'
+ />
+
+
+ writeToNFC(getURL())}
+ fullWidth
+ sx={{ mr: 1 }}
+ disabled={nfcStatus === 'writing'}
+ >
+ Write NFC
+
+
+ Request Access
+
+
+ >
+ )}
+
)
}
diff --git a/src/views/Modals/RedeemPointsModal.jsx b/src/views/Modals/RedeemPointsModal.jsx
index 2c21636..dd92d6a 100644
--- a/src/views/Modals/RedeemPointsModal.jsx
+++ b/src/views/Modals/RedeemPointsModal.jsx
@@ -1,90 +1,249 @@
+import { CreditCard, Person, Toll } from '@mui/icons-material'
import {
+ Avatar,
Box,
Button,
+ Card,
+ Chip,
+ Divider,
+ FormControl,
FormLabel,
IconButton,
Input,
- Modal,
- ModalDialog,
+ Stack,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
+import FadeModal from '../../components/common/FadeModal'
+import { resolvePhotoURL } from '../../utils/Helpers.jsx'
function RedeemPointsModal({ config }) {
+ const [points, setPoints] = useState(0)
+ const predefinedPoints = [1, 5, 10, 25, 50]
+
useEffect(() => {
setPoints(0)
}, [config])
- const [points, setPoints] = useState(0)
+ const handlePointsChange = value => {
+ const numValue = Number(value)
+ if (numValue > config.available) {
+ setPoints(config.available)
+ return
+ }
+ if (numValue < 0) {
+ setPoints(0)
+ return
+ }
+ setPoints(numValue)
+ }
- const predefinedPoints = [1, 5, 10, 25]
+ const addPredefinedPoints = point => {
+ const newPoints = points + point
+ if (newPoints > config.available) {
+ setPoints(config.available)
+ return
+ }
+ setPoints(newPoints)
+ }
+
+ const canRedeem = points > 0 && points <= config.available
return (
-
-
-
- Redeem Points
-
-
- Points to Redeem ({config.available ? config.available : 0} points
- available)
-
- {
- if (e.target.value > config.available) {
- setPoints(config.available)
- return
- }
- setPoints(e.target.value)
- }}
- />
- Or select from predefined points:
-
- {predefinedPoints.map(point => (
- config.available}
- sx={{ borderRadius: '50%' }}
- key={point}
- onClick={() => {
- const newPoints = points + point
- if (newPoints > config.available) {
- setPoints(config.available)
- return
- }
- setPoints(newPoints)
- }}
- >
- {point}
-
- ))}
+
+ {/* Header Section */}
+
+
+
+
+ Redeem Points
+
- {/* 3 button save , cancel and delete */}
-
+
+
+ {/* User Info Card */}
+
+
+
+
+
+
+
+ {config?.user?.displayName || 'User'}
+
+ }
+ sx={{ mt: 0.5 }}
+ >
+ {config?.available || 0} points available
+
+
+
+
+
+ {/* Points Input Section */}
+
+
+ Points to Redeem
+
+ }
+ slotProps={{
+ input: {
+ min: 0,
+ max: config?.available || 0,
+ placeholder: 'Enter points...',
+ },
+ }}
+ onChange={e => handlePointsChange(e.target.value)}
+ sx={{
+ '--Input-decoratorChildHeight': '45px',
+ fontSize: 'lg',
+ fontWeight: 500,
+ '&:focus-within': {
+ borderColor: 'warning.500',
+ boxShadow: '0 0 0 2px rgba(255, 193, 7, 0.2)',
+ },
+ }}
+ />
+ {points > config?.available && (
+
+ Cannot exceed available points
+
+ )}
+
+
+ {/* Quick Selection Buttons */}
+
+
+ Quick Add:
+
+
+ {predefinedPoints.map(point => (
+ config?.available}
+ onClick={() => addPredefinedPoints(point)}
+ sx={{
+ borderRadius: '50%',
+ minWidth: 45,
+ minHeight: 45,
+ fontWeight: 600,
+ fontSize: 'sm',
+ '&:hover:not(:disabled)': {
+ transform: 'scale(1.05)',
+ boxShadow: 'sm',
+ },
+ '&:disabled': {
+ opacity: 0.3,
+ },
+ transition: 'all 0.2s ease',
+ }}
+ >
+ +{point}
+
+ ))}
+
+
+
+ {/* Summary Section */}
+ {points > 0 && (
+
+
+ You are about to redeem
+
+
+ {points} points
+
+
+ Remaining: {(config?.available || 0) - points} points
+
+
+ )}
+
+
+
+ {/* Action Buttons */}
+
+
+ Cancel
+
- config.onSave({
+ config?.onSave({
points: Number(points),
- userId: config.user.userId,
+ userId: config?.user?.userId,
})
}
+ disabled={!canRedeem}
fullWidth
- sx={{ mr: 1 }}
+ startDecorator={}
+ sx={{
+ transition: 'all 0.2s ease',
+ }}
>
Redeem
-
- Cancel
-
-
-
-
+
+
+
)
}
+
export default RedeemPointsModal
diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx
index 01d95df..463f957 100644
--- a/src/views/Settings/NotificationSetting.jsx
+++ b/src/views/Settings/NotificationSetting.jsx
@@ -199,7 +199,7 @@ const NotificationSetting = () => {
set: setPreDueNotification,
label: 'Notification a few hours before the task is due',
property: 'preDueNotification',
- disabled: true,
+ disabled: false,
},
{
title: 'Overdue Notification',
@@ -207,7 +207,7 @@ const NotificationSetting = () => {
set: setNaggingNotification,
label: 'Notification when the task is overdue',
property: 'naggingNotification',
- disabled: true,
+ disabled: false,
},
].map(item => (
{
const { data: userProfile } = useUserProfile()
@@ -163,6 +164,9 @@ const Settings = () => {
)
}
+ if (!userProfile) {
+ return
+ }
return (
diff --git a/src/views/TestView/TimerCard.jsx b/src/views/TestView/TimerCard.jsx
new file mode 100644
index 0000000..d8429ce
--- /dev/null
+++ b/src/views/TestView/TimerCard.jsx
@@ -0,0 +1,490 @@
+import { Pause, PlayArrow, Stop, WatchLater } from '@mui/icons-material'
+import { Box, Card, CardContent, IconButton, Typography } from '@mui/joy'
+import { useEffect, useRef, useState } from 'react'
+
+const TimerCard = ({
+ variant = 'standalone', // 'standalone' | 'infoCard' | 'floating'
+ sx = {},
+ onTimeUpdate = () => {},
+ title = 'Timer',
+}) => {
+ const [time, setTime] = useState(0) // Time in seconds
+ const [isRunning, setIsRunning] = useState(false)
+ const [isPaused, setIsPaused] = useState(false)
+ const intervalRef = useRef(null)
+
+ // Format time as HH:MM:SS
+ const formatTime = seconds => {
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const secs = seconds % 60
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
+ }
+
+ // Handle timer logic
+ useEffect(() => {
+ if (isRunning && !isPaused) {
+ intervalRef.current = setInterval(() => {
+ setTime(prevTime => {
+ const newTime = prevTime + 1
+ onTimeUpdate(newTime)
+ return newTime
+ })
+ }, 1000)
+ } else {
+ clearInterval(intervalRef.current)
+ }
+
+ return () => clearInterval(intervalRef.current)
+ }, [isRunning, isPaused, onTimeUpdate])
+
+ const startTimer = () => {
+ setIsRunning(true)
+ setIsPaused(false)
+ }
+
+ const pauseTimer = () => {
+ setIsPaused(true)
+ }
+
+ const stopTimer = () => {
+ setIsRunning(false)
+ setIsPaused(false)
+ setTime(0)
+ onTimeUpdate(0)
+ }
+
+ const resumeTimer = () => {
+ setIsPaused(false)
+ }
+
+ // Info Card variant - fits in ChoreView grid
+ if (variant === 'infoCard') {
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+ {formatTime(time)}
+
+ {!isRunning ? (
+
+
+
+ ) : (
+
+
+ {isPaused ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )}
+
+ {time > 0 && (
+
+ {Math.floor(time / 60)}m {time % 60}s
+
+ )}
+
+
+ )
+ }
+
+ // Floating variant - position fixed
+ if (variant === 'floating') {
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+ {formatTime(time)}
+
+
+ {isRunning && !isPaused ? 'Running' : isPaused ? 'Paused' : 'Ready'}
+
+
+
+
+ {!isRunning ? (
+
+
+
+ ) : (
+ <>
+
+ {isPaused ? : }
+
+
+
+
+ >
+ )}
+
+
+ )
+ }
+
+ // Default standalone variant
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {title}
+
+
+
+
+ {/* Timer Display */}
+
+ {/* Circular Background */}
+
+ {/* Timer Text */}
+
+
+ {formatTime(time)}
+
+
+ {isRunning && !isPaused
+ ? 'Running'
+ : isPaused
+ ? 'Paused'
+ : 'Ready'}
+
+
+
+
+ {/* Pulse effect for running state */}
+ {isRunning && !isPaused && (
+
+ )}
+
+
+ {/* Control Buttons */}
+
+ {!isRunning ? (
+
+
+
+ ) : (
+ <>
+
+ {isPaused ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Session Info */}
+ {time > 0 && (
+
+
+ Session: {Math.floor(time / 60)}m {time % 60}s
+
+
+ )}
+
+ )
+}
+
+export default TimerCard
diff --git a/src/views/Things/ThingsHistory.jsx b/src/views/Things/ThingsHistory.jsx
index 0b91828..fe8b97a 100644
--- a/src/views/Things/ThingsHistory.jsx
+++ b/src/views/Things/ThingsHistory.jsx
@@ -1,9 +1,11 @@
-import { EventBusy } from '@mui/icons-material'
+import { EventBusy, Schedule, TrendingUp } from '@mui/icons-material'
import {
+ Avatar,
Box,
Button,
Chip,
Container,
+ Grid,
List,
ListDivider,
ListItem,
@@ -42,7 +44,7 @@ const ThingsHistory = () => {
setErrLoading(true)
}
})
- }, [])
+ }, [id])
const handleLoadMore = () => {
GetThingHistory(id, thingsHistory.length).then(resp => {
@@ -107,7 +109,7 @@ const ThingsHistory = () => {
No history found
- It's look like there is no history for this thing yet.
+ It looks like there is no history for this thing yet.
Go back to things
@@ -175,46 +177,118 @@ const ThingsHistory = () => {
Change log:
-
+
{thingsHistory.map((history, index) => (
- <>
-
-
-
-
- {moment(history.updatedAt).format(
- 'ddd MM/DD/yyyy HH:mm:ss',
- )}
-
- {history.state}
-
+
+
+
+
+ {/* First Row: Status and Time Info */}
+
+
+
+
+
+
+
+ Updated
+
+
+ }
+ >
+ {moment(history.updatedAt).format('MMM DD, h:mm A')}
+
+
+
+
+ {/* Second Row: State Value */}
+
+
+
+ {history.state}
+
+
+
+
+
+ {/* Divider with time difference */}
{index < thingsHistory.length - 1 && (
- <>
-
- {/* time between two completion: */}
- {index < thingsHistory.length - 1 &&
- thingsHistory[index + 1].createdAt && (
-
- {formatTimeDifference(
- history.createdAt,
- thingsHistory[index + 1].createdAt,
- )}{' '}
- before
-
- )}
-
- >
+
+
+ {formatTimeDifference(
+ history.createdAt,
+ thingsHistory[index + 1].createdAt,
+ )}{' '}
+ before
+
+
)}
- >
+
))}
diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx
index 35dea3c..7298a98 100644
--- a/src/views/Things/ThingsView.jsx
+++ b/src/views/Things/ThingsView.jsx
@@ -8,16 +8,8 @@ import {
ToggleOn,
Widgets,
} from '@mui/icons-material'
-import {
- Box,
- Button,
- Chip,
- Container,
- Grid,
- IconButton,
- Typography,
-} from '@mui/joy'
-import { useEffect, useState } from 'react'
+import { Avatar, Box, Chip, Container, IconButton, Typography } from '@mui/joy'
+import React, { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider'
import {
@@ -38,6 +30,17 @@ const ThingCard = ({
}) => {
const [isDisabled, setIsDisabled] = useState(false)
const Navigate = useNavigate()
+
+ // 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 = 200
+ const dragStartX = useRef(0)
+ const cardRef = useRef(null)
+
const getThingIcon = type => {
if (type === 'text') {
return
@@ -54,67 +57,233 @@ const ThingCard = ({
}
}
+ const getThingAvatar = () => {
+ const typeConfig = {
+ text: { color: 'primary', icon: },
+ number: { color: 'success', icon: },
+ boolean: {
+ color: thing.state === 'true' ? 'success' : 'neutral',
+ icon: thing.state === 'true' ? : ,
+ },
+ }
+
+ const config = typeConfig[thing?.type] || typeConfig.boolean
+ return (
+
+ {config.icon}
+
+ )
+ }
+
const handleRequestChange = thing => {
setIsDisabled(true)
+ resetSwipe()
onStateChangeRequest(thing)
setTimeout(() => {
setIsDisabled(false)
}, 2000)
}
- return (
- {
+ dragStartX.current = e.touches[0].clientX
+ setIsDragging(true)
+ }
- mb: 2,
- }}
- >
-
- Navigate(`/things/${thing?.id}`)}
+ 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
+ React.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) */}
+
- Navigate(`/things/${thing?.id}`)}
- >
- {thing?.name}
-
- {thing?.type}
-
-
- State: {thing?.state}
-
-
- {
+ size='sm'
+ onClick={e => {
+ e.stopPropagation()
if (thing?.type === 'text') {
onEditClick(thing)
} else {
@@ -122,42 +291,220 @@ const ThingCard = ({
}
}}
disabled={isDisabled}
- startDecorator={getThingIcon(thing?.type)}
- >
- {thing?.type === 'text'
- ? 'Change'
- : thing?.type === 'number'
- ? 'Increment'
- : 'Toggle'}
-
- onEditClick(thing)}
sx={{
- borderRadius: '50%',
- width: 30,
- height: 30,
- ml: 1,
- transition: 'background-color 0.2s',
- '&:hover': { backgroundColor: 'action.hover' },
+ width: 40,
+ height: 40,
+ mx: 1,
}}
>
-
+ {getThingIcon(thing?.type)}
+
{
+ e.stopPropagation()
+ resetSwipe()
+ onEditClick(thing)
+ }}
+ sx={{
+ width: 40,
+ height: 40,
+ mx: 1,
+ }}
+ >
+
+
+
+ onDeleteClick(thing)}
+ size='sm'
+ onClick={e => {
+ e.stopPropagation()
+ resetSwipe()
+ onDeleteClick(thing)
+ }}
sx={{
- borderRadius: '50%',
- width: 30,
- height: 30,
- ml: 1,
+ width: 40,
+ height: 40,
+ mx: 1,
}}
>
-
+
-
-
+
+
+ {/* Main card content */}
+ {
+ if (isSwipeRevealed) {
+ resetSwipe()
+ return
+ }
+ Navigate(`/things/${thing?.id}`)
+ }}
+ 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) => (
+
+ ))}
+
+
+ {/* Avatar and Primary Action */}
+
+ {getThingAvatar()}
+
+
+ {/* Content - Center */}
+
+ {/* Line 1: Name + State */}
+
+
+ {thing?.name}
+
+
+
+ {thing?.state}
+
+
+
+ {/* Line 2: Type */}
+
+
+ {thing?.type}
+
+
+
+
+
)
}
@@ -312,38 +659,47 @@ const ThingsView = () => {
}
return (
-
- {things.length === 0 && (
-
-
+
+ {things.length === 0 && (
+
+
+
+ No things has been created/found
+
+
+ )}
+ {things.map(thing => (
+
-
- No things has been created/found
-
-
- )}
- {things.map(thing => (
-
- ))}
+ ))}
+
{
+ const { choreId } = useParams()
+ const navigate = useNavigate()
+ const [timerData, setTimerData] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [editingSessions, setEditingSessions] = useState({})
+ const [confirmDeleteConfig, setConfirmDeleteConfig] = useState({})
+ const [currentTime, setCurrentTime] = useState(new Date())
+ const { showError, showSuccess } = useNotification()
+
+ // Fetch timer data when component mounts
+ useEffect(() => {
+ if (choreId) {
+ fetchTimerData()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [choreId])
+
+ // Real-time update interval for active timers
+ useEffect(() => {
+ let interval
+ if (timerData && !timerData.endTime) {
+ // Update every second if timer is active
+ interval = setInterval(() => {
+ setCurrentTime(new Date())
+ }, 1000)
+ }
+ return () => {
+ if (interval) clearInterval(interval)
+ }
+ }, [timerData])
+
+ const fetchTimerData = async () => {
+ setLoading(true)
+ try {
+ const response = await GetChoreTimer(choreId)
+ if (response.ok) {
+ const data = await response.json()
+ setTimerData(data.res)
+ } else {
+ showError({
+ title: 'Failed to fetch timer data',
+ message: 'Please try again.',
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Error fetching timer data',
+ message: error.message,
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatTime = seconds => {
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const secs = seconds % 60
+ return `${hours.toString().padStart(2, '0')}:${minutes
+ .toString()
+ .padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
+ }
+
+ const formatDuration = seconds => {
+ if (seconds < 60) return `${seconds}s`
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ return `${hours}h ${minutes}m`
+ }
+
+ const startEditingSession = () => {
+ if (timerData) {
+ setEditingSessions(prev => ({
+ ...prev,
+ [timerData.id]: {
+ startTime: moment(timerData.startTime).format('YYYY-MM-DDTHH:mm:ss'),
+ endTime: timerData.endTime
+ ? moment(timerData.endTime).format('YYYY-MM-DDTHH:mm:ss')
+ : '',
+ duration: timerData.duration,
+ formattedDuration: formatTime(timerData.duration),
+ pauseLog: timerData.pauseLog || [],
+ },
+ }))
+ }
+ }
+
+ const addPauseLogEntry = sessionId => {
+ setEditingSessions(prev => ({
+ ...prev,
+ [sessionId]: {
+ ...prev[sessionId],
+ pauseLog: [
+ ...prev[sessionId].pauseLog,
+ {
+ start: new Date().toISOString(),
+ end: null,
+ duration: 0,
+ updatedBy: 0, // This should be current user ID
+ },
+ ],
+ },
+ }))
+ }
+
+ const updatePauseLogEntry = (sessionId, pauseIndex, field, value) => {
+ setEditingSessions(prev => {
+ const updatedPauseLog = prev[sessionId].pauseLog.map((pause, index) => {
+ if (index === pauseIndex) {
+ const updatedPause = { ...pause, [field]: value }
+
+ // Auto-calculate duration if both start and end are present
+ if (updatedPause.start && updatedPause.end) {
+ const startTime = new Date(updatedPause.start)
+ const endTime = new Date(updatedPause.end)
+ updatedPause.duration = Math.floor((endTime - startTime) / 1000)
+ }
+
+ return updatedPause
+ }
+ return pause
+ })
+
+ return {
+ ...prev,
+ [sessionId]: {
+ ...prev[sessionId],
+ pauseLog: updatedPauseLog,
+ },
+ }
+ })
+ }
+
+ const deletePauseLogEntry = (sessionId, pauseIndex) => {
+ setEditingSessions(prev => ({
+ ...prev,
+ [sessionId]: {
+ ...prev[sessionId],
+ pauseLog: prev[sessionId].pauseLog.filter(
+ (_, index) => index !== pauseIndex,
+ ),
+ },
+ }))
+ }
+
+ const cancelEditingSession = sessionId => {
+ setEditingSessions(prev => {
+ // eslint-disable-next-line no-unused-vars
+ const { [sessionId]: removed, ...rest } = prev
+ return rest
+ })
+ }
+
+ const saveSession = async sessionId => {
+ const editingData = editingSessions[sessionId]
+ if (!editingData) return
+
+ setLoading(true)
+ try {
+ // Use the auto-calculated duration from the editing session
+ const updateData = {
+ startTime: new Date(editingData.startTime).toISOString(),
+ endTime: editingData.endTime
+ ? new Date(editingData.endTime).toISOString()
+ : null,
+ duration: editingData.duration,
+ pauseLog: editingData.pauseLog,
+ }
+
+ const response = await UpdateTimeSession(choreId, sessionId, updateData)
+ if (response.ok) {
+ showSuccess({
+ title: 'Session updated',
+ message: 'Timer session has been updated successfully.',
+ })
+ await fetchTimerData()
+ cancelEditingSession(sessionId)
+ } else {
+ showError({
+ title: 'Failed to update session',
+ message: 'Please try again.',
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Error updating session',
+ message: error.message,
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const deleteSession = async sessionId => {
+ setLoading(true)
+ try {
+ const response = await DeleteTimeSession(choreId, sessionId)
+ if (response.ok) {
+ showSuccess({
+ title: 'Session deleted',
+ message: 'Timer session has been deleted successfully.',
+ })
+ await fetchTimerData()
+ // Navigate back after successful deletion
+ navigate(`/chores/${choreId}`)
+ } else {
+ showError({
+ title: 'Failed to delete session',
+ message: 'Please try again.',
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Error deleting session',
+ message: error.message,
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const confirmDeleteSession = sessionId => {
+ setConfirmDeleteConfig({
+ isOpen: true,
+ title: 'Delete Timer Session',
+ message: 'Are you sure you want to delete this timer session?',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ color: 'danger',
+ onClose: isConfirmed => {
+ if (isConfirmed) {
+ deleteSession(sessionId)
+ }
+ setConfirmDeleteConfig({})
+ },
+ })
+ }
+
+ const handleGoBack = () => {
+ navigate(`/chores/${choreId}`)
+ }
+
+ // Calculate total duration from start to now/end (real-time)
+ const calculateTotalDuration = () => {
+ if (!timerData) return 0
+
+ const startTime = new Date(timerData.startTime)
+ const endTime = timerData.endTime
+ ? new Date(timerData.endTime)
+ : currentTime
+
+ return Math.floor((endTime - startTime) / 1000) // in seconds
+ }
+
+ // Calculate current active duration (including ongoing session) (real-time)
+ const calculateCurrentActiveDuration = () => {
+ if (!timerData || !timerData.pauseLog) return 0
+
+ let totalActive = 0
+ const now = currentTime
+
+ timerData.pauseLog.forEach(session => {
+ if (session.start && session.end) {
+ // Completed session
+ totalActive += Math.floor(
+ (new Date(session.end) - new Date(session.start)) / 1000,
+ )
+ } else if (session.start && !session.end) {
+ // Ongoing session - real-time calculation
+ totalActive += Math.floor((now - new Date(session.start)) / 1000)
+ }
+ })
+
+ return totalActive
+ }
+
+ // Calculate idle time (total time minus active time) (real-time)
+ const calculateIdleTime = () => {
+ const totalDuration = calculateTotalDuration()
+ const activeDuration = calculateCurrentActiveDuration()
+
+ return Math.max(0, totalDuration - activeDuration)
+ }
+
+ return (
+
+ {/* Header */}
+
+ {loading && (
+
+ Loading timer data...
+
+ )}
+
+ {!loading && !timerData && (
+
+ No timer data found for this chore.
+
+ )}
+
+ {!loading && timerData && (
+
+ {/* Timer Summary */}
+
+ {/* Stats Grid */}
+
+ {/* Active Time */}
+
+
+
+
+
+
+ Active Work
+
+
+
+
+ {formatDuration(calculateCurrentActiveDuration())}
+
+
+
+
+
+
+ {/* Idle Time */}
+
+
+
+
+
+
+ Break Time
+
+
+
+
+ {formatDuration(calculateIdleTime())}
+
+
+
+
+
+
+ {/* Total Sessions */}
+
+
+
+
+
+
+ Sessions
+
+
+
+
+ {timerData.pauseLog?.length || 0}
+
+
+
+
+
+
+ {/* Total Session Time */}
+
+
+
+
+
+
+ Total Time
+
+
+
+
+ {formatTime(calculateTotalDuration())}
+
+
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+
+ Work vs Break Distribution
+
+
+ {calculateCurrentActiveDuration() > 0
+ ? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
+ : 'No active time yet'}
+
+
+
+
+
+
+
+
+ {/* Session Breakdown */}
+
+
+ Session Breakdown
+
+
+ {!editingSessions[timerData.id] ? (
+
+ {/* Read-only view */}
+ {timerData.pauseLog && timerData.pauseLog.length > 0 && (
+
+
+ Work Sessions ({timerData.pauseLog.length})
+
+
+
+ {timerData.pauseLog
+ .sort((a, b) => moment(b.start) - moment(a.start))
+ .map((pause, pauseIndex) => {
+ const isOngoing = !pause.end
+ const sessionDate = moment(pause.start).format(
+ 'MMM DD',
+ )
+ const startTime = moment(pause.start).format('HH:mm')
+ const endTime = pause.end
+ ? moment(pause.end).format('HH:mm')
+ : null
+
+ const realTimeDuration = isOngoing
+ ? Math.max(
+ 0,
+ Math.floor(
+ (currentTime - new Date(pause.start)) / 1000,
+ ),
+ )
+ : pause.duration
+
+ return (
+
+ {/* Session indicator */}
+
+
+ {/* Duration - Main focus */}
+
+
+ {formatDuration(realTimeDuration)}
+
+ {isOngoing && (
+
+ Live
+
+ )}
+
+
+ {/* Session details */}
+
+
+ Session #{pauseIndex + 1} • {sessionDate}
+
+
+ {startTime}{' '}
+ {endTime ? `→ ${endTime}` : '→ ongoing'}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {(!timerData.pauseLog || timerData.pauseLog.length === 0) && (
+
+ No work sessions found for this timer.
+
+ )}
+
+ ) : (
+
+ {/* Editing view */}
+
+ {/* Session Editor */}
+
+
+
+ Sessions
+
+ }
+ onClick={() => addPauseLogEntry(timerData.id)}
+ >
+ Add Session
+
+
+
+ {editingSessions[timerData.id].pauseLog.map(
+ (pause, pauseIndex) => (
+
+
+
+ Session #{pauseIndex + 1}
+
+
+ deletePauseLogEntry(timerData.id, pauseIndex)
+ }
+ >
+
+
+
+
+
+
+
+ Start Time
+
+
+ updatePauseLogEntry(
+ timerData.id,
+ pauseIndex,
+ 'start',
+ new Date(e.target.value).toISOString(),
+ )
+ }
+ />
+
+
+
+
+ End Time
+
+
+ updatePauseLogEntry(
+ timerData.id,
+ pauseIndex,
+ 'end',
+ e.target.value
+ ? new Date(e.target.value).toISOString()
+ : null,
+ )
+ }
+ />
+
+ Leave empty if session is ongoing
+
+
+
+
+
+ Duration (Auto-calculated)
+
+
+ {formatDuration(pause.duration)} (
+ {pause.duration}s)
+
+
+
+
+ ),
+ )}
+
+
+
+ )}
+
+
+ )}
+
+ {/* Sticky Bottom Actions */}
+
+
+
+ {/*
+ Back to Chore
+ */}
+
+ {/* Right side - Action buttons */}
+ {!loading && timerData && !editingSessions[timerData.id] && (
+
+ confirmDeleteSession(timerData.id)}
+ >
+ Delete
+
+ }
+ onClick={() => startEditingSession()}
+ >
+ Edit
+
+
+ )}
+
+ {/* Save/Cancel buttons when editing */}
+ {!loading && timerData && editingSessions[timerData.id] && (
+
+ cancelEditingSession(timerData.id)}
+ >
+ Cancel
+
+ saveSession(timerData.id)}
+ loading={loading}
+ >
+ Save Changes
+
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+export default TimerDetails
diff --git a/src/views/User/UserActivities.jsx b/src/views/User/UserActivities.jsx
index d6c1eb0..598f2f7 100644
--- a/src/views/User/UserActivities.jsx
+++ b/src/views/User/UserActivities.jsx
@@ -3,7 +3,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CircleIcon from '@mui/icons-material/Circle'
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'
-import { EventBusy, Toll } from '@mui/icons-material'
+import { EventBusy, Group, Toll } from '@mui/icons-material'
import {
Avatar,
Box,
@@ -27,7 +27,7 @@ import React, { useEffect, useState } from 'react'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { ChoresGrouper } from '../../utils/Chores'
-import { TASK_COLOR } from '../../utils/Colors.jsx'
+import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
import LoadingComponent from '../components/Loading'
@@ -131,7 +131,7 @@ const ChoreHistoryTimeline = ({ history }) => {
)
}
-const renderPieChart = (data, size, isPrimary) => (
+const renderPieChart = (data, size, isPrimary, chartType = null) => (
(
|
))}
- {isPrimary && }
+ {isPrimary && (
+ {
+ if (chartType === 'tasksTime' && props.payload.count) {
+ return [`${value}h (${props.payload.count} times)`, name]
+ }
+ return [`${value}`, name]
+ }}
+ />
+ )}
{isPrimary && (