Merge branch 'dev'
This commit is contained in:
29
src/App.jsx
29
src/App.jsx
@@ -1,20 +1,16 @@
|
|||||||
import NavBar from '@/views/components/NavBar'
|
import NavBar from '@/views/components/NavBar'
|
||||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||||
import Tracker from '@openreplay/tracker'
|
import Tracker from '@openreplay/tracker'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { Outlet, useNavigate } from 'react-router-dom'
|
import { Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
import { registerCapacitorListeners } from './CapacitorListener'
|
import { registerCapacitorListeners } from './CapacitorListener'
|
||||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||||
import { useResource } from './queries/ResourceQueries'
|
|
||||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||||
import {
|
import { useNotification } from './service/NotificationProvider'
|
||||||
NotificationProvider,
|
|
||||||
useNotification,
|
|
||||||
} from './service/NotificationProvider'
|
|
||||||
import { apiManager } from './utils/TokenManager'
|
import { apiManager } from './utils/TokenManager'
|
||||||
import NetworkBanner from './views/components/NetworkBanner'
|
import NetworkBanner from './views/components/NetworkBanner'
|
||||||
|
|
||||||
const add = className => {
|
const add = className => {
|
||||||
document.getElementById('root').classList.add(className)
|
document.getElementById('root').classList.add(className)
|
||||||
}
|
}
|
||||||
@@ -22,9 +18,9 @@ const add = className => {
|
|||||||
const remove = className => {
|
const remove = className => {
|
||||||
document.getElementById('root').classList.remove(className)
|
document.getElementById('root').classList.remove(className)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update the interval to at 60 minutes
|
// TODO: Update the interval to at 60 minutes
|
||||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||||
const queryClient = new QueryClient({})
|
|
||||||
|
|
||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
@@ -85,14 +81,13 @@ const AppContent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const resource = useResource()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
startApiManager(navigate)
|
startApiManager(navigate)
|
||||||
startOpenReplay()
|
startOpenReplay()
|
||||||
|
|
||||||
const { mode, systemMode } = useColorScheme()
|
const { mode, systemMode } = useColorScheme()
|
||||||
|
|
||||||
const setThemeClass = () => {
|
const setThemeClass = useCallback(() => {
|
||||||
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||||
|
|
||||||
if (value === 'system') {
|
if (value === 'system') {
|
||||||
@@ -107,11 +102,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return remove('dark')
|
return remove('dark')
|
||||||
}
|
}, [mode, systemMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setThemeClass()
|
setThemeClass()
|
||||||
}, [mode, systemMode])
|
}, [setThemeClass])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerCapacitorListeners()
|
registerCapacitorListeners()
|
||||||
@@ -121,12 +116,9 @@ function App() {
|
|||||||
<div className='min-h-screen'>
|
<div className='min-h-screen'>
|
||||||
<NetworkBanner />
|
<NetworkBanner />
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<AuthenticationProvider>
|
||||||
<AuthenticationProvider />
|
|
||||||
<NotificationProvider>
|
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</NotificationProvider>
|
</AuthenticationProvider>
|
||||||
</QueryClientProvider>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,7 +131,6 @@ const startOpenReplay = () => {
|
|||||||
|
|
||||||
tracker.start()
|
tracker.start()
|
||||||
}
|
}
|
||||||
export default App
|
|
||||||
|
|
||||||
const startApiManager = navigate => {
|
const startApiManager = navigate => {
|
||||||
apiManager.init()
|
apiManager.init()
|
||||||
@@ -147,3 +138,5 @@ const startApiManager = navigate => {
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|||||||
82
src/components/common/FadeModal.jsx
Normal file
82
src/components/common/FadeModal.jsx
Normal file
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
sx={{
|
||||||
|
'& .MuiModal-backdrop': {
|
||||||
|
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
keepMounted
|
||||||
|
// These transition properties create a smooth fade + slide effect
|
||||||
|
transition={{
|
||||||
|
mount: { opacity: 1, transform: 'translateY(0px)' },
|
||||||
|
unmount: { opacity: 0, transform: 'translateY(20px)' },
|
||||||
|
duration: 250, // Animation duration in ms
|
||||||
|
easing: {
|
||||||
|
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
|
||||||
|
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ModalOverflow>
|
||||||
|
<ModalDialog
|
||||||
|
size={size}
|
||||||
|
sx={{
|
||||||
|
minWidth: fullWidth ? '100%' : 'auto',
|
||||||
|
animation: open
|
||||||
|
? 'modalFadeIn 0.35s forwards'
|
||||||
|
: 'modalFadeOut 0.25s forwards',
|
||||||
|
'@keyframes modalFadeIn': {
|
||||||
|
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||||
|
to: { opacity: 1, transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
'@keyframes modalFadeOut': {
|
||||||
|
from: { opacity: 1, transform: 'translateY(0)' },
|
||||||
|
to: { opacity: 0, transform: 'translateY(8px)' },
|
||||||
|
},
|
||||||
|
// Add staggered animation for child elements
|
||||||
|
'& > *': {
|
||||||
|
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}
|
||||||
|
</ModalDialog>
|
||||||
|
</ModalOverflow>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FadeModal
|
||||||
71
src/components/common/KeyboardShortcutHint.jsx
Normal file
71
src/components/common/KeyboardShortcutHint.jsx
Normal file
@@ -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 (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
color='neutral'
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
maxHeight: '1.5rem',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
fontWeight: '500',
|
||||||
|
letterSpacing: '0.025em',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'neutral.300',
|
||||||
|
// backgroundColor: 'background.surface',
|
||||||
|
color: 'text.secondary',
|
||||||
|
borderRadius: '8px',
|
||||||
|
px: 0.5,
|
||||||
|
py: 0.125,
|
||||||
|
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||||
|
display: { xs: 'none', md: 'inline-flex' }, // Hide on mobile
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{displayShortcut}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardShortcutHint.propTypes = {
|
||||||
|
shortcut: PropTypes.string.isRequired,
|
||||||
|
show: PropTypes.bool,
|
||||||
|
withCmd: PropTypes.bool,
|
||||||
|
withShift: PropTypes.bool,
|
||||||
|
sx: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KeyboardShortcutHint
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AlertsProvider } from '../service/AlertsProvider'
|
||||||
|
import { NotificationProvider } from '../service/NotificationProvider'
|
||||||
import QueryContext from './QueryContext'
|
import QueryContext from './QueryContext'
|
||||||
import RouterContext from './RouterContext'
|
import RouterContext from './RouterContext'
|
||||||
import SSEProvider from './SSEContext'
|
import SSEProvider from './SSEContext'
|
||||||
@@ -6,8 +8,10 @@ import WebSocketProvider from './WebSocketContext'
|
|||||||
|
|
||||||
const Contexts = () => {
|
const Contexts = () => {
|
||||||
const contexts = [
|
const contexts = [
|
||||||
|
AlertsProvider,
|
||||||
ThemeContext,
|
ThemeContext,
|
||||||
QueryContext,
|
QueryContext,
|
||||||
|
NotificationProvider,
|
||||||
SSEProvider,
|
SSEProvider,
|
||||||
WebSocketProvider,
|
WebSocketProvider,
|
||||||
RouterContext,
|
RouterContext,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import TermsView from '../views/Terms/TermsView'
|
|||||||
import TestView from '../views/TestView/Test'
|
import TestView from '../views/TestView/Test'
|
||||||
import ThingsHistory from '../views/Things/ThingsHistory'
|
import ThingsHistory from '../views/Things/ThingsHistory'
|
||||||
import ThingsView from '../views/Things/ThingsView'
|
import ThingsView from '../views/Things/ThingsView'
|
||||||
|
import TimerDetails from '../views/Timer/TimerDetails'
|
||||||
import UserActivities from '../views/User/UserActivities'
|
import UserActivities from '../views/User/UserActivities'
|
||||||
import UserPoints from '../views/User/UserPoints'
|
import UserPoints from '../views/User/UserPoints'
|
||||||
import NotFound from '../views/components/NotFound'
|
import NotFound from '../views/components/NotFound'
|
||||||
@@ -70,6 +71,10 @@ const Router = createBrowserRouter([
|
|||||||
path: '/chores/:choreId/history',
|
path: '/chores/:choreId/history',
|
||||||
element: <ChoreHistory />,
|
element: <ChoreHistory />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/chores/:choreId/timer',
|
||||||
|
element: <TimerDetails />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/my/chores',
|
path: '/my/chores',
|
||||||
element: <MyChores />,
|
element: <MyChores />,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useAlerts } from '../service/AlertsProvider'
|
||||||
|
import { useNotification } from '../service/NotificationProvider'
|
||||||
import { apiManager, isTokenValid } from '../utils/TokenManager'
|
import { apiManager, isTokenValid } from '../utils/TokenManager'
|
||||||
|
|
||||||
const SSE_STATES = {
|
const SSE_STATES = {
|
||||||
CONNECTING: 0,
|
CONNECTING: 0,
|
||||||
OPEN: 1,
|
OPEN: 1,
|
||||||
@@ -27,6 +28,8 @@ export const useSSE = () => {
|
|||||||
const heartbeatMonitorRef = useRef(null)
|
const heartbeatMonitorRef = useRef(null)
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const { showError, showNotification } = useNotification()
|
||||||
|
const { showAlert } = useAlerts()
|
||||||
|
|
||||||
const getSSEUrl = useCallback(() => {
|
const getSSEUrl = useCallback(() => {
|
||||||
const token = localStorage.getItem('ca_token')
|
const token = localStorage.getItem('ca_token')
|
||||||
@@ -54,54 +57,111 @@ export const useSSE = () => {
|
|||||||
if (eventData.type === 'heartbeat') {
|
if (eventData.type === 'heartbeat') {
|
||||||
lastHeartbeatRef.current = Date.now()
|
lastHeartbeatRef.current = Date.now()
|
||||||
}
|
}
|
||||||
|
console.log('SSE Message received:', eventData)
|
||||||
|
|
||||||
// Handle different event types and update React Query cache accordingly
|
// Handle different event types and update React Query cache accordingly
|
||||||
switch (eventData.type) {
|
switch (eventData.type) {
|
||||||
case 'chore.created':
|
case 'chore.created':
|
||||||
case 'chore.updated':
|
case 'chore.updated':
|
||||||
case 'chore.completed':
|
case 'chore.completed':
|
||||||
case 'chore.skipped':
|
case 'chore.skipped': {
|
||||||
queryClient.invalidateQueries(['choresHistory', 7])
|
showNotification({
|
||||||
queryClient.invalidateQueries(['chores'])
|
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
|
||||||
|
|
||||||
// If it's a specific chore event, also invalidate that chore's details
|
// Update individual chore cache
|
||||||
if (eventData.data.chore?.id) {
|
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
|
||||||
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
|
if (!oldData) return { res: updatedChore }
|
||||||
queryClient.invalidateQueries([
|
return { res: { ...oldData.res, ...updatedChore } }
|
||||||
'choreDetails',
|
})
|
||||||
eventData.data.chore.id,
|
|
||||||
])
|
// 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],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'chore.deleted':
|
case 'chore.deleted':
|
||||||
// Invalidate chores queries to refetch data
|
// update chores list cache
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.setQueryData(['chores'], oldData => {
|
||||||
|
if (!oldData || !oldData.res) return oldData
|
||||||
// If it's a specific chore event, also invalidate that chore's details
|
return {
|
||||||
if (eventData.data.chore?.id) {
|
res: oldData.res.filter(
|
||||||
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
|
chore => chore.id !== eventData.data.choreId,
|
||||||
queryClient.invalidateQueries([
|
),
|
||||||
'choreDetails',
|
|
||||||
eventData.data.chore.id,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'subtask.updated':
|
case 'subtask.updated':
|
||||||
case 'subtask.completed':
|
case 'subtask.completed':
|
||||||
// Invalidate the specific chore that contains this subtask
|
queryClient.refetchQueries({
|
||||||
if (eventData.data.choreId) {
|
queryKey: ['choreDetails', eventData.data.choreId],
|
||||||
queryClient.invalidateQueries(['chore', eventData.data.choreId])
|
})
|
||||||
queryClient.invalidateQueries([
|
|
||||||
'choreDetails',
|
|
||||||
eventData.data.choreId,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
// Also invalidate general chores list
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
|
||||||
break
|
|
||||||
|
|
||||||
|
// 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':
|
case 'heartbeat':
|
||||||
// Heartbeat events don't need cache invalidation
|
// Heartbeat events don't need cache invalidation
|
||||||
console.debug('SSE Heartbeat received at', new Date().toISOString())
|
console.debug('SSE Heartbeat received at', new Date().toISOString())
|
||||||
@@ -111,11 +171,21 @@ export const useSSE = () => {
|
|||||||
console.log('SSE connection established')
|
console.log('SSE connection established')
|
||||||
setError(null)
|
setError(null)
|
||||||
lastHeartbeatRef.current = Date.now()
|
lastHeartbeatRef.current = Date.now()
|
||||||
|
showAlert({
|
||||||
|
type: 'success',
|
||||||
|
color: 'success',
|
||||||
|
message: 'You are now receiving real-time as they happen.',
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
console.error('SSE error event:', eventData.data)
|
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
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -123,11 +193,14 @@ export const useSSE = () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', 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
|
return // Stop processing if JSON parsing fails
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient, showNotification, showError],
|
||||||
)
|
)
|
||||||
|
|
||||||
const stopHeartbeatMonitor = useCallback(() => {
|
const stopHeartbeatMonitor = useCallback(() => {
|
||||||
@@ -141,9 +214,11 @@ export const useSSE = () => {
|
|||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (isCircuitBreakerOpen) {
|
if (isCircuitBreakerOpen) {
|
||||||
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
||||||
setError(
|
showError({
|
||||||
|
title: 'Connection Temporarily Disabled',
|
||||||
|
message:
|
||||||
'Connection blocked due to repeated failures. Please try again later.',
|
'Connection blocked due to repeated failures. Please try again later.',
|
||||||
)
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +227,11 @@ export const useSSE = () => {
|
|||||||
'SSE: Maximum reconnection attempts reached, opening circuit breaker',
|
'SSE: Maximum reconnection attempts reached, opening circuit breaker',
|
||||||
)
|
)
|
||||||
setIsCircuitBreakerOpen(true)
|
setIsCircuitBreakerOpen(true)
|
||||||
setError(
|
showError({
|
||||||
'Maximum connection attempts reached. SSE disabled for 5 minutes.',
|
title: 'Connection Failed',
|
||||||
)
|
message:
|
||||||
|
'Maximum connection attempts reached. SSE disabled for 10 minutes.',
|
||||||
|
})
|
||||||
|
|
||||||
// Reset circuit breaker after timeout
|
// Reset circuit breaker after timeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -308,10 +385,19 @@ export const useSSE = () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create SSE connection:', 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)
|
setConnectionState(SSE_STATES.CLOSED)
|
||||||
}
|
}
|
||||||
}, [getSSEUrl, handleSSEMessage, stopHeartbeatMonitor, isCircuitBreakerOpen])
|
}, [
|
||||||
|
getSSEUrl,
|
||||||
|
handleSSEMessage,
|
||||||
|
stopHeartbeatMonitor,
|
||||||
|
isCircuitBreakerOpen,
|
||||||
|
showError,
|
||||||
|
])
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
isManuallyClosedRef.current = true
|
isManuallyClosedRef.current = true
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
import Contexts from './contexts/Contexts.jsx'
|
import Contexts from './contexts/Contexts.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Contexts />
|
<Contexts queryClient={queryClient}>
|
||||||
|
<App />
|
||||||
|
</Contexts>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { localStore } from '../utils/LocalStore'
|
|||||||
|
|
||||||
export const useChores = includeArchive => {
|
export const useChores = includeArchive => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['chores'],
|
queryKey: ['chores', includeArchive],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const onlineChores = await GetChoresNew(includeArchive)
|
const onlineChores = await GetChoresNew(includeArchive)
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
|||||||
|
|
||||||
export const useChoreDetails = choreId => {
|
export const useChoreDetails = choreId => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['chore', choreId],
|
queryKey: ['choreDetails', choreId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
var onlineChore = null
|
var onlineChore = null
|
||||||
|
|
||||||
|
|||||||
84
src/service/AlertsProvider.jsx
Normal file
84
src/service/AlertsProvider.jsx
Normal file
@@ -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 (
|
||||||
|
<AlertsContext.Provider value={{ showAlert, hideAlert }}>
|
||||||
|
{children}
|
||||||
|
{visibleAlert && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 2000,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
variant='soft'
|
||||||
|
color={visibleAlert.color || 'primary'}
|
||||||
|
startDecorator={visibleAlert.icon}
|
||||||
|
onClick={hideAlert}
|
||||||
|
sx={{
|
||||||
|
transition: `transform ${FADE_DURATION}ms ease-in-out, opacity ${FADE_DURATION}ms ease-in-out`,
|
||||||
|
transform: show ? 'translateY(0)' : 'translateY(-100%)',
|
||||||
|
opacity: show ? 1 : 0,
|
||||||
|
pointerEvents: show ? 'auto' : 'none',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 'md',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleAlert.message}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</AlertsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertsProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAlerts = () => useContext(AlertsContext)
|
||||||
@@ -2,6 +2,13 @@ import moment from 'moment'
|
|||||||
import { TASK_COLOR } from './Colors.jsx'
|
import { TASK_COLOR } from './Colors.jsx'
|
||||||
|
|
||||||
const priorityOrder = [1, 2, 3, 4, 0]
|
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) => {
|
export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@@ -12,6 +19,110 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
|||||||
chores.sort(ChoreSorter)
|
chores.sort(ChoreSorter)
|
||||||
var groups = []
|
var groups = []
|
||||||
switch (groupBy) {
|
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':
|
case 'due_date':
|
||||||
var groupRaw = {
|
var groupRaw = {
|
||||||
Today: [],
|
Today: [],
|
||||||
|
|||||||
@@ -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) => {
|
const CompleteSubTask = (id, choreId, completedAt) => {
|
||||||
var markChoreURL = `/chores/${choreId}/subtask`
|
var markChoreURL = `/chores/${choreId}/subtask`
|
||||||
return Fetch(markChoreURL, {
|
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 GetAllCircleMembers = async () => {
|
||||||
const resp = await Fetch(`/circles/members`, {
|
const resp = await Fetch(`/circles/members`, {
|
||||||
method: 'GET',
|
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 {
|
export {
|
||||||
AcceptCircleMemberRequest,
|
AcceptCircleMemberRequest,
|
||||||
ArchiveChore,
|
ArchiveChore,
|
||||||
CancelSubscription,
|
CancelSubscription,
|
||||||
ChangePassword,
|
ChangePassword,
|
||||||
|
ClearChoreTimer,
|
||||||
CompleteSubTask,
|
CompleteSubTask,
|
||||||
ConfirmMFA,
|
ConfirmMFA,
|
||||||
CreateChore,
|
CreateChore,
|
||||||
@@ -570,6 +614,7 @@ export {
|
|||||||
DeleteLabel,
|
DeleteLabel,
|
||||||
DeleteLongLiveToken,
|
DeleteLongLiveToken,
|
||||||
DeleteThing,
|
DeleteThing,
|
||||||
|
DeleteTimeSession,
|
||||||
DisableMFA,
|
DisableMFA,
|
||||||
GetAllCircleMembers,
|
GetAllCircleMembers,
|
||||||
GetAllUsers,
|
GetAllUsers,
|
||||||
@@ -577,6 +622,7 @@ export {
|
|||||||
GetChoreByID,
|
GetChoreByID,
|
||||||
GetChoreDetailById,
|
GetChoreDetailById,
|
||||||
GetChoreHistory,
|
GetChoreHistory,
|
||||||
|
GetChoreTimer,
|
||||||
GetChores,
|
GetChores,
|
||||||
GetChoresHistory,
|
GetChoresHistory,
|
||||||
GetChoresNew,
|
GetChoresNew,
|
||||||
@@ -594,27 +640,30 @@ export {
|
|||||||
JoinCircle,
|
JoinCircle,
|
||||||
LeaveCircle,
|
LeaveCircle,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
|
PauseChore,
|
||||||
PutNotificationTarget,
|
PutNotificationTarget,
|
||||||
PutWebhookURL,
|
PutWebhookURL,
|
||||||
RedeemPoints,
|
RedeemPoints,
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
RegenerateBackupCodes,
|
RegenerateBackupCodes,
|
||||||
|
ResetChoreTimer,
|
||||||
ResetPassword,
|
ResetPassword,
|
||||||
SaveChore,
|
SaveChore,
|
||||||
SaveThing,
|
SaveThing,
|
||||||
SetupMFA,
|
SetupMFA,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
|
StartChore,
|
||||||
UnArchiveChore,
|
UnArchiveChore,
|
||||||
UpdateChoreAssignee,
|
UpdateChoreAssignee,
|
||||||
UpdateChoreHistory,
|
UpdateChoreHistory,
|
||||||
UpdateChorePriority,
|
UpdateChorePriority,
|
||||||
UpdateChoreStatus,
|
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
UpdateLabel,
|
UpdateLabel,
|
||||||
UpdateMemberRole,
|
UpdateMemberRole,
|
||||||
UpdateNotificationTarget,
|
UpdateNotificationTarget,
|
||||||
UpdatePassword,
|
UpdatePassword,
|
||||||
UpdateThingState,
|
UpdateThingState,
|
||||||
|
UpdateTimeSession,
|
||||||
UpdateUserDetails,
|
UpdateUserDetails,
|
||||||
VerifyMFA,
|
VerifyMFA,
|
||||||
createChore,
|
createChore,
|
||||||
|
|||||||
63
src/utils/PlatformUtils.js
Normal file
63
src/utils/PlatformUtils.js
Normal file
@@ -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
|
||||||
|
})
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Network } from '@capacitor/network'
|
|
||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
import murmurhash from 'murmurhash'
|
import murmurhash from 'murmurhash'
|
||||||
@@ -82,11 +81,11 @@ export async function Fetch(url, options) {
|
|||||||
const baseURL = apiManager.getApiURL()
|
const baseURL = apiManager.getApiURL()
|
||||||
const fullURL = `${baseURL}${url}`
|
const fullURL = `${baseURL}${url}`
|
||||||
|
|
||||||
const networkStatus = await Network.getStatus()
|
// const networkStatus = await Network.getStatus()
|
||||||
|
|
||||||
if (!networkStatus.connected) {
|
// if (!networkStatus.connected) {
|
||||||
return handleOfflineRequest(fullURL, options)
|
// return handleOfflineRequest(fullURL, options)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Online: Perform the fetch
|
// Online: Perform the fetch
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Sheet,
|
Sheet,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
@@ -27,8 +28,8 @@ import { apiManager, isTokenValid } from '../../utils/TokenManager'
|
|||||||
import MFAVerificationModal from './MFAVerificationModal'
|
import MFAVerificationModal from './MFAVerificationModal'
|
||||||
|
|
||||||
const LoginView = () => {
|
const LoginView = () => {
|
||||||
// Only fetch user profile if token is valid to prevent unnecessary queries
|
// Use React Query client directly to invalidate the user profile query
|
||||||
// const { data: userProfileData } = useUserProfile()
|
const queryClient = useQueryClient()
|
||||||
const [userProfile, setUserProfile] = useState(null)
|
const [userProfile, setUserProfile] = useState(null)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -78,11 +79,19 @@ const LoginView = () => {
|
|||||||
// Normal login without MFA
|
// Normal login without MFA
|
||||||
localStorage.setItem('ca_token', data.token)
|
localStorage.setItem('ca_token', data.token)
|
||||||
localStorage.setItem('ca_expiration', data.expire)
|
localStorage.setItem('ca_expiration', data.expire)
|
||||||
|
|
||||||
|
// Refetch user profile after successful login
|
||||||
|
queryClient.refetchQueries(['userProfile'])
|
||||||
|
|
||||||
const redirectUrl = Cookies.get('ca_redirect')
|
const redirectUrl = Cookies.get('ca_redirect')
|
||||||
if (redirectUrl) {
|
|
||||||
|
if (redirectUrl && redirectUrl !== '/') {
|
||||||
|
console.log('Redirecting to', redirectUrl)
|
||||||
|
|
||||||
Cookies.remove('ca_redirect')
|
Cookies.remove('ca_redirect')
|
||||||
Navigate(redirectUrl)
|
Navigate(redirectUrl)
|
||||||
} else {
|
} else {
|
||||||
|
Cookies.remove('ca_redirect')
|
||||||
Navigate('/my/chores')
|
Navigate('/my/chores')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -143,6 +152,9 @@ const LoginView = () => {
|
|||||||
localStorage.setItem('ca_token', data.token)
|
localStorage.setItem('ca_token', data.token)
|
||||||
localStorage.setItem('ca_expiration', data.expire)
|
localStorage.setItem('ca_expiration', data.expire)
|
||||||
|
|
||||||
|
// Refetch user profile after successful OAuth login
|
||||||
|
queryClient.invalidateQueries(['userProfile'])
|
||||||
|
|
||||||
const redirectUrl = Cookies.get('ca_redirect')
|
const redirectUrl = Cookies.get('ca_redirect')
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
Cookies.remove('ca_redirect')
|
Cookies.remove('ca_redirect')
|
||||||
@@ -161,9 +173,9 @@ const LoginView = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const getUserProfileAndNavigateToHome = () => {
|
const getUserProfileAndNavigateToHome = () => {
|
||||||
// Refetch user profile after login
|
// Refetch user profile after login using React Query
|
||||||
// refetchUserProfile().then(() => {
|
queryClient.invalidateQueries(['userProfile']).then(() => {
|
||||||
// // check if redirect url is set in cookie:
|
// check if redirect url is set in cookie:
|
||||||
const redirectUrl = Cookies.get('ca_redirect')
|
const redirectUrl = Cookies.get('ca_redirect')
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
Cookies.remove('ca_redirect')
|
Cookies.remove('ca_redirect')
|
||||||
@@ -171,7 +183,7 @@ const LoginView = () => {
|
|||||||
} else {
|
} else {
|
||||||
Navigate('/my/chores')
|
Navigate('/my/chores')
|
||||||
}
|
}
|
||||||
// })
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMFASuccess = data => {
|
const handleMFASuccess = data => {
|
||||||
@@ -180,6 +192,9 @@ const LoginView = () => {
|
|||||||
setMfaModalOpen(false)
|
setMfaModalOpen(false)
|
||||||
setMfaSessionToken('')
|
setMfaSessionToken('')
|
||||||
|
|
||||||
|
// Refetch user profile after MFA success
|
||||||
|
queryClient.invalidateQueries(['userProfile'])
|
||||||
|
|
||||||
const redirectUrl = Cookies.get('ca_redirect')
|
const redirectUrl = Cookies.get('ca_redirect')
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
Cookies.remove('ca_redirect')
|
Cookies.remove('ca_redirect')
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Link,
|
Link,
|
||||||
Modal,
|
|
||||||
ModalClose,
|
ModalClose,
|
||||||
ModalDialog,
|
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import FadeModal from '../../components/common/FadeModal'
|
||||||
import { VerifyMFA } from '../../utils/Fetcher'
|
import { VerifyMFA } from '../../utils/Fetcher'
|
||||||
|
|
||||||
const MFAVerificationModal = ({
|
const MFAVerificationModal = ({
|
||||||
@@ -70,8 +69,7 @@ const MFAVerificationModal = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={handleClose}>
|
<FadeModal open={open} onClose={handleClose} size='sm'>
|
||||||
<ModalDialog size='sm' sx={{ maxWidth: 400 }}>
|
|
||||||
<ModalClose />
|
<ModalClose />
|
||||||
|
|
||||||
<Box className='mb-4 text-center'>
|
<Box className='mb-4 text-center'>
|
||||||
@@ -152,8 +150,7 @@ const MFAVerificationModal = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Stack>
|
</Stack>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ const ChoreEdit = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isChoreLoading === false && choreData && choreId) {
|
if (isChoreLoading === false && choreData && choreId) {
|
||||||
const data = choreData
|
const data = choreData
|
||||||
|
const isCloneMode = searchParams.get('clone') === 'true'
|
||||||
|
|
||||||
setChore(data.res)
|
setChore(data.res)
|
||||||
setName(data.res.name ? data.res.name : '')
|
setName(data.res.name ? data.res.name : '')
|
||||||
@@ -280,7 +281,7 @@ const ChoreEdit = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
setLabelsV2(data.res.labelsV2)
|
setLabelsV2(data.res.labelsV2)
|
||||||
setSubTasks(data.res.subTasks)
|
|
||||||
setPriority(data.res.priority)
|
setPriority(data.res.priority)
|
||||||
setAssignStrategy(
|
setAssignStrategy(
|
||||||
data.res.assignStrategy
|
data.res.assignStrategy
|
||||||
@@ -289,23 +290,30 @@ const ChoreEdit = () => {
|
|||||||
)
|
)
|
||||||
setIsRolling(data.res.isRolling)
|
setIsRolling(data.res.isRolling)
|
||||||
setIsActive(data.res.isActive)
|
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)
|
if (isCloneMode) {
|
||||||
setCreatedBy(data.res.createdBy)
|
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)
|
setIsNotificable(data.res.notification)
|
||||||
setThingTrigger(data.res.thingChore)
|
setThingTrigger(data.res.thingChore)
|
||||||
// setDueDate(data.res.dueDate)
|
// setDueDate(data.res.dueDate)
|
||||||
// setCompleted(data.res.completed)
|
// setCompleted(data.res.completed)
|
||||||
// setCompletedDate(data.res.completedDate)
|
// setCompletedDate(data.res.completedDate)
|
||||||
}
|
}
|
||||||
}, [choreData, isChoreLoading])
|
}, [choreData, isChoreLoading, searchParams])
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// if (userLabels && userLabels.length == 0 && labelsV2.length == 0) {
|
// if (userLabels && userLabels.length == 0 && labelsV2.length == 0) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
OpenInFull,
|
OpenInFull,
|
||||||
PeopleAlt,
|
PeopleAlt,
|
||||||
Person,
|
Person,
|
||||||
|
PlayArrow,
|
||||||
SwitchAccessShortcut,
|
SwitchAccessShortcut,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
@@ -44,9 +45,14 @@ import { useCircleMembers } from '../../queries/UserQueries.jsx'
|
|||||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
import {
|
import {
|
||||||
|
DeleteTimeSession,
|
||||||
GetChoreDetailById,
|
GetChoreDetailById,
|
||||||
|
GetChoreTimer,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
|
PauseChore,
|
||||||
|
ResetChoreTimer,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
|
StartChore,
|
||||||
UpdateChorePriority,
|
UpdateChorePriority,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import Priorities from '../../utils/Priorities'
|
import Priorities from '../../utils/Priorities'
|
||||||
@@ -54,6 +60,8 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
|||||||
import LoadingComponent from '../components/Loading.jsx'
|
import LoadingComponent from '../components/Loading.jsx'
|
||||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
import SubTasks from '../components/SubTask.jsx'
|
import SubTasks from '../components/SubTask.jsx'
|
||||||
|
import TimePassedCard from './TimePassedCard.jsx'
|
||||||
|
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||||
|
|
||||||
const ChoreView = () => {
|
const ChoreView = () => {
|
||||||
const [chore, setChore] = useState({})
|
const [chore, setChore] = useState({})
|
||||||
@@ -73,6 +81,7 @@ const ChoreView = () => {
|
|||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
const [chorePriority, setChorePriority] = useState(null)
|
const [chorePriority, setChorePriority] = useState(null)
|
||||||
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
|
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
|
||||||
|
const [timerActionConfig, setTimerActionConfig] = useState({})
|
||||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||||
useCircleMembers()
|
useCircleMembers()
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
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) {
|
if (isChoreLoading || isCircleMembersLoading) {
|
||||||
// while loading the chore or circle members, return a loading state
|
// while loading the chore or circle members, return a loading state
|
||||||
return <LoadingComponent />
|
return <LoadingComponent />
|
||||||
@@ -298,6 +396,21 @@ const ChoreView = () => {
|
|||||||
mb: 1,
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{chore.status !== 0 && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TimePassedCard
|
||||||
|
chore={chore}
|
||||||
|
handleAction={action => {
|
||||||
|
if (action === 'pause') {
|
||||||
|
handleChorePause()
|
||||||
|
} else if (action === 'resume') {
|
||||||
|
handleChoreStart()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
{infoCards.map((card, index) => (
|
{infoCards.map((card, index) => (
|
||||||
<Grid item xs={6} sm={6} key={index}>
|
<Grid item xs={6} sm={6} key={index}>
|
||||||
<Card
|
<Card
|
||||||
@@ -308,6 +421,7 @@ const ChoreView = () => {
|
|||||||
px: 2,
|
px: 2,
|
||||||
py: 1,
|
py: 1,
|
||||||
minHeight: 90,
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
// change from space-between to start:
|
// change from space-between to start:
|
||||||
justifyContent: 'start',
|
justifyContent: 'start',
|
||||||
}}
|
}}
|
||||||
@@ -527,6 +641,7 @@ const ChoreView = () => {
|
|||||||
>
|
>
|
||||||
<SubTasks
|
<SubTasks
|
||||||
editMode={false}
|
editMode={false}
|
||||||
|
performers={performers}
|
||||||
tasks={chore.subTasks}
|
tasks={chore.subTasks}
|
||||||
setTasks={tasks => {
|
setTasks={tasks => {
|
||||||
setChore({
|
setChore({
|
||||||
@@ -550,7 +665,7 @@ const ChoreView = () => {
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
>
|
>
|
||||||
<Typography level='body-md' sx={{ mb: 1 }}>
|
<Typography level='body-md' sx={{ mb: 1 }}>
|
||||||
Complete the task
|
Completion options
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<FormControl size='sm'>
|
<FormControl size='sm'>
|
||||||
@@ -573,7 +688,7 @@ const ChoreView = () => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add Additional Notes
|
Add a note
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -583,7 +698,7 @@ const ChoreView = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
label='Additional Notes'
|
label='Additional Notes'
|
||||||
placeholder='note or information about the task'
|
placeholder='Add any additional notes here...'
|
||||||
value={note || ''}
|
value={note || ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (e.target.value.trim() === '') {
|
if (e.target.value.trim() === '') {
|
||||||
@@ -626,7 +741,7 @@ const ChoreView = () => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Specify completion date
|
Set custom completion time
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -642,6 +757,15 @@ const ChoreView = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
alignContent: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -649,6 +773,7 @@ const ChoreView = () => {
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
alignContent: 'center',
|
alignContent: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -691,7 +816,8 @@ const ChoreView = () => {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
chore.lastCompletedDate !== null && chore.frequencyType === 'once'
|
chore.lastCompletedDate !== null &&
|
||||||
|
chore.frequencyType === 'once'
|
||||||
}
|
}
|
||||||
startDecorator={<SwitchAccessShortcut />}
|
startDecorator={<SwitchAccessShortcut />}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -701,6 +827,47 @@ const ChoreView = () => {
|
|||||||
<Box>Skip</Box>
|
<Box>Skip</Box>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
|
||||||
|
{chore.status !== 0 ? (
|
||||||
|
<TimerSplitButton
|
||||||
|
disabled={
|
||||||
|
chore.lastCompletedDate !== null &&
|
||||||
|
chore.frequencyType === 'once'
|
||||||
|
}
|
||||||
|
chore={chore}
|
||||||
|
onAction={action => {
|
||||||
|
if (action === 'pause') {
|
||||||
|
handleChorePause()
|
||||||
|
} else if (action === 'resume') {
|
||||||
|
handleChoreStart()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
|
||||||
|
onResetTimer={handleResetTimer}
|
||||||
|
onClearAllTime={handleClearAllTime}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
onClick={() => {
|
||||||
|
handleChoreStart()
|
||||||
|
}}
|
||||||
|
variant='soft'
|
||||||
|
color='success'
|
||||||
|
disabled={
|
||||||
|
chore.lastCompletedDate !== null &&
|
||||||
|
chore.frequencyType === 'once'
|
||||||
|
}
|
||||||
|
startDecorator={<PlayArrow />}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={isPendingCompletion}
|
open={isPendingCompletion}
|
||||||
@@ -728,6 +895,7 @@ const ChoreView = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
<ConfirmationModal config={confirmModelConfig} />
|
<ConfirmationModal config={confirmModelConfig} />
|
||||||
|
<ConfirmationModal config={timerActionConfig} />
|
||||||
</Card>
|
</Card>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
203
src/views/ChoreEdit/TimePassedCard.jsx
Normal file
203
src/views/ChoreEdit/TimePassedCard.jsx
Normal file
@@ -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 (
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
gap: 0,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
height: '75px',
|
||||||
|
alignItems: 'center',
|
||||||
|
...(shouldAnimate && {
|
||||||
|
animation: 'slideInUp 0.3s ease-out',
|
||||||
|
}),
|
||||||
|
'@keyframes slideInUp': {
|
||||||
|
'0%': {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateY(20px) scale(0.95)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'translateY(0) scale(1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: chore.status === 1 ? 'success.main' : 'text.primary',
|
||||||
|
// mb: 0.5,
|
||||||
|
mb: 0.5,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
transform: chore.status === 1 ? 'scale(1.40)' : 'scale(1)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => onShowDetails?.()}
|
||||||
|
>
|
||||||
|
{formatTime(time)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Status and info section */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0 }}>
|
||||||
|
{/* Show start time and user if active */}
|
||||||
|
{chore.status === 1 ? (
|
||||||
|
<Chip
|
||||||
|
variant='soft'
|
||||||
|
color='warning'
|
||||||
|
size='md'
|
||||||
|
startDecorator={<Pause sx={{ fontSize: 14 }} />}
|
||||||
|
onClick={() => {
|
||||||
|
handleAction('pause')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
variant='solid'
|
||||||
|
color='success'
|
||||||
|
size='md'
|
||||||
|
startDecorator={<PlayArrow sx={{ fontSize: 14 }} />}
|
||||||
|
onClick={() => {
|
||||||
|
handleAction('resume')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chips for start time and current session */}
|
||||||
|
{chore.status === 1 && chore.timerUpdatedAt && (
|
||||||
|
<>
|
||||||
|
{/* Original start time */}
|
||||||
|
{chore.startTime && (
|
||||||
|
<Chip
|
||||||
|
variant='plain'
|
||||||
|
color='primary'
|
||||||
|
size='md'
|
||||||
|
startDecorator={<Flag sx={{ fontSize: 14 }} />}
|
||||||
|
>
|
||||||
|
{new Date(chore.startTime).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current session start time */}
|
||||||
|
{chore.timerUpdatedAt !== chore.startTime && (
|
||||||
|
<Chip
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
size='md'
|
||||||
|
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
|
||||||
|
>
|
||||||
|
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chips for paused state */}
|
||||||
|
{chore.status === 2 && (
|
||||||
|
<Chip
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
size='md'
|
||||||
|
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
|
||||||
|
>
|
||||||
|
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimePassedCard
|
||||||
162
src/views/ChoreEdit/TimerSplitButton.jsx
Normal file
162
src/views/ChoreEdit/TimerSplitButton.jsx
Normal file
@@ -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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: fullWidth ? '100%' : 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonGroup
|
||||||
|
variant='soft'
|
||||||
|
color={chore.status === 1 ? 'warning' : 'success'}
|
||||||
|
sx={{
|
||||||
|
'--ButtonGroup-separatorSize': '1px',
|
||||||
|
'--ButtonGroup-connected': '1',
|
||||||
|
width: fullWidth ? '100%' : 'auto',
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{/* Main action button */}
|
||||||
|
<IconButton
|
||||||
|
onClick={handleMainAction}
|
||||||
|
disabled={disabled}
|
||||||
|
size='md'
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 1,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
minWidth: fullWidth ? 'auto' : 120,
|
||||||
|
flex: fullWidth ? 1 : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||||
|
{chore.status === 1 ? 'Pause' : 'Resume'}
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* Dropdown arrow button */}
|
||||||
|
<IconButton
|
||||||
|
onClick={handleMenuOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
size='lg'
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderLeft: '1px solid',
|
||||||
|
borderLeftColor: 'divider',
|
||||||
|
minWidth: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDropDown />
|
||||||
|
</IconButton>
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
<Menu
|
||||||
|
ref={menuRef}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={isMenuOpen}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
placement='bottom-end'
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleShowDetails}>
|
||||||
|
<Info sx={{ mr: 1 }} />
|
||||||
|
Timer Details
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleResetTimer}>
|
||||||
|
<RestartAlt sx={{ mr: 1 }} />
|
||||||
|
Restart timer
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleClearAllTime} color='danger'>
|
||||||
|
<DeleteSweep sx={{ mr: 1 }} />
|
||||||
|
Clear & Reset
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimerSplitButton
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Person,
|
Person,
|
||||||
Redo,
|
Redo,
|
||||||
Refresh,
|
Refresh,
|
||||||
|
Timelapse,
|
||||||
Toll,
|
Toll,
|
||||||
WatchLater,
|
WatchLater,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
@@ -32,9 +33,9 @@ const ActivityItem = ({ activity, members }) => {
|
|||||||
member => member.userId === activity.completedBy,
|
member => member.userId === activity.completedBy,
|
||||||
)
|
)
|
||||||
|
|
||||||
const getTimeDisplay = performedAt => {
|
const getTimeDisplay = dateToDisplay => {
|
||||||
const now = moment()
|
const now = moment()
|
||||||
const completed = moment(performedAt)
|
const completed = moment(dateToDisplay)
|
||||||
const diffInHours = now.diff(completed, 'hours')
|
const diffInHours = now.diff(completed, 'hours')
|
||||||
const diffInDays = now.diff(completed, 'days')
|
const diffInDays = now.diff(completed, 'days')
|
||||||
|
|
||||||
@@ -50,6 +51,13 @@ const ActivityItem = ({ activity, members }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusInfo = activity => {
|
const getStatusInfo = activity => {
|
||||||
|
if (activity.status === 0) {
|
||||||
|
return {
|
||||||
|
color: 'primary',
|
||||||
|
text: 'Started',
|
||||||
|
icon: <Timelapse />,
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!activity.status === 1) {
|
if (!activity.status === 1) {
|
||||||
return {
|
return {
|
||||||
color: 'neutral',
|
color: 'neutral',
|
||||||
@@ -105,7 +113,11 @@ const ActivityItem = ({ activity, members }) => {
|
|||||||
{activity.choreName}
|
{activity.choreName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-xs' color='text.secondary'>
|
<Typography level='body-xs' color='text.secondary'>
|
||||||
{getTimeDisplay(activity.performedAt)}
|
{getTimeDisplay(
|
||||||
|
activity.performedAt ||
|
||||||
|
activity.updatedAt ||
|
||||||
|
activity.createdAt,
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -127,18 +139,6 @@ const ActivityItem = ({ activity, members }) => {
|
|||||||
completedByMember?.name ||
|
completedByMember?.name ||
|
||||||
'Unknown'}
|
'Unknown'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Status, Points, and Notes */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 0.5,
|
|
||||||
mt: 0.5,
|
|
||||||
ml: 2.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Points chip */}
|
{/* Points chip */}
|
||||||
{activity.points && activity.points > 0 && (
|
{activity.points && activity.points > 0 && (
|
||||||
<Chip
|
<Chip
|
||||||
@@ -152,6 +152,17 @@ const ActivityItem = ({ activity, members }) => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Status, Points, and Notes */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 0.5,
|
||||||
|
mt: 0.5,
|
||||||
|
ml: 2.5,
|
||||||
|
}}
|
||||||
|
></Box>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{activity.notes && (
|
{activity.notes && (
|
||||||
<Box sx={{ mt: 0.5, ml: 2.5 }}>
|
<Box sx={{ mt: 0.5, ml: 2.5 }}>
|
||||||
@@ -180,7 +191,9 @@ const groupActivitiesByDate = activities => {
|
|||||||
const groups = {}
|
const groups = {}
|
||||||
|
|
||||||
activities.forEach(activity => {
|
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]) {
|
if (!groups[date]) {
|
||||||
groups[date] = []
|
groups[date] = []
|
||||||
}
|
}
|
||||||
@@ -270,7 +283,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
|||||||
const sortedHistory = enrichedHistory
|
const sortedHistory = enrichedHistory
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(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
|
.slice(0, 10) // Show only latest 10 activities
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
CancelScheduleSend,
|
CancelScheduleSend,
|
||||||
Check,
|
Check,
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
|
Pause,
|
||||||
|
PlayArrow,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Schedule,
|
||||||
TimesOneMobiledata,
|
TimesOneMobiledata,
|
||||||
Toll,
|
Toll,
|
||||||
Webhook,
|
Webhook,
|
||||||
@@ -30,6 +35,8 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
|||||||
import {
|
import {
|
||||||
DeleteChore,
|
DeleteChore,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
|
PauseChore,
|
||||||
|
StartChore,
|
||||||
UpdateChoreAssignee,
|
UpdateChoreAssignee,
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
@@ -74,6 +81,25 @@ const ChoreCard = ({
|
|||||||
|
|
||||||
const { showError } = useNotification()
|
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 = () => {
|
const handleDelete = () => {
|
||||||
setConfirmModelConfig({
|
setConfirmModelConfig({
|
||||||
isOpen: true,
|
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 => {
|
const getDueDateChipText = nextDueDate => {
|
||||||
if (chore.nextDueDate === null) return 'No Due Date'
|
if (chore.nextDueDate === null) return 'No Due Date'
|
||||||
// if due in next 48 hours, we should it in this format : Tomorrow 11:00 AM
|
// if due in next 48 hours, we should it in this format : Tomorrow 11:00 AM
|
||||||
@@ -358,7 +585,7 @@ const ChoreCard = ({
|
|||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
top: 10,
|
top: 10,
|
||||||
zIndex: 1,
|
zIndex: 3,
|
||||||
left: 10,
|
left: 10,
|
||||||
}}
|
}}
|
||||||
color={getDueDateChipColor(chore.nextDueDate)}
|
color={getDueDateChipColor(chore.nextDueDate)}
|
||||||
@@ -371,7 +598,7 @@ const ChoreCard = ({
|
|||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
top: 10,
|
top: 10,
|
||||||
zIndex: 1,
|
zIndex: 3,
|
||||||
ml: 0.4,
|
ml: 0.4,
|
||||||
left: 10,
|
left: 10,
|
||||||
}}
|
}}
|
||||||
@@ -388,7 +615,117 @@ const ChoreCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 20,
|
||||||
|
}}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{/* Action buttons underneath (revealed on swipe) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: maxSwipeDistance,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||||
|
zIndex: 0,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
borderBottomRightRadius: 20,
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleActionAreaMouseEnter}
|
||||||
|
onMouseLeave={handleActionAreaMouseLeave}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='success'
|
||||||
|
size='md'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
|
||||||
|
if (chore.status !== 0) {
|
||||||
|
handleTaskCompletion()
|
||||||
|
} else {
|
||||||
|
handleChoreStart()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chore.status !== 0 ? (
|
||||||
|
<Check sx={{ fontSize: 20 }} />
|
||||||
|
) : (
|
||||||
|
<PlayArrow sx={{ fontSize: 20 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='warning'
|
||||||
|
size='md'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
setIsChangeDueDateModalOpen(true)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Schedule sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
size='md'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
navigate(`/chores/${chore.id}/edit`)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='danger'
|
||||||
|
size='md'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
handleDelete()
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
ref={cardRef}
|
||||||
style={viewOnly ? { pointerEvents: 'none' } : {}}
|
style={viewOnly ? { pointerEvents: 'none' } : {}}
|
||||||
variant='plain'
|
variant='plain'
|
||||||
sx={{
|
sx={{
|
||||||
@@ -404,10 +741,12 @@ const ChoreCard = ({
|
|||||||
backgroundColor: 'background.surface',
|
backgroundColor: 'background.surface',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
transition: 'all 0.2s ease-in-out',
|
transform: `translateX(${swipeTranslateX}px)`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||||
|
zIndex: 1,
|
||||||
cursor: isMultiSelectMode ? 'pointer' : 'default',
|
cursor: isMultiSelectMode ? 'pointer' : 'default',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: 'md',
|
boxShadow: isSwipeRevealed ? 'sm' : 'md',
|
||||||
borderColor: isMultiSelectMode ? 'primary.500' : 'primary.300',
|
borderColor: isMultiSelectMode ? 'primary.500' : 'primary.300',
|
||||||
},
|
},
|
||||||
// Add padding when in multi-select mode to account for checkbox
|
// Add padding when in multi-select mode to account for checkbox
|
||||||
@@ -420,6 +759,12 @@ const ChoreCard = ({
|
|||||||
boxShadow: 'sm',
|
boxShadow: 'sm',
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
>
|
>
|
||||||
{/* Multi-select checkbox */}
|
{/* Multi-select checkbox */}
|
||||||
{isMultiSelectMode && (
|
{isMultiSelectMode && (
|
||||||
@@ -470,13 +815,23 @@ const ChoreCard = ({
|
|||||||
{Array.from(chore.name)[0]}
|
{Array.from(chore.name)[0]}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box display='flex' flexDirection='column'>
|
<Box display='flex' flexDirection='column'>
|
||||||
<Typography level='title-md'>{getName(chore.name)}</Typography>
|
<Typography level='title-md'>
|
||||||
|
{getName(chore.name)}
|
||||||
|
</Typography>
|
||||||
{userProfile && chore.assignedTo !== userProfile.id && (
|
{userProfile && chore.assignedTo !== userProfile.id && (
|
||||||
<Box display='flex' alignItems='center' gap={0.5}>
|
<Box display='flex' alignItems='center' gap={0.5}>
|
||||||
<Typography level='body-md' color='text.disabled'>
|
<Chip
|
||||||
Assigned to
|
variant='outlined'
|
||||||
</Typography>
|
startDecorator={
|
||||||
<Chip variant='outlined'>
|
<Avatar
|
||||||
|
src={
|
||||||
|
performers.find(
|
||||||
|
p => p.userId === chore.assignedTo,
|
||||||
|
)?.image
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
performers.find(p => p.userId === chore.assignedTo)
|
performers.find(p => p.userId === chore.assignedTo)
|
||||||
?.displayName
|
?.displayName
|
||||||
@@ -588,22 +943,60 @@ const ChoreCard = ({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box display='flex' justifyContent='flex-end' alignItems='flex-end'>
|
<Box
|
||||||
|
display='flex'
|
||||||
|
justifyContent='flex-end'
|
||||||
|
alignItems='flex-end'
|
||||||
|
>
|
||||||
{/* <ButtonGroup> */}
|
{/* <ButtonGroup> */}
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='solid'
|
variant={chore.status === 0 ? 'solid' : 'soft'}
|
||||||
color='success'
|
color={chore.status === 0 ? 'success' : 'warning'}
|
||||||
onClick={handleTaskCompletion}
|
onClick={e => {
|
||||||
|
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)}
|
disabled={isPendingCompletion || notInCompletionWindow(chore)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
transform: 'scale(0.95)',
|
||||||
|
},
|
||||||
|
'&:disabled': {
|
||||||
|
opacity: 0.5,
|
||||||
|
transform: 'none',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className='relative grid place-items-center'>
|
<div className='relative grid place-items-center'>
|
||||||
|
{isPendingCompletion ? (
|
||||||
|
<CircularProgress size='md' />
|
||||||
|
) : chore.status === 0 ? (
|
||||||
<Check />
|
<Check />
|
||||||
|
) : chore.status === 1 ? (
|
||||||
|
<Pause />
|
||||||
|
) : (
|
||||||
|
<PlayArrow />
|
||||||
|
)}
|
||||||
{isPendingCompletion && (
|
{isPendingCompletion && (
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
variant='solid'
|
variant='solid'
|
||||||
@@ -622,7 +1015,9 @@ const ChoreCard = ({
|
|||||||
chore={chore}
|
chore={chore}
|
||||||
onChoreUpdate={onChoreUpdate}
|
onChoreUpdate={onChoreUpdate}
|
||||||
onChoreRemove={onChoreRemove}
|
onChoreRemove={onChoreRemove}
|
||||||
onCompleteWithNote={() => setIsCompleteWithNoteModalOpen(true)}
|
onCompleteWithNote={() =>
|
||||||
|
setIsCompleteWithNoteModalOpen(true)
|
||||||
|
}
|
||||||
onCompleteWithPastDate={() =>
|
onCompleteWithPastDate={() =>
|
||||||
setIsCompleteWithPastDateModalOpen(true)
|
setIsCompleteWithPastDateModalOpen(true)
|
||||||
}
|
}
|
||||||
@@ -630,6 +1025,14 @@ const ChoreCard = ({
|
|||||||
onChangeDueDate={() => setIsChangeDueDateModalOpen(true)}
|
onChangeDueDate={() => setIsChangeDueDateModalOpen(true)}
|
||||||
onWriteNFC={() => setIsNFCModalOpen(true)}
|
onWriteNFC={() => setIsNFCModalOpen(true)}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onOpen={() => {
|
||||||
|
// Clear any pending hide timer when menu opens
|
||||||
|
if (hoverTimer) {
|
||||||
|
clearTimeout(hoverTimer)
|
||||||
|
setHoverTimer(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -688,7 +1091,8 @@ const ChoreCard = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={isPendingCompletion}
|
open={isPendingCompletion}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
@@ -714,7 +1118,6 @@ const ChoreCard = ({
|
|||||||
Task will be marked as completed in {secondsLeftToCancel} seconds
|
Task will be marked as completed in {secondsLeftToCancel} seconds
|
||||||
</Typography>
|
</Typography>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
</Card>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
CancelScheduleSend,
|
CancelScheduleSend,
|
||||||
Check,
|
Check,
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
|
Pause,
|
||||||
|
PlayArrow,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Schedule,
|
||||||
TimesOneMobiledata,
|
TimesOneMobiledata,
|
||||||
Webhook,
|
Webhook,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
@@ -29,6 +34,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
DeleteChore,
|
DeleteChore,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
|
PauseChore,
|
||||||
|
StartChore,
|
||||||
UpdateChoreAssignee,
|
UpdateChoreAssignee,
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
@@ -73,6 +80,196 @@ const CompactChoreCard = ({
|
|||||||
|
|
||||||
const { showError } = useNotification()
|
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)
|
// All the existing handler methods (same as original ChoreCard)
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setConfirmModelConfig({
|
setConfirmModelConfig({
|
||||||
@@ -385,29 +582,184 @@ const CompactChoreCard = ({
|
|||||||
return TASK_COLOR.NO_PRIORITY
|
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 (
|
return (
|
||||||
<Box key={chore.id + '-compact-box'}>
|
<Box key={chore.id + '-compact-box'}>
|
||||||
<Box
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
'&:last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{/* Action buttons underneath (revealed on swipe) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: maxSwipeDistance,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
// soft background color for the swipe area
|
||||||
|
// bgcolor: 'background.backdrop',
|
||||||
|
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleActionAreaMouseEnter}
|
||||||
|
onMouseLeave={handleActionAreaMouseLeave}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='success'
|
||||||
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
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 ? (
|
||||||
|
<PlayArrow sx={{ fontSize: 16 }} />
|
||||||
|
) : (
|
||||||
|
<Check sx={{ fontSize: 16 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='warning'
|
||||||
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
setIsChangeDueDateModalOpen(true)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
// bgcolor: 'warning.100',
|
||||||
|
// color: 'warning.600',
|
||||||
|
// '&:hover': {
|
||||||
|
// bgcolor: 'warning.200',
|
||||||
|
// },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Schedule sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
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',
|
||||||
|
// },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='danger'
|
||||||
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
handleDelete()
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main card content */}
|
||||||
|
<Box
|
||||||
|
ref={cardRef}
|
||||||
style={viewOnly ? { pointerEvents: 'none' } : {}}
|
style={viewOnly ? { pointerEvents: 'none' } : {}}
|
||||||
sx={{
|
sx={{
|
||||||
...sx,
|
...sx,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minHeight: 56, // More compact height
|
minHeight: 56,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
pl: '16px', // Consistent padding since both elements are in the same position
|
pl: '16px',
|
||||||
// backgroundColor: 'background.surface',
|
bgcolor: 'background.body',
|
||||||
transition: 'all 0.2s ease-in-out',
|
transform: `translateX(${swipeTranslateX}px)`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||||
|
zIndex: 1,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: 'background.level1',
|
bgcolor: isSwipeRevealed
|
||||||
boxShadow: 'sm',
|
? 'background.surface'
|
||||||
},
|
: 'background.level1',
|
||||||
'&:last-child': {
|
boxShadow: isSwipeRevealed ? 'none' : 'sm',
|
||||||
borderBottom: 'none',
|
|
||||||
},
|
},
|
||||||
'&::before': {
|
'&::before': {
|
||||||
content: '""',
|
content: '""',
|
||||||
@@ -421,12 +773,23 @@ const CompactChoreCard = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isSwipeRevealed) {
|
||||||
|
resetSwipe()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isMultiSelectMode) {
|
if (isMultiSelectMode) {
|
||||||
onSelectionToggle()
|
onSelectionToggle()
|
||||||
} else {
|
} else {
|
||||||
navigate(`/chores/${chore.id}`)
|
navigate(`/chores/${chore.id}`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
// onMouseEnter={handleMouseEnter}
|
||||||
>
|
>
|
||||||
{/* Priority bar clickable area */}
|
{/* Priority bar clickable area */}
|
||||||
{chore.priority > 0 && (
|
{chore.priority > 0 && (
|
||||||
@@ -482,11 +845,17 @@ const CompactChoreCard = ({
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='success'
|
color={chore.status === 0 ? 'success' : 'warning'}
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
if (chore.status === 0) {
|
||||||
handleTaskCompletion()
|
handleTaskCompletion()
|
||||||
|
} else if (chore.status === 1) {
|
||||||
|
handleChorePause()
|
||||||
|
} else {
|
||||||
|
handleChoreStart()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isPendingCompletion || notInCompletionWindow(chore)}
|
disabled={isPendingCompletion || notInCompletionWindow(chore)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -509,8 +878,12 @@ const CompactChoreCard = ({
|
|||||||
>
|
>
|
||||||
{isPendingCompletion ? (
|
{isPendingCompletion ? (
|
||||||
<CircularProgress size='sm' />
|
<CircularProgress size='sm' />
|
||||||
) : (
|
) : chore.status === 0 ? (
|
||||||
<Check sx={{ fontSize: 16 }} />
|
<Check sx={{ fontSize: 16 }} />
|
||||||
|
) : chore.status === 1 ? (
|
||||||
|
<Pause sx={{ fontSize: 16 }} />
|
||||||
|
) : (
|
||||||
|
<PlayArrow sx={{ fontSize: 16 }} />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -704,6 +1077,8 @@ const CompactChoreCard = ({
|
|||||||
onChangeDueDate={() => setIsChangeDueDateModalOpen(true)}
|
onChangeDueDate={() => setIsChangeDueDateModalOpen(true)}
|
||||||
onWriteNFC={() => setIsNFCModalOpen(true)}
|
onWriteNFC={() => setIsNFCModalOpen(true)}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
// onMouseLeave={handleMouseLeave}
|
||||||
sx={{
|
sx={{
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -714,9 +1089,13 @@ const CompactChoreCard = ({
|
|||||||
bgcolor: 'background.level1',
|
bgcolor: 'background.level1',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
onOpen={() => {
|
||||||
|
handleMouseLeave()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* All modals (same as original) */}
|
{/* All modals (same as original) */}
|
||||||
<DateModal
|
<DateModal
|
||||||
|
|||||||
@@ -1,128 +1,216 @@
|
|||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||||
import { Preferences } from '@capacitor/preferences';
|
import { Preferences } from '@capacitor/preferences'
|
||||||
|
import murmurhash from 'murmurhash'
|
||||||
|
|
||||||
const getNotificationPreferences = async () => {
|
const getNotificationPreferences = async () => {
|
||||||
const ret = await Preferences.get({ key: 'notificationPreferences' });
|
const ret = await Preferences.get({ key: 'notificationPreferences' })
|
||||||
return JSON.parse(ret.value);
|
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 canScheduleNotification = async () => {
|
||||||
|
if (Capacitor.isNativePlatform() === false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const notificationPreferences = await getNotificationPreferences()
|
||||||
|
console.log('Notification preferences:', notificationPreferences)
|
||||||
|
|
||||||
const scheduleChoreNotification = async (chores, userProfile,allPerformers) => {
|
if (notificationPreferences['granted'] === false) {
|
||||||
// for each chore will create local notification:
|
return false
|
||||||
const notifications = [];
|
}
|
||||||
|
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 now = new Date()
|
||||||
|
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 devicePreferences = await getNotificationPreferences();
|
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`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
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++) {
|
for (let i = 0; i < chores.length; i++) {
|
||||||
|
const chore = chores[i]
|
||||||
const chore = chores[i];
|
try {
|
||||||
const chorePreferences = JSON.parse(chore.notificationMetadata)
|
if (chore.notification === false || chore.nextDueDate === null) {
|
||||||
if ( chore.notification ===false || chore.nextDueDate === null) {
|
continue
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
scheduleDueNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
|
scheduleNotificationFromTemplate(
|
||||||
schedulePreDueNotification(chore, userProfile, allPerformers,chorePreferences, devicePreferences,notifications)
|
chore,
|
||||||
scheduleNaggingNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
|
userProfile,
|
||||||
|
allPerformers,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Error parsing notification metadata for chore:',
|
||||||
|
chore.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LocalNotifications.schedule({
|
LocalNotifications.schedule({
|
||||||
notifications,
|
notifications,
|
||||||
});
|
})
|
||||||
|
return notifications
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
|
export { canScheduleNotification, scheduleChoreNotification }
|
||||||
|
|
||||||
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 }
|
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
IconButton,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import FadeModal from '../../components/common/FadeModal'
|
||||||
|
|
||||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||||
const [isHelpOpen, setIsHelpOpen] = useState(false)
|
const [isHelpOpen, setIsHelpOpen] = useState(false)
|
||||||
@@ -40,15 +32,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Help Modal */}
|
{/* Help Modal */}
|
||||||
<Modal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
<FadeModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
||||||
<ModalDialog
|
|
||||||
variant='outlined'
|
|
||||||
size='md'
|
|
||||||
sx={{
|
|
||||||
maxWidth: 500,
|
|
||||||
p: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -69,19 +53,14 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
<Close />
|
<Close />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
|
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
|
||||||
Use these keyboard shortcuts to work more efficiently with multiple
|
Use these keyboard shortcuts to work more efficiently with multiple
|
||||||
tasks:
|
tasks:
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{/* Selection shortcuts */}
|
{/* Selection shortcuts */}
|
||||||
<Card variant='soft' sx={{ p: 2 }}>
|
<Card variant='soft' sx={{ p: 2 }}>
|
||||||
<Typography
|
<Typography level='title-sm' sx={{ mb: 1.5, color: 'primary.600' }}>
|
||||||
level='title-sm'
|
|
||||||
sx={{ mb: 1.5, color: 'primary.600' }}
|
|
||||||
>
|
|
||||||
Selection
|
Selection
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
@@ -98,10 +77,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
|
|
||||||
{/* Action shortcuts */}
|
{/* Action shortcuts */}
|
||||||
<Card variant='soft' sx={{ p: 2 }}>
|
<Card variant='soft' sx={{ p: 2 }}>
|
||||||
<Typography
|
<Typography level='title-sm' sx={{ mb: 1.5, color: 'success.600' }}>
|
||||||
level='title-sm'
|
|
||||||
sx={{ mb: 1.5, color: 'success.600' }}
|
|
||||||
>
|
|
||||||
Actions
|
Actions
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
@@ -118,10 +94,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
|
|
||||||
{/* Interface shortcuts */}
|
{/* Interface shortcuts */}
|
||||||
<Card variant='soft' sx={{ p: 2 }}>
|
<Card variant='soft' sx={{ p: 2 }}>
|
||||||
<Typography
|
<Typography level='title-sm' sx={{ mb: 1.5, color: 'warning.600' }}>
|
||||||
level='title-sm'
|
|
||||||
sx={{ mb: 1.5, color: 'warning.600' }}
|
|
||||||
>
|
|
||||||
Interface
|
Interface
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
@@ -132,9 +105,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Button
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
@@ -144,8 +115,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
Got it!
|
Got it!
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,9 +129,9 @@ const ShortcutItem = ({ keys, description }) => (
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body-sm' sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
{description}
|
<Typography level='body-sm'>{description}</Typography>
|
||||||
</Typography>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
{keys.map((key, index) => (
|
{keys.map((key, index) => (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import CompactChoreCard from './CompactChoreCard'
|
|||||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||||
import MultiSelectHelp from './MultiSelectHelp'
|
import MultiSelectHelp from './MultiSelectHelp'
|
||||||
|
|
||||||
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||||
@@ -67,7 +68,7 @@ import SortAndGrouping from './SortAndGrouping'
|
|||||||
const MyChores = () => {
|
const MyChores = () => {
|
||||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||||
useUserProfile()
|
useUserProfile()
|
||||||
const { showSuccess, showError } = useNotification()
|
const { showSuccess, showError, showWarning } = useNotification()
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
const [chores, setChores] = useState([])
|
const [chores, setChores] = useState([])
|
||||||
const [archivedChores, setArchivedChores] = useState(null)
|
const [archivedChores, setArchivedChores] = useState(null)
|
||||||
@@ -102,15 +103,16 @@ const MyChores = () => {
|
|||||||
data: choresData,
|
data: choresData,
|
||||||
isLoading: choresLoading,
|
isLoading: choresLoading,
|
||||||
refetch: refetchChores,
|
refetch: refetchChores,
|
||||||
} = useChores()
|
} = useChores(false)
|
||||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||||
|
|
||||||
// Multi-select state
|
// Multi-select state
|
||||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
if (!choresLoading && !membersLoading && userProfile) {
|
if (!choresLoading && !membersLoading && userProfile) {
|
||||||
setPerformers(membersData.res)
|
setPerformers(membersData.res)
|
||||||
const sortedChores = choresData.res.sort(ChoreSorter)
|
const sortedChores = choresData.res.sort(ChoreSorter)
|
||||||
@@ -132,10 +134,16 @@ const MyChores = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canScheduleNotification()) {
|
if (await canScheduleNotification()) {
|
||||||
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
|
console.log('Scheduling chore notifications...')
|
||||||
|
scheduleChoreNotification(
|
||||||
|
choresData.res,
|
||||||
|
userProfile,
|
||||||
|
membersData.res,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
}, [
|
}, [
|
||||||
membersLoading,
|
membersLoading,
|
||||||
choresLoading,
|
choresLoading,
|
||||||
@@ -164,20 +172,45 @@ const MyChores = () => {
|
|||||||
// Keyboard shortcuts for multi-select and other actions
|
// Keyboard shortcuts for multi-select and other actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
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
|
// Ctrl/Cmd + K to open task modal
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setAddTaskModalOpen(true)
|
setAddTaskModalOpen(true)
|
||||||
return
|
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:
|
// Ctrl/Cmd + F to focus search input:
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
|
else if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
searchInputRef.current?.focus()
|
searchInputRef.current?.focus()
|
||||||
return
|
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
|
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||||
else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -297,14 +330,117 @@ const MyChores = () => {
|
|||||||
handleBulkComplete()
|
handleBulkComplete()
|
||||||
return
|
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('keydown', handleKeyDown)
|
||||||
|
document.addEventListener('keyup', handleKeyUp)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.removeEventListener('keyup', handleKeyUp)
|
||||||
}
|
}
|
||||||
}, [isMultiSelectMode, selectedChores.size])
|
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen])
|
||||||
const setSelectedChoreSectionWithCache = value => {
|
const setSelectedChoreSectionWithCache = value => {
|
||||||
setSelectedChoreSection(value)
|
setSelectedChoreSection(value)
|
||||||
localStorage.setItem('selectedChoreSection', value)
|
localStorage.setItem('selectedChoreSection', value)
|
||||||
@@ -471,6 +607,19 @@ const MyChores = () => {
|
|||||||
'The task has been archived and hidden from the active list.',
|
'The task has been archived and hidden from the active list.',
|
||||||
})
|
})
|
||||||
break
|
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:
|
default:
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: 'Task Updated',
|
title: 'Task Updated',
|
||||||
@@ -506,7 +655,7 @@ const MyChores = () => {
|
|||||||
const fuse = new Fuse(
|
const fuse = new Fuse(
|
||||||
chores.map(c => ({
|
chores.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
raw_label: c.labelsV2.map(c => c.name).join(' '),
|
raw_label: c.labelsV2?.map(c => c.name).join(' '),
|
||||||
})),
|
})),
|
||||||
searchOptions,
|
searchOptions,
|
||||||
)
|
)
|
||||||
@@ -526,6 +675,12 @@ const MyChores = () => {
|
|||||||
setSearchTerm(term)
|
setSearchTerm(term)
|
||||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
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
|
// Multi-select helper functions
|
||||||
const toggleMultiSelectMode = () => {
|
const toggleMultiSelectMode = () => {
|
||||||
@@ -744,9 +899,18 @@ const MyChores = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const deletedIds = new Set(deletedTasks.map(c => c.id))
|
const deletedIds = new Set(deletedTasks.map(c => c.id))
|
||||||
setChores(chores.filter(c => !deletedIds.has(c.id)))
|
const newChores = chores.filter(c => !deletedIds.has(c.id))
|
||||||
setFilteredChores(
|
const newFilteredChores = filteredChores.filter(
|
||||||
filteredChores.filter(c => !deletedIds.has(c.id)),
|
c => !deletedIds.has(c.id),
|
||||||
|
)
|
||||||
|
setChores(newChores)
|
||||||
|
setFilteredChores(newFilteredChores)
|
||||||
|
setChoreSections(
|
||||||
|
ChoresGrouper(
|
||||||
|
selectedChoreSection,
|
||||||
|
newChores,
|
||||||
|
ChoreFilters(userProfile)[selectedChoreFilter],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,15 +1034,21 @@ const MyChores = () => {
|
|||||||
padding: 1,
|
padding: 1,
|
||||||
}}
|
}}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
|
startDecorator={
|
||||||
|
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||||
|
}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
searchTerm && (
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<CancelRounded
|
{searchTerm && (
|
||||||
onClick={() => {
|
<>
|
||||||
setSearchTerm('')
|
<KeyboardShortcutHint
|
||||||
setFilteredChores(chores)
|
shortcut='X'
|
||||||
}}
|
show={showKeyboardShortcuts}
|
||||||
/>
|
/>
|
||||||
)
|
<CancelRounded onClick={handleSearchClose} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -963,6 +1133,7 @@ const MyChores = () => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Multi-select Toggle Button */}
|
{/* Multi-select Toggle Button */}
|
||||||
|
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||||
@@ -975,12 +1146,23 @@ const MyChores = () => {
|
|||||||
onClick={toggleMultiSelectMode}
|
onClick={toggleMultiSelectMode}
|
||||||
title={
|
title={
|
||||||
isMultiSelectMode
|
isMultiSelectMode
|
||||||
? 'Exit Multi-select Mode'
|
? 'Exit Multi-select Mode (Ctrl+S)'
|
||||||
: 'Enable Multi-select Mode'
|
: 'Enable Multi-select Mode (Ctrl+S)'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='S'
|
||||||
|
show={showKeyboardShortcuts}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Search Filter with animation */}
|
{/* Search Filter with animation */}
|
||||||
@@ -1201,9 +1383,22 @@ const MyChores = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
minWidth: 'auto',
|
minWidth: 'auto',
|
||||||
'--Button-paddingInline': '0.75rem',
|
'--Button-paddingInline': '0.75rem',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
title='Select all visible tasks (Ctrl+A)'
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
|
{showKeyboardShortcuts && (
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='A'
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -1219,9 +1414,23 @@ const MyChores = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
minWidth: 'auto',
|
minWidth: 'auto',
|
||||||
'--Button-paddingInline': '0.75rem',
|
'--Button-paddingInline': '0.75rem',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
|
||||||
>
|
>
|
||||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||||
|
{showKeyboardShortcuts && (
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
withCtrl={false}
|
||||||
|
shortcut='Esc'
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1251,9 +1460,22 @@ const MyChores = () => {
|
|||||||
disabled={selectedChores.size === 0}
|
disabled={selectedChores.size === 0}
|
||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
title='Complete selected tasks (Enter)'
|
||||||
>
|
>
|
||||||
Complete
|
Complete
|
||||||
|
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='Enter'
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -1264,9 +1486,22 @@ const MyChores = () => {
|
|||||||
disabled={selectedChores.size === 0}
|
disabled={selectedChores.size === 0}
|
||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
title='Skip selected tasks (/)'
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
|
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='/'
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -1277,9 +1512,22 @@ const MyChores = () => {
|
|||||||
disabled={selectedChores.size === 0}
|
disabled={selectedChores.size === 0}
|
||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
title='Archive selected tasks (X)'
|
||||||
>
|
>
|
||||||
Archive
|
Archive
|
||||||
|
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='X'
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -1291,9 +1539,23 @@ const MyChores = () => {
|
|||||||
disabled={selectedChores.size === 0}
|
disabled={selectedChores.size === 0}
|
||||||
sx={{
|
sx={{
|
||||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
title='Delete selected tasks (Shift+X)'
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
withShift={true}
|
||||||
|
shortcut='X'
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
@@ -1473,6 +1735,12 @@ const MyChores = () => {
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
startDecorator={<Unarchive />}
|
startDecorator={<Unarchive />}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='O'
|
||||||
|
show={showKeyboardShortcuts}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Show Archived
|
Show Archived
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1522,12 +1790,24 @@ const MyChores = () => {
|
|||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
zIndex: 101,
|
zIndex: 101,
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
Navigate(`/chores/create`)
|
Navigate(`/chores/create`)
|
||||||
}}
|
}}
|
||||||
|
title='Create new chore (Cmd+C)'
|
||||||
>
|
>
|
||||||
<Add />
|
<Add />
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
show={showKeyboardShortcuts}
|
||||||
|
shortcut='J'
|
||||||
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
color='primary'
|
color='primary'
|
||||||
@@ -1550,6 +1830,12 @@ const MyChores = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
sx={{ position: 'relative', left: -40, top: 30 }}
|
||||||
|
show={showKeyboardShortcuts}
|
||||||
|
shortcut='K'
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<NotificationAccessSnackbar />
|
<NotificationAccessSnackbar />
|
||||||
{addTaskModalOpen && (
|
{addTaskModalOpen && (
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||||
import { Preferences } from '@capacitor/preferences'
|
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'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
const NotificationAccessSnackbar = () => {
|
const NotificationAccessSnackbar = () => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
if (!Capacitor.isNativePlatform()) {
|
// Define the function outside of useEffect
|
||||||
return null
|
|
||||||
}
|
|
||||||
const getNotificationPreferences = async () => {
|
const getNotificationPreferences = async () => {
|
||||||
const ret = await Preferences.get({ key: 'notificationPreferences' })
|
const ret = await Preferences.get({ key: 'notificationPreferences' })
|
||||||
return JSON.parse(ret.value)
|
return JSON.parse(ret.value) || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only run the effect on native platforms
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
getNotificationPreferences().then(data => {
|
getNotificationPreferences().then(data => {
|
||||||
// if optOut is true then don't show the snackbar
|
// if optOut is true then don't show the snackbar
|
||||||
if (data?.optOut === true || data?.granted === true) {
|
if (data?.optOut === true || data?.granted === true) {
|
||||||
@@ -23,8 +23,14 @@ const NotificationAccessSnackbar = () => {
|
|||||||
}
|
}
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Return early if not on a native platform
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Snackbar
|
<Snackbar
|
||||||
// autoHideDuration={5000}
|
// autoHideDuration={5000}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import IconButton from '@mui/joy/IconButton'
|
import IconButton from '@mui/joy/IconButton'
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
|
|
||||||
const SortAndGrouping = ({
|
const SortAndGrouping = ({
|
||||||
@@ -100,6 +100,7 @@ const SortAndGrouping = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
{[
|
{[
|
||||||
|
{ name: 'Smart', value: 'default' },
|
||||||
{ name: 'Due Date', value: 'due_date' },
|
{ name: 'Due Date', value: 'due_date' },
|
||||||
{ name: 'Priority', value: 'priority' },
|
{ name: 'Priority', value: 'priority' },
|
||||||
{ name: 'Labels', value: 'labels' },
|
{ name: 'Labels', value: 'labels' },
|
||||||
|
|||||||
@@ -104,12 +104,16 @@ const ChoreHistory = () => {
|
|||||||
{
|
{
|
||||||
icon: <Timelapse />,
|
icon: <Timelapse />,
|
||||||
text: 'Usually Within',
|
text: 'Usually Within',
|
||||||
subtext: moment.duration(averageDelayMoment).humanize(),
|
subtext: moment.duration(averageDelayMoment).isValid()
|
||||||
|
? moment.duration(averageDelayMoment).humanize()
|
||||||
|
: '--',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Timelapse />,
|
icon: <Timelapse />,
|
||||||
text: 'Maximum Delay',
|
text: 'Maximum Delay',
|
||||||
subtext: moment.duration(maxDelayMoment).humanize(),
|
subtext: moment.duration(maxDelayMoment).isValid()
|
||||||
|
? moment.duration(maxDelayMoment).humanize()
|
||||||
|
: '--',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Avatar />,
|
icon: <Avatar />,
|
||||||
@@ -215,7 +219,7 @@ const ChoreHistory = () => {
|
|||||||
<Typography level='title-md' my={1.5}>
|
<Typography level='title-md' my={1.5}>
|
||||||
History:
|
History:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Sheet sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md' }}>
|
<Sheet variant='plain' sx={{ borderRadius: 'sm', boxShadow: 'md' }}>
|
||||||
{/* Chore History List (Updated Style) */}
|
{/* Chore History List (Updated Style) */}
|
||||||
|
|
||||||
<List sx={{ p: 0 }}>
|
<List sx={{ p: 0 }}>
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
|
Grid,
|
||||||
ListDivider,
|
ListDivider,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemContent,
|
ListItemContent,
|
||||||
ListItemDecorator,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
export const getCompletedChip = historyEntry => {
|
const getCompletedChip = historyEntry => {
|
||||||
var text = 'No Due Date'
|
if (historyEntry.status === 0) {
|
||||||
var color = 'info'
|
return null
|
||||||
var icon = <CalendarViewDay />
|
}
|
||||||
// if completed few hours +-6 hours
|
if (!historyEntry.dueDate) {
|
||||||
if (
|
return null
|
||||||
historyEntry.dueDate &&
|
// <Chip
|
||||||
historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
|
// size='sm'
|
||||||
historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
|
// variant='soft'
|
||||||
) {
|
// color='neutral'
|
||||||
text = 'On Time'
|
// startDecorator={<CalendarViewDay />}
|
||||||
color = 'success'
|
// >
|
||||||
icon = <Check />
|
// No Due Date
|
||||||
} else if (
|
// </Chip>
|
||||||
historyEntry.dueDate &&
|
|
||||||
historyEntry.performedAt < historyEntry.dueDate
|
|
||||||
) {
|
|
||||||
text = 'On Time'
|
|
||||||
color = 'success'
|
|
||||||
icon = <Check />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if completed after due date then it's late
|
const performedAt = moment(historyEntry.performedAt)
|
||||||
else if (
|
const dueDate = moment(historyEntry.dueDate)
|
||||||
historyEntry.dueDate &&
|
// TODO: make this a config at some point
|
||||||
historyEntry.performedAt > historyEntry.dueDate
|
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
|
||||||
) {
|
|
||||||
text = 'Late'
|
|
||||||
color = 'warning'
|
|
||||||
icon = <Timelapse />
|
|
||||||
} else {
|
|
||||||
text = 'No Due Date'
|
|
||||||
color = 'neutral'
|
|
||||||
icon = <CalendarViewDay />
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||||
return (
|
return (
|
||||||
<Chip startDecorator={icon} color={color}>
|
<Chip
|
||||||
{text}
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color='success'
|
||||||
|
startDecorator={<Check />}
|
||||||
|
>
|
||||||
|
On Time
|
||||||
</Chip>
|
</Chip>
|
||||||
)
|
)
|
||||||
|
} else if (performedAt.isBefore(dueDate)) {
|
||||||
|
return (
|
||||||
|
<Chip size='sm' variant='soft' color='primary' startDecorator={<Check />}>
|
||||||
|
Early
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color='warning'
|
||||||
|
startDecorator={<Timelapse />}
|
||||||
|
>
|
||||||
|
Late
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = ({
|
const HistoryCard = ({
|
||||||
allHistory,
|
allHistory,
|
||||||
performers,
|
performers,
|
||||||
@@ -61,7 +93,10 @@ const HistoryCard = ({
|
|||||||
index,
|
index,
|
||||||
onClick,
|
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')
|
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
|
||||||
let timeValue = diffInMinutes
|
let timeValue = diffInMinutes
|
||||||
let unit = 'minute'
|
let unit = 'minute'
|
||||||
@@ -81,86 +116,203 @@ const HistoryCard = ({
|
|||||||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusAvatar = () => {
|
||||||
|
const statusMap = {
|
||||||
|
0: { icon: <AccessTime />, color: 'primary' }, // Started
|
||||||
|
1: { icon: <Check />, color: 'success' }, // Completed
|
||||||
|
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
size='sm'
|
||||||
|
color={config.color}
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
'& svg': { fontSize: '14px' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }} onClick={onClick}>
|
<ListItem
|
||||||
{' '}
|
onClick={onClick}
|
||||||
{/* Adjusted spacing and alignment */}
|
sx={{
|
||||||
<ListItemDecorator>
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
<Avatar sx={{ mr: 1 }}>
|
py: 1.5,
|
||||||
{performers
|
px: 2,
|
||||||
.find(p => p.userId === historyEntry.completedBy)
|
'&:hover': onClick
|
||||||
?.displayName?.charAt(0) || '?'}
|
? {
|
||||||
</Avatar>
|
backgroundColor: 'background.level1',
|
||||||
</ListItemDecorator>
|
}
|
||||||
<ListItemContent sx={{ my: 0 }}>
|
: {},
|
||||||
{' '}
|
borderRadius: 'sm',
|
||||||
{/* Removed vertical margin */}
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemContent>
|
||||||
|
<Grid container spacing={1} alignItems='center'>
|
||||||
|
{/* First Row/Column: Status and Time Info */}
|
||||||
|
<Grid xs={12} sm={8}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body1' sx={{ fontWeight: 'md' }}>
|
{getStatusAvatar()}
|
||||||
{historyEntry.performedAt
|
|
||||||
? moment(historyEntry.performedAt).format(
|
<Typography
|
||||||
'ddd MM/DD/yyyy HH:mm',
|
level='body-sm'
|
||||||
)
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontWeight: 'md',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{historyEntry.status === 0
|
||||||
|
? 'In Progress'
|
||||||
|
: historyEntry.status === 1
|
||||||
|
? 'Completed'
|
||||||
: 'Skipped'}
|
: 'Skipped'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<Chip size='sm' startDecorator={<EventNote />}>
|
||||||
|
{moment(
|
||||||
|
historyEntry.performedAt || historyEntry.updatedAt,
|
||||||
|
).format('MMM DD, h:mm A')}
|
||||||
|
</Chip>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
{getCompletedChip(historyEntry)}
|
{getCompletedChip(historyEntry)}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography level='body2' color='text.tertiary'>
|
</Box>
|
||||||
<Chip>
|
</Grid>
|
||||||
{
|
|
||||||
performers.find(p => p.userId === historyEntry.completedBy)
|
{/* Second Row/Column: Completion Status (right side on desktop) */}
|
||||||
?.displayName
|
<Grid xs={12} sm={4}>
|
||||||
}
|
<Box
|
||||||
</Chip>{' '}
|
sx={{
|
||||||
completed
|
display: 'flex',
|
||||||
{historyEntry.completedBy !== historyEntry.assignedTo && (
|
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{historyEntry.dueDate && (
|
||||||
|
<Chip size='sm' startDecorator={<CalendarMonth />}>
|
||||||
|
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Third Row: Performer and Assignment Info */}
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
mt: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip size='sm' variant='outlined' startDecorator={<Person />}>
|
||||||
|
{performer?.displayName || 'Unknown'}
|
||||||
|
</Chip>
|
||||||
|
|
||||||
|
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||||
|
assignedTo && (
|
||||||
<>
|
<>
|
||||||
{', '}
|
<Typography
|
||||||
assigned to{' '}
|
level='body-xs'
|
||||||
<Chip>
|
sx={{ color: 'text.tertiary' }}
|
||||||
{
|
>
|
||||||
performers.find(p => p.userId === historyEntry.assignedTo)
|
→
|
||||||
?.displayName
|
</Typography>
|
||||||
}
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
startDecorator={<CheckCircle />}
|
||||||
|
>
|
||||||
|
{assignedTo.displayName}
|
||||||
</Chip>
|
</Chip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Typography>
|
|
||||||
{historyEntry.dueDate && (
|
|
||||||
<Typography level='body2' color='text.tertiary'>
|
|
||||||
Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{historyEntry.notes && (
|
{historyEntry.notes && (
|
||||||
<Typography level='body2' color='text.tertiary'>
|
<Chip
|
||||||
Note: {historyEntry.notes}
|
size='sm'
|
||||||
</Typography>
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
startDecorator={<EventNote />}
|
||||||
|
sx={{ maxWidth: '120px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
Note
|
||||||
|
</Chip>
|
||||||
)}
|
)}
|
||||||
|
{/* add a duration chip if we have duration */}
|
||||||
|
{historyEntry?.duration > 0 && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color='primary'
|
||||||
|
startDecorator={<AccessTime />}
|
||||||
|
>
|
||||||
|
{formatTime(historyEntry.duration)}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{historyEntry?.points > 0 && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color='success'
|
||||||
|
startDecorator={<Toll />}
|
||||||
|
>
|
||||||
|
{historyEntry.points} pt
|
||||||
|
{historyEntry.points > 1 ? 's' : ''}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
{index < allHistory.length - 1 && (
|
|
||||||
<>
|
{/* Compact Divider with Time Difference */}
|
||||||
<ListDivider component='li'>
|
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
|
||||||
{/* time between two completion: */}
|
<ListDivider
|
||||||
{index < allHistory.length - 1 &&
|
component='li'
|
||||||
allHistory[index + 1].performedAt && (
|
sx={{
|
||||||
<Typography level='body3' color='text.tertiary'>
|
my: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
color: 'text.tertiary',
|
||||||
|
backgroundColor: 'background.surface',
|
||||||
|
px: 1,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatTimeDifference(
|
{formatTimeDifference(
|
||||||
historyEntry.performedAt,
|
historyEntry.performedAt || historyEntry.updatedAt,
|
||||||
allHistory[index + 1].performedAt,
|
allHistory[index + 1].performedAt,
|
||||||
)}{' '}
|
)}{' '}
|
||||||
before
|
before
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
|
||||||
</ListDivider>
|
</ListDivider>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +1,472 @@
|
|||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
IconButton,
|
IconButton,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
|
|
||||||
// import { useMutation, useQueryClient } from '@tanstack/react-query'
|
// 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 { 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 { DeleteLabel } from '../../utils/Fetcher'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import { useLabels } from './LabelQueries'
|
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 (
|
||||||
|
<Box key={label.id + '-compact-box'}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
'&:last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
// Only clear timers, don't auto-hide
|
||||||
|
if (hoverTimer) {
|
||||||
|
clearTimeout(hoverTimer)
|
||||||
|
setHoverTimer(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Action buttons underneath (revealed on swipe) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: maxSwipeDistance,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleActionAreaMouseEnter}
|
||||||
|
onMouseLeave={handleActionAreaMouseLeave}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
onEditClick(label)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
bgcolor: 'primary.100',
|
||||||
|
color: 'primary.600',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.200',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='plain'
|
||||||
|
color='danger'
|
||||||
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
onDeleteClick(label.id)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
bgcolor: 'danger.100',
|
||||||
|
color: 'danger.600',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'danger.200',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main card content */}
|
||||||
|
<Box
|
||||||
|
ref={cardRef}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 64,
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: 'background.body',
|
||||||
|
transform: `translateX(${swipeTranslateX}px)`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||||
|
zIndex: 1,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: isSwipeRevealed
|
||||||
|
? 'background.surface'
|
||||||
|
: 'background.level1',
|
||||||
|
boxShadow: isSwipeRevealed ? 'none' : 'sm',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
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 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '20px',
|
||||||
|
cursor: 'grab',
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
|
||||||
|
'&:hover': {
|
||||||
|
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
cursor: 'grabbing',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{/* Drag indicator dots */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
sx={{
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'text.tertiary',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{/* Color Avatar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
mr: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size='sm'
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
bgcolor: label.color,
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: isOwnedByCurrentUser
|
||||||
|
? 'background.surface'
|
||||||
|
: 'warning.300',
|
||||||
|
boxShadow: isOwnedByCurrentUser
|
||||||
|
? 'sm'
|
||||||
|
: '0 0 0 1px var(--joy-palette-warning-300)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
color: getTextColorFromBackgroundColor(label.color),
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name.charAt(0).toUpperCase()}
|
||||||
|
</Typography>
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content - Center */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Label Name */}
|
||||||
|
<Typography
|
||||||
|
level='title-sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
mb: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Color Info */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{label.color && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
startDecorator={<ColorLens />}
|
||||||
|
sx={{
|
||||||
|
fontSize: 10,
|
||||||
|
height: 18,
|
||||||
|
px: 0.75,
|
||||||
|
bgcolor: `${label.color}20`,
|
||||||
|
color: label.color,
|
||||||
|
border: `1px solid ${label.color}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getColorName(label.color)}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{!isOwnedByCurrentUser && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color='warning'
|
||||||
|
sx={{
|
||||||
|
fontSize: 9,
|
||||||
|
height: 16,
|
||||||
|
px: 0.5,
|
||||||
|
fontWeight: 'md',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Shared
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const LabelView = () => {
|
const LabelView = () => {
|
||||||
const { data: labels, isLabelsLoading, isError } = useLabels()
|
const { data: labels, isLabelsLoading, isError } = useLabels()
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
|
||||||
const [userLabels, setUserLabels] = useState([])
|
const [userLabels, setUserLabels] = useState([])
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -61,7 +506,7 @@ const LabelView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteLabel = id => {
|
const handleDeleteLabel = id => {
|
||||||
DeleteLabel(id).then(res => {
|
DeleteLabel(id).then(() => {
|
||||||
const updatedLabels = userLabels.filter(label => label.id !== id)
|
const updatedLabels = userLabels.filter(label => label.id !== id)
|
||||||
setUserLabels(updatedLabels)
|
setUserLabels(updatedLabels)
|
||||||
|
|
||||||
@@ -106,54 +551,41 @@ const LabelView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth='md'>
|
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||||
<div className='flex flex-col gap-2'>
|
<Box
|
||||||
{userLabels.map(label => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className='grid w-full grid-cols-[1fr,auto,auto] rounded-lg border border-zinc-200/80 p-4 shadow-sm dark:bg-zinc-900'
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
variant='outlined'
|
|
||||||
color='primary'
|
|
||||||
size='lg'
|
|
||||||
sx={{
|
sx={{
|
||||||
background: label.color,
|
// bgcolor: 'background.body',
|
||||||
borderColor: label.color,
|
// border: '1px solid',
|
||||||
color: getTextColorFromBackgroundColor(label.color),
|
// borderColor: 'divider',
|
||||||
|
// borderRadius: 'md',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label.name}
|
|
||||||
</Chip>
|
|
||||||
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant='soft'
|
|
||||||
color='neutral'
|
|
||||||
onClick={() => handleEditLabel(label)}
|
|
||||||
startDecorator={<EditIcon />}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<IconButton
|
|
||||||
size='sm'
|
|
||||||
variant='soft'
|
|
||||||
onClick={() => handleDeleteClicked(label.id)}
|
|
||||||
color='danger'
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{userLabels.length === 0 && (
|
{userLabels.length === 0 && (
|
||||||
<Typography textAlign='center' mt={2}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '50vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level='title-md' gutterBottom>
|
||||||
No labels available. Add a new label to get started.
|
No labels available. Add a new label to get started.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{userLabels.map(label => (
|
||||||
|
<LabelCard
|
||||||
|
key={label.id}
|
||||||
|
label={label}
|
||||||
|
onEditClick={handleEditLabel}
|
||||||
|
onDeleteClick={handleDeleteClicked}
|
||||||
|
currentUserId={userProfile?.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
<LabelModal
|
<LabelModal
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import {
|
import { Box, Button, FormLabel, Input, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
FormLabel,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import FadeModal from '../../components/common/FadeModal'
|
||||||
import ConfirmationModal from './Inputs/ConfirmationModal'
|
import ConfirmationModal from './Inputs/ConfirmationModal'
|
||||||
|
|
||||||
function EditHistoryModal({ config, historyRecord }) {
|
function EditHistoryModal({ config, historyRecord }) {
|
||||||
@@ -29,8 +22,7 @@ function EditHistoryModal({ config, historyRecord }) {
|
|||||||
const [notes, setNotes] = useState(historyRecord.notes)
|
const [notes, setNotes] = useState(historyRecord.notes)
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
return (
|
return (
|
||||||
<Modal open={config?.isOpen} onClose={config?.onClose}>
|
<FadeModal open={config?.isOpen} onClose={config?.onClose}>
|
||||||
<ModalDialog>
|
|
||||||
<Typography level='h4' mb={1}>
|
<Typography level='h4' mb={1}>
|
||||||
Edit History
|
Edit History
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -114,8 +106,7 @@ function EditHistoryModal({ config, historyRecord }) {
|
|||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default EditHistoryModal
|
export default EditHistoryModal
|
||||||
|
|||||||
@@ -1,14 +1,81 @@
|
|||||||
import { Box, Button, Modal, ModalDialog, Typography } from '@mui/joy'
|
import { Box, Button, Typography } from '@mui/joy'
|
||||||
import React from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
|
|
||||||
function ConfirmationModal({ config }) {
|
function ConfirmationModal({ config }) {
|
||||||
const handleAction = isConfirmed => {
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
isConfirmed => {
|
||||||
config.onClose(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 (
|
return (
|
||||||
<Modal open={config?.isOpen} onClose={config?.onClose}>
|
<FadeModal
|
||||||
<ModalDialog>
|
open={config?.isOpen}
|
||||||
|
onClose={config?.onClose}
|
||||||
|
size='sm'
|
||||||
|
unmountDelay={250}
|
||||||
|
>
|
||||||
<Typography level='h4' mb={1}>
|
<Typography level='h4' mb={1}>
|
||||||
{config?.title}
|
{config?.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -17,28 +84,33 @@ function ConfirmationModal({ config }) {
|
|||||||
{config?.message}
|
{config?.message}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
<Box display={'flex'} justifyContent={'space-around'} mt={1} gap={1}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleAction(true)
|
handleAction(true)
|
||||||
}}
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ mr: 1 }}
|
|
||||||
color={config.color ? config.color : 'primary'}
|
color={config.color ? config.color : 'primary'}
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{config?.confirmText}
|
{config?.confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleAction(false)
|
handleAction(false)
|
||||||
}}
|
}}
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
|
endDecorator={
|
||||||
|
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{config?.cancelText}
|
{config?.cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default ConfirmationModal
|
export default ConfirmationModal
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
Textarea,
|
Textarea,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
|
|
||||||
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||||
const [name, setName] = useState(currentThing?.name || '')
|
const [name, setName] = useState(currentThing?.name || '')
|
||||||
@@ -59,9 +58,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose}>
|
||||||
<ModalDialog>
|
|
||||||
{/* <ModalClose /> */}
|
|
||||||
<Typography level='h4'>
|
<Typography level='h4'>
|
||||||
{currentThing?.id ? 'Edit' : 'Create'} Thing
|
{currentThing?.id ? 'Edit' : 'Create'} Thing
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -118,11 +115,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
<Typography>Value</Typography>
|
<Typography>Value</Typography>
|
||||||
<Select sx={{ minWidth: 300 }} value={state}>
|
<Select sx={{ minWidth: 300 }} value={state}>
|
||||||
{['true', 'false'].map(value => (
|
{['true', 'false'].map(value => (
|
||||||
<Option
|
<Option value={value} key={value} onClick={() => setState(value)}>
|
||||||
value={value}
|
|
||||||
key={value}
|
|
||||||
onClick={() => setState(value)}
|
|
||||||
>
|
|
||||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
@@ -138,8 +131,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
{currentThing?.id ? 'Cancel' : 'Close'}
|
{currentThing?.id ? 'Cancel' : 'Close'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default CreateThingModal
|
export default CreateThingModal
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import { Box, Button, Input, Typography } from '@mui/joy'
|
||||||
import {
|
import { useState } from 'react'
|
||||||
Modal,
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
ModalDialog,
|
|
||||||
ModalClose,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
|
|
||||||
function DateModal({ isOpen, onClose, onSave, current, title }) {
|
function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||||
const [date, setDate] = useState(
|
const [date, setDate] = useState(
|
||||||
@@ -20,9 +13,7 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose}>
|
||||||
<ModalDialog>
|
|
||||||
{/* <ModalClose /> */}
|
|
||||||
<Typography variant='h4'>{title}</Typography>
|
<Typography variant='h4'>{title}</Typography>
|
||||||
<Input
|
<Input
|
||||||
sx={{ mt: 3 }}
|
sx={{ mt: 3 }}
|
||||||
@@ -38,8 +29,7 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default DateModal
|
export default DateModal
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
|
|
||||||
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||||
const [state, setState] = useState(currentThing?.state || '')
|
const [state, setState] = useState(currentThing?.state || '')
|
||||||
@@ -39,8 +38,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose}>
|
||||||
<ModalDialog>
|
|
||||||
<Typography level='h4'>Update state</Typography>
|
<Typography level='h4'>Update state</Typography>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -62,8 +60,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
{currentThing?.id ? 'Cancel' : 'Close'}
|
{currentThing?.id ? 'Cancel' : 'Close'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default EditThingStateModal
|
export default EditThingStateModal
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNotification } from '../../../service/NotificationProvider.jsx'
|
import { useNotification } from '../../../service/NotificationProvider.jsx'
|
||||||
@@ -58,29 +57,9 @@ function LabelModal({ isOpen, onClose, label }) {
|
|||||||
return true
|
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 = () => {
|
const handleSave = () => {
|
||||||
if (!validateLabel()) return
|
if (!validateLabel()) return
|
||||||
const saveLabel = label?.id && label.id !== -1 ? UpdateLabel : CreateLabel
|
const saveLabel = label?.id && label.id !== -1 ? UpdateLabel : CreateLabel
|
||||||
// ? { id: label.id, name: labelName, color }
|
|
||||||
// : { name: labelName, color }
|
|
||||||
// saveLabelMutation.mutate({ name: labelName, color })
|
|
||||||
saveLabel({
|
saveLabel({
|
||||||
id: label?.id,
|
id: label?.id,
|
||||||
name: labelName,
|
name: labelName,
|
||||||
@@ -110,8 +89,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose}>
|
||||||
<ModalDialog>
|
|
||||||
<Typography level='title-md' mb={1}>
|
<Typography level='title-md' mb={1}>
|
||||||
{label ? 'Edit Label' : 'Add Label'}
|
{label ? 'Edit Label' : 'Add Label'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -181,8 +159,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
|
|
||||||
function PassowrdChangeModal({ isOpen, onClose }) {
|
function PassowrdChangeModal({ isOpen, onClose }) {
|
||||||
const [password, setPassword] = React.useState('')
|
const [password, setPassword] = React.useState('')
|
||||||
@@ -40,8 +39,7 @@ function PassowrdChangeModal({ isOpen, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose}>
|
||||||
<ModalDialog>
|
|
||||||
<Typography level='h4' mb={1}>
|
<Typography level='h4' mb={1}>
|
||||||
Change Password
|
Change Password
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -110,8 +108,7 @@ function PassowrdChangeModal({ isOpen, onClose }) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default PassowrdChangeModal
|
export default PassowrdChangeModal
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import {
|
import { Box, Button, Option, Select, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import React from 'react'
|
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 [selected, setSelected] = React.useState(null)
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave(options.find(item => item.id === selected))
|
onSave(options.find(item => item.id === selected))
|
||||||
@@ -17,8 +18,7 @@ function SelectModal({ isOpen, onClose, onSave, options, title, displayKey,place
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose}>
|
||||||
<ModalDialog>
|
|
||||||
<Typography variant='h4'>{title}</Typography>
|
<Typography variant='h4'>{title}</Typography>
|
||||||
<Select placeholder={placeholder}>
|
<Select placeholder={placeholder}>
|
||||||
{options.map((item, index) => (
|
{options.map((item, index) => (
|
||||||
@@ -42,8 +42,7 @@ function SelectModal({ isOpen, onClose, onSave, options, title, displayKey,place
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default SelectModal
|
export default SelectModal
|
||||||
|
|||||||
@@ -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 { useState } from 'react'
|
||||||
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
|
|
||||||
function TextModal({
|
function TextModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -18,9 +19,7 @@ function TextModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose}>
|
||||||
<ModalDialog>
|
|
||||||
{/* <ModalClose /> */}
|
|
||||||
<Typography variant='h4'>{title}</Typography>
|
<Typography variant='h4'>{title}</Typography>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder='Type in here…'
|
placeholder='Type in here…'
|
||||||
@@ -39,8 +38,7 @@ function TextModal({
|
|||||||
{cancelText ? cancelText : 'Cancel'}
|
{cancelText ? cancelText : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default TextModal
|
export default TextModal
|
||||||
|
|||||||
986
src/views/Modals/Inputs/TimerEditModal.jsx
Normal file
986
src/views/Modals/Inputs/TimerEditModal.jsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<FadeModal open={isOpen} onClose={onClose} size='lg' fullWidth={true}>
|
||||||
|
<Typography level='h4'>Timer Details</Typography>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Alert color='neutral' sx={{ mb: 2 }}>
|
||||||
|
Loading timer data...
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !timerData && (
|
||||||
|
<Alert color='warning' sx={{ mb: 2 }}>
|
||||||
|
No timer data found for this chore.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && timerData && (
|
||||||
|
<Box sx={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
|
{/* Timer Summary */}
|
||||||
|
<Card
|
||||||
|
variant='plain'
|
||||||
|
sx={{
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header with timeline */}
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Active Time */}
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'success.500',
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Active Work
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'success.600',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(calculateCurrentActiveDuration())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Idle Time */}
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'warning.500',
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Break Time
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'warning.600',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(calculateIdleTime())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Total Sessions */}
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'primary.500',
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Work Sessions
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'primary.600',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timerData.pauseLog?.length || 0}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Total Session Time */}
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'neutral.500',
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Total Time
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'neutral.700',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(calculateTotalDuration())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
|
||||||
|
>
|
||||||
|
Work vs Break Distribution
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||||
|
{calculateCurrentActiveDuration() > 0
|
||||||
|
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
|
||||||
|
: 'No active time yet'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: 'neutral.200',
|
||||||
|
borderRadius: 'sm',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.round((calculateCurrentActiveDuration() / Math.max(calculateTotalDuration(), 1)) * 100)}%`,
|
||||||
|
backgroundColor: 'success.400',
|
||||||
|
borderRadius: 'sm',
|
||||||
|
transition: 'width 0.3s ease-in-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Time Session */}
|
||||||
|
<Box>
|
||||||
|
<Typography level='h4' sx={{ mb: 2 }}>
|
||||||
|
Session Breakdown
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
{!editingSessions[timerData.id] ? (
|
||||||
|
<Box>
|
||||||
|
{/* Read-only view */}
|
||||||
|
{/* Sessions */}
|
||||||
|
{timerData.pauseLog && timerData.pauseLog.length > 0 && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ fontWeight: 'bold', mb: 2 }}
|
||||||
|
>
|
||||||
|
Work Sessions ({timerData.pauseLog.length})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<Card
|
||||||
|
key={pauseIndex}
|
||||||
|
variant='outlined'
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
minHeight: 'auto',
|
||||||
|
|
||||||
|
borderColor: isOngoing
|
||||||
|
? 'success.300'
|
||||||
|
: 'divider',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Session indicator */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: isOngoing
|
||||||
|
? 'success.500'
|
||||||
|
: 'neutral.400',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Duration - Main focus */}
|
||||||
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isOngoing
|
||||||
|
? 'success.600'
|
||||||
|
: 'text.primary',
|
||||||
|
lineHeight: 1,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(realTimeDuration)}
|
||||||
|
</Typography>
|
||||||
|
{isOngoing && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
color='success'
|
||||||
|
variant='soft'
|
||||||
|
sx={{ fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Session details */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
// this align to the right side of the card
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'medium',
|
||||||
|
color: 'text.secondary',
|
||||||
|
mb: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Session #{pauseIndex + 1} • {sessionDate}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
color: 'text.tertiary',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{startTime}{' '}
|
||||||
|
{endTime ? `→ ${endTime}` : '→ ongoing'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{/* Editing view */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Session Editor */}
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Sessions
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
startDecorator={<Add />}
|
||||||
|
onClick={() => addPauseLogEntry(timerData.id)}
|
||||||
|
>
|
||||||
|
Add Session
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{editingSessions[timerData.id].pauseLog.map(
|
||||||
|
(pause, pauseIndex) => (
|
||||||
|
<Card
|
||||||
|
key={pauseIndex}
|
||||||
|
variant='soft'
|
||||||
|
sx={{ mb: 2, p: 2 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Session #{pauseIndex + 1}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
color='danger'
|
||||||
|
onClick={() =>
|
||||||
|
deletePauseLogEntry(
|
||||||
|
timerData.id,
|
||||||
|
pauseIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl size='sm'>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Start Time
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type='datetime-local'
|
||||||
|
value={moment(pause.start).format(
|
||||||
|
'YYYY-MM-DDTHH:mm:ss',
|
||||||
|
)}
|
||||||
|
onChange={e =>
|
||||||
|
updatePauseLogEntry(
|
||||||
|
timerData.id,
|
||||||
|
pauseIndex,
|
||||||
|
'start',
|
||||||
|
new Date(e.target.value).toISOString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size='sm'>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
End Time
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type='datetime-local'
|
||||||
|
value={
|
||||||
|
pause.end
|
||||||
|
? moment(pause.end).format(
|
||||||
|
'YYYY-MM-DDTHH:mm:ss',
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onChange={e =>
|
||||||
|
updatePauseLogEntry(
|
||||||
|
timerData.id,
|
||||||
|
pauseIndex,
|
||||||
|
'end',
|
||||||
|
e.target.value
|
||||||
|
? new Date(
|
||||||
|
e.target.value,
|
||||||
|
).toISOString()
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Leave empty if session is ongoing
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
||||||
|
>
|
||||||
|
Duration (Auto-calculated)
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
bgcolor: 'background.surface',
|
||||||
|
borderRadius: 'sm',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(pause.duration)} (
|
||||||
|
{pause.duration}s)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!timerData && (
|
||||||
|
<Alert color='neutral' sx={{ mt: 2 }}>
|
||||||
|
No timer session found for this chore.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button variant='outlined' onClick={handleClose} color='neutral'>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{/* Action buttons on the right */}
|
||||||
|
{!loading && timerData && !editingSessions[timerData.id] && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
color='danger'
|
||||||
|
onClick={() => confirmDeleteSession(timerData.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
startDecorator={<Edit />}
|
||||||
|
onClick={() => startEditingSession()}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save button when editing */}
|
||||||
|
{!loading && timerData && editingSessions[timerData.id] && (
|
||||||
|
<Button
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
onClick={() => saveSession(timerData.id)}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</FadeModal>
|
||||||
|
|
||||||
|
<ConfirmationModal config={confirmDeleteConfig} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimerEditModal
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
import {
|
import { Avatar, Box, Button, List, ListItem, Typography } from '@mui/joy'
|
||||||
Avatar,
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
ModalOverflow,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
|
|
||||||
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onClose={onClose}>
|
<FadeModal open={isOpen} onClose={onClose} size='md' fullWidth>
|
||||||
<ModalOverflow>
|
|
||||||
<ModalDialog size='md' sx={{ minWidth: 360 }}>
|
|
||||||
<Typography level='h4' sx={{ mb: 2 }}>
|
<Typography level='h4' sx={{ mb: 2 }}>
|
||||||
Select User
|
Select User
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -49,9 +38,7 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</ModalOverflow>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { CopyAll } from '@mui/icons-material'
|
import { CopyAll } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
|
||||||
Box,
|
import { useState } from 'react'
|
||||||
Button,
|
import FadeModal from '../../../components/common/FadeModal'
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
ListItem,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
|
|
||||||
function WriteNFCModal({ config }) {
|
function WriteNFCModal({ config }) {
|
||||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
|
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
|
||||||
@@ -60,8 +52,7 @@ function WriteNFCModal({ config }) {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Modal open={config?.isOpen} onClose={handleClose}>
|
<FadeModal open={config?.isOpen} onClose={handleClose}>
|
||||||
<ModalDialog>
|
|
||||||
<Typography level='h4' mb={1}>
|
<Typography level='h4' mb={1}>
|
||||||
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
|
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -115,8 +106,7 @@ function WriteNFCModal({ config }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,249 @@
|
|||||||
|
import { CreditCard, Person, Toll } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Stack,
|
||||||
ModalDialog,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import FadeModal from '../../components/common/FadeModal'
|
||||||
|
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||||
|
|
||||||
function RedeemPointsModal({ config }) {
|
function RedeemPointsModal({ config }) {
|
||||||
|
const [points, setPoints] = useState(0)
|
||||||
|
const predefinedPoints = [1, 5, 10, 25, 50]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPoints(0)
|
setPoints(0)
|
||||||
}, [config])
|
}, [config])
|
||||||
|
|
||||||
const [points, setPoints] = useState(0)
|
const handlePointsChange = value => {
|
||||||
|
const numValue = Number(value)
|
||||||
const predefinedPoints = [1, 5, 10, 25]
|
if (numValue > config.available) {
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={config?.isOpen} onClose={config?.onClose}>
|
|
||||||
<ModalDialog>
|
|
||||||
<Typography level='h4' mb={1}>
|
|
||||||
Redeem Points
|
|
||||||
</Typography>
|
|
||||||
<FormLabel>
|
|
||||||
Points to Redeem ({config.available ? config.available : 0} points
|
|
||||||
available)
|
|
||||||
</FormLabel>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
value={points}
|
|
||||||
slotProps={{
|
|
||||||
input: { min: 0, max: config.available ? config.available : 0 },
|
|
||||||
}}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.value > config.available) {
|
|
||||||
setPoints(config.available)
|
setPoints(config.available)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setPoints(e.target.value)
|
if (numValue < 0) {
|
||||||
}}
|
setPoints(0)
|
||||||
/>
|
return
|
||||||
<FormLabel>Or select from predefined points:</FormLabel>
|
}
|
||||||
<Box display='flex' justifyContent='space-evenly' mb={1}>
|
setPoints(numValue)
|
||||||
{predefinedPoints.map(point => (
|
}
|
||||||
<IconButton
|
|
||||||
variant='outlined'
|
const addPredefinedPoints = point => {
|
||||||
disabled={points + point > config.available}
|
|
||||||
sx={{ borderRadius: '50%' }}
|
|
||||||
key={point}
|
|
||||||
onClick={() => {
|
|
||||||
const newPoints = points + point
|
const newPoints = points + point
|
||||||
if (newPoints > config.available) {
|
if (newPoints > config.available) {
|
||||||
setPoints(config.available)
|
setPoints(config.available)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setPoints(newPoints)
|
setPoints(newPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRedeem = points > 0 && points <= config.available
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FadeModal open={config?.isOpen} onClose={config?.onClose} size='md'>
|
||||||
|
{/* Header Section */}
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<CreditCard
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{point}
|
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
||||||
</IconButton>
|
Redeem Points
|
||||||
))}
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 3 button save , cancel and delete */}
|
<Divider />
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
|
||||||
|
{/* User Info Card */}
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction='row' spacing={2} alignItems='center'>
|
||||||
|
<Avatar
|
||||||
|
size='md'
|
||||||
|
src={resolvePhotoURL(config?.user?.image)}
|
||||||
|
sx={{
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'warning.200',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Person />
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||||
|
{config?.user?.displayName || 'User'}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color='success'
|
||||||
|
startDecorator={<Toll />}
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
>
|
||||||
|
{config?.available || 0} points available
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Points Input Section */}
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
|
Points to Redeem
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
value={points}
|
||||||
|
size='lg'
|
||||||
|
variant='outlined'
|
||||||
|
startDecorator={<Toll />}
|
||||||
|
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 && (
|
||||||
|
<Typography level='body-xs' sx={{ color: 'danger.500', mt: 0.5 }}>
|
||||||
|
Cannot exceed available points
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Quick Selection Buttons */}
|
||||||
|
<Box>
|
||||||
|
<Typography level='body-sm' sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||||
|
Quick Add:
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction='row'
|
||||||
|
spacing={1}
|
||||||
|
justifyContent='center'
|
||||||
|
flexWrap='wrap'
|
||||||
|
useFlexGap
|
||||||
|
>
|
||||||
|
{predefinedPoints.map(point => (
|
||||||
|
<IconButton
|
||||||
|
key={point}
|
||||||
|
variant='outlined'
|
||||||
|
disabled={points + 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}
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Summary Section */}
|
||||||
|
{points > 0 && (
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
color='primary'
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(25,118,210,0.1) 0%, rgba(25,118,210,0.05) 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
You are about to redeem
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{ color: 'primary.600', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{points} points
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ color: 'text.secondary', mt: 0.5 }}
|
||||||
|
>
|
||||||
|
Remaining: {(config?.available || 0) - points} points
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Stack direction='row' spacing={2}>
|
||||||
|
<Button
|
||||||
|
onClick={config?.onClose}
|
||||||
|
variant='outlined'
|
||||||
|
color='neutral'
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'neutral.50',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
config.onSave({
|
config?.onSave({
|
||||||
points: Number(points),
|
points: Number(points),
|
||||||
userId: config.user.userId,
|
userId: config?.user?.userId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={!canRedeem}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ mr: 1 }}
|
startDecorator={<CreditCard />}
|
||||||
|
sx={{
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Redeem
|
Redeem
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={config.onClose} variant='outlined'>
|
</Stack>
|
||||||
Cancel
|
</Stack>
|
||||||
</Button>
|
</FadeModal>
|
||||||
</Box>
|
|
||||||
</ModalDialog>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RedeemPointsModal
|
export default RedeemPointsModal
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const NotificationSetting = () => {
|
|||||||
set: setPreDueNotification,
|
set: setPreDueNotification,
|
||||||
label: 'Notification a few hours before the task is due',
|
label: 'Notification a few hours before the task is due',
|
||||||
property: 'preDueNotification',
|
property: 'preDueNotification',
|
||||||
disabled: true,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Overdue Notification',
|
title: 'Overdue Notification',
|
||||||
@@ -207,7 +207,7 @@ const NotificationSetting = () => {
|
|||||||
set: setNaggingNotification,
|
set: setNaggingNotification,
|
||||||
label: 'Notification when the task is overdue',
|
label: 'Notification when the task is overdue',
|
||||||
property: 'naggingNotification',
|
property: 'naggingNotification',
|
||||||
disabled: true,
|
disabled: false,
|
||||||
},
|
},
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<FormControl
|
<FormControl
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
AcceptCircleMemberRequest,
|
AcceptCircleMemberRequest,
|
||||||
CancelSubscription,
|
CancelSubscription,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
UpdatePassword,
|
UpdatePassword,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
|
import LoadingComponent from '../components/Loading'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||||
import APITokenSettings from './APITokenSettings'
|
import APITokenSettings from './APITokenSettings'
|
||||||
@@ -42,7 +44,6 @@ import NotificationSetting from './NotificationSetting'
|
|||||||
import ProfileSettings from './ProfileSettings'
|
import ProfileSettings from './ProfileSettings'
|
||||||
import StorageSettings from './StorageSettings'
|
import StorageSettings from './StorageSettings'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
@@ -163,6 +164,9 @@ const Settings = () => {
|
|||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!userProfile) {
|
||||||
|
return <LoadingComponent />
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<ProfileSettings />
|
<ProfileSettings />
|
||||||
|
|||||||
490
src/views/TestView/TimerCard.jsx
Normal file
490
src/views/TestView/TimerCard.jsx
Normal file
@@ -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 (
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
justifyContent: 'start',
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WatchLater />
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
level='title-sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isRunning && !isPaused ? 'primary.600' : 'text.primary',
|
||||||
|
transition: 'color 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(time)}
|
||||||
|
</Typography>
|
||||||
|
{!isRunning ? (
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='success'
|
||||||
|
size='sm'
|
||||||
|
onClick={startTimer}
|
||||||
|
sx={{ width: 24, height: 24 }}
|
||||||
|
>
|
||||||
|
<PlayArrow sx={{ fontSize: '1rem' }} />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color={isPaused ? 'success' : 'warning'}
|
||||||
|
size='sm'
|
||||||
|
onClick={isPaused ? resumeTimer : pauseTimer}
|
||||||
|
sx={{ width: 24, height: 24 }}
|
||||||
|
>
|
||||||
|
{isPaused ? (
|
||||||
|
<PlayArrow sx={{ fontSize: '1rem' }} />
|
||||||
|
) : (
|
||||||
|
<Pause sx={{ fontSize: '1rem' }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='danger'
|
||||||
|
size='sm'
|
||||||
|
onClick={stopTimer}
|
||||||
|
sx={{ width: 24, height: 24 }}
|
||||||
|
>
|
||||||
|
<Stop sx={{ fontSize: '1rem' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{time > 0 && (
|
||||||
|
<Typography level='body-xs' color='text.secondary'>
|
||||||
|
{Math.floor(time / 60)}m {time % 60}s
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating variant - position fixed
|
||||||
|
if (variant === 'floating') {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant='outlined'
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 20,
|
||||||
|
right: 20,
|
||||||
|
p: 2,
|
||||||
|
boxShadow: 'lg',
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: 'background.surface',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
transition: 'all 0.3s ease-in-out',
|
||||||
|
width: 200,
|
||||||
|
zIndex: 1000,
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: 'xl',
|
||||||
|
borderColor: 'primary.200',
|
||||||
|
},
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<WatchLater sx={{ color: 'primary.600', fontSize: '1rem' }} />
|
||||||
|
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', mb: 1 }}>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isRunning && !isPaused ? 'primary.600' : 'text.primary',
|
||||||
|
transition: 'color 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(time)}
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-xs' color='text.secondary'>
|
||||||
|
{isRunning && !isPaused ? 'Running' : isPaused ? 'Paused' : 'Ready'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
||||||
|
{!isRunning ? (
|
||||||
|
<IconButton
|
||||||
|
variant='solid'
|
||||||
|
color='success'
|
||||||
|
size='sm'
|
||||||
|
onClick={startTimer}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<PlayArrow />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color={isPaused ? 'success' : 'warning'}
|
||||||
|
size='sm'
|
||||||
|
onClick={isPaused ? resumeTimer : pauseTimer}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
{isPaused ? <PlayArrow /> : <Pause />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='danger'
|
||||||
|
size='sm'
|
||||||
|
onClick={stopTimer}
|
||||||
|
sx={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
<Stop />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default standalone variant
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant='outlined'
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
boxShadow: 'lg',
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: 'background.surface',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
transition: 'all 0.3s ease-in-out',
|
||||||
|
maxWidth: 420,
|
||||||
|
mx: 'auto',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: 'xl',
|
||||||
|
borderColor: 'primary.200',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
},
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
mb: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'primary.100',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WatchLater sx={{ color: 'primary.600', fontSize: '1.25rem' }} />
|
||||||
|
</Box>
|
||||||
|
<Typography level='title-lg' sx={{ fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Timer Display */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: 180,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Circular Background */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
isRunning && !isPaused
|
||||||
|
? 'linear-gradient(135deg, rgba(25, 118, 210, 0.1), rgba(25, 118, 210, 0.05))'
|
||||||
|
: 'linear-gradient(135deg, rgba(158, 158, 158, 0.08), rgba(158, 158, 158, 0.03))',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: isRunning && !isPaused ? 'primary.200' : 'neutral.200',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.4s ease',
|
||||||
|
'&::before':
|
||||||
|
isRunning && !isPaused
|
||||||
|
? {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: -4,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #1976d2, #42a5f5)',
|
||||||
|
zIndex: -1,
|
||||||
|
animation: 'rotate 3s linear infinite',
|
||||||
|
opacity: 0.3,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
'@keyframes rotate': {
|
||||||
|
'0%': { transform: 'rotate(0deg)' },
|
||||||
|
'100%': { transform: 'rotate(360deg)' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Timer Text */}
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography
|
||||||
|
level='h2'
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isRunning && !isPaused ? 'primary.600' : 'text.primary',
|
||||||
|
transition: 'color 0.3s ease',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(time)}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRunning && !isPaused
|
||||||
|
? 'Running'
|
||||||
|
: isPaused
|
||||||
|
? 'Paused'
|
||||||
|
: 'Ready'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Pulse effect for running state */}
|
||||||
|
{isRunning && !isPaused && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'primary.300',
|
||||||
|
animation: 'pulse-ring 2s ease-out infinite',
|
||||||
|
'@keyframes pulse-ring': {
|
||||||
|
'0%': {
|
||||||
|
transform: 'scale(1)',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'scale(1.4)',
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Control Buttons */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
mt: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isRunning ? (
|
||||||
|
<IconButton
|
||||||
|
variant='solid'
|
||||||
|
color='success'
|
||||||
|
size='lg'
|
||||||
|
onClick={startTimer}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
boxShadow: 'lg',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
boxShadow: 'xl',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
transform: 'scale(0.95)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayArrow sx={{ fontSize: '2.25rem' }} />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color={isPaused ? 'success' : 'warning'}
|
||||||
|
size='lg'
|
||||||
|
onClick={isPaused ? resumeTimer : pauseTimer}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPaused ? (
|
||||||
|
<PlayArrow sx={{ fontSize: '1.5rem' }} />
|
||||||
|
) : (
|
||||||
|
<Pause sx={{ fontSize: '1.5rem' }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='outlined'
|
||||||
|
color='danger'
|
||||||
|
size='lg'
|
||||||
|
onClick={stopTimer}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
bgcolor: 'danger.50',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stop sx={{ fontSize: '1.5rem' }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Session Info */}
|
||||||
|
{time > 0 && (
|
||||||
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||||
|
<Typography level='body-sm' color='text.secondary'>
|
||||||
|
Session: {Math.floor(time / 60)}m {time % 60}s
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimerCard
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { EventBusy } from '@mui/icons-material'
|
import { EventBusy, Schedule, TrendingUp } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
|
Grid,
|
||||||
List,
|
List,
|
||||||
ListDivider,
|
ListDivider,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -42,7 +44,7 @@ const ThingsHistory = () => {
|
|||||||
setErrLoading(true)
|
setErrLoading(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
}, [id])
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
GetThingHistory(id, thingsHistory.length).then(resp => {
|
GetThingHistory(id, thingsHistory.length).then(resp => {
|
||||||
@@ -107,7 +109,7 @@ const ThingsHistory = () => {
|
|||||||
No history found
|
No history found
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body1'>
|
<Typography level='body1'>
|
||||||
It's look like there is no history for this thing yet.
|
It looks like there is no history for this thing yet.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant='soft' sx={{ mt: 2 }}>
|
<Button variant='soft' sx={{ mt: 2 }}>
|
||||||
<Link to='/things'>Go back to things</Link>
|
<Link to='/things'>Go back to things</Link>
|
||||||
@@ -175,46 +177,118 @@ const ThingsHistory = () => {
|
|||||||
<Typography level='h4' gutterBottom>
|
<Typography level='h4' gutterBottom>
|
||||||
Change log:
|
Change log:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md' }}>
|
<Box sx={{ borderRadius: 'sm', p: 1, boxShadow: 'md' }}>
|
||||||
<List sx={{ p: 0 }}>
|
<List sx={{ p: 0 }}>
|
||||||
{thingsHistory.map((history, index) => (
|
{thingsHistory.map((history, index) => (
|
||||||
<>
|
<Box key={index}>
|
||||||
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
<ListItem
|
||||||
<ListItemContent sx={{ my: 0 }}>
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 'sm',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'background.level1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemContent>
|
||||||
|
<Grid container spacing={1} alignItems='center'>
|
||||||
|
{/* First Row: Status and Time Info */}
|
||||||
|
<Grid xs={12} sm={8}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body1' sx={{ fontWeight: 'md' }}>
|
<Avatar
|
||||||
{moment(history.updatedAt).format(
|
size='sm'
|
||||||
'ddd MM/DD/yyyy HH:mm:ss',
|
color='primary'
|
||||||
)}
|
variant='solid'
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
'& svg': { fontSize: '14px' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrendingUp />
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontWeight: 'md',
|
||||||
|
display: { xs: 'none', sm: 'block' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Updated
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip>{history.state}</Chip>
|
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color='primary'
|
||||||
|
startDecorator={<Schedule />}
|
||||||
|
>
|
||||||
|
{moment(history.updatedAt).format('MMM DD, h:mm A')}
|
||||||
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Second Row: State Value */}
|
||||||
|
<Grid xs={12} sm={4}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size='md'
|
||||||
|
variant='solid'
|
||||||
|
color='success'
|
||||||
|
sx={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{history.state}
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
{/* Divider with time difference */}
|
||||||
{index < thingsHistory.length - 1 && (
|
{index < thingsHistory.length - 1 && (
|
||||||
<>
|
<ListDivider
|
||||||
<ListDivider component='li'>
|
component='li'
|
||||||
{/* time between two completion: */}
|
sx={{
|
||||||
{index < thingsHistory.length - 1 &&
|
my: 0.5,
|
||||||
thingsHistory[index + 1].createdAt && (
|
}}
|
||||||
<Typography level='body3' color='text.tertiary'>
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
color: 'text.tertiary',
|
||||||
|
backgroundColor: 'background.surface',
|
||||||
|
px: 1,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatTimeDifference(
|
{formatTimeDifference(
|
||||||
history.createdAt,
|
history.createdAt,
|
||||||
thingsHistory[index + 1].createdAt,
|
thingsHistory[index + 1].createdAt,
|
||||||
)}{' '}
|
)}{' '}
|
||||||
before
|
before
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
|
||||||
</ListDivider>
|
</ListDivider>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,16 +8,8 @@ import {
|
|||||||
ToggleOn,
|
ToggleOn,
|
||||||
Widgets,
|
Widgets,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import { Avatar, Box, Chip, Container, IconButton, Typography } from '@mui/joy'
|
||||||
Box,
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Container,
|
|
||||||
Grid,
|
|
||||||
IconButton,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +30,17 @@ const ThingCard = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isDisabled, setIsDisabled] = useState(false)
|
const [isDisabled, setIsDisabled] = useState(false)
|
||||||
const Navigate = useNavigate()
|
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 => {
|
const getThingIcon = type => {
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
return <Flip />
|
return <Flip />
|
||||||
@@ -54,67 +57,233 @@ const ThingCard = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getThingAvatar = () => {
|
||||||
|
const typeConfig = {
|
||||||
|
text: { color: 'primary', icon: <Flip /> },
|
||||||
|
number: { color: 'success', icon: <PlusOne /> },
|
||||||
|
boolean: {
|
||||||
|
color: thing.state === 'true' ? 'success' : 'neutral',
|
||||||
|
icon: thing.state === 'true' ? <ToggleOn /> : <ToggleOff />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = typeConfig[thing?.type] || typeConfig.boolean
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
size='sm'
|
||||||
|
color={config.color}
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
'& svg': { fontSize: '16px' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleRequestChange = thing => {
|
const handleRequestChange = thing => {
|
||||||
setIsDisabled(true)
|
setIsDisabled(true)
|
||||||
|
resetSwipe()
|
||||||
onStateChangeRequest(thing)
|
onStateChangeRequest(thing)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsDisabled(false)
|
setIsDisabled(false)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Swipe gesture handlers
|
||||||
<Box
|
const handleTouchStart = e => {
|
||||||
className='rounded-lg border border-zinc-200/80 p-4 shadow-sm'
|
dragStartX.current = e.touches[0].clientX
|
||||||
sx={{
|
setIsDragging(true)
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
p: 2,
|
|
||||||
|
|
||||||
mb: 2,
|
const handleTouchMove = e => {
|
||||||
}}
|
if (!isDragging) return
|
||||||
>
|
|
||||||
<Grid container alignItems='center'>
|
const currentX = e.touches[0].clientX
|
||||||
<Grid
|
const deltaX = currentX - dragStartX.current
|
||||||
item
|
|
||||||
xs={12}
|
if (isSwipeRevealed) {
|
||||||
sm={8}
|
if (deltaX > 0) {
|
||||||
onClick={() => Navigate(`/things/${thing?.id}`)}
|
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 (
|
||||||
|
<Box key={thing.id + '-compact-box'}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
position: 'relative',
|
||||||
flexDirection: 'row',
|
overflow: 'hidden',
|
||||||
alignItems: 'center',
|
borderBottom: '1px solid',
|
||||||
gap: 1,
|
borderColor: 'divider',
|
||||||
cursor: 'pointer',
|
'&:last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
// Only clear timers, don't auto-hide
|
||||||
|
if (hoverTimer) {
|
||||||
|
clearTimeout(hoverTimer)
|
||||||
|
setHoverTimer(null)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => Navigate(`/things/${thing?.id}`)}
|
|
||||||
>
|
>
|
||||||
<Typography level='title-lg'>{thing?.name}</Typography>
|
{/* Action buttons underneath (revealed on swipe) */}
|
||||||
<Chip
|
<Box
|
||||||
size='sm'
|
|
||||||
sx={{
|
sx={{
|
||||||
ml: 1,
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: maxSwipeDistance,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||||
|
zIndex: 0,
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={handleActionAreaMouseEnter}
|
||||||
|
onMouseLeave={handleActionAreaMouseLeave}
|
||||||
>
|
>
|
||||||
{thing?.type}
|
<IconButton
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
State: <Chip size='md'>{thing?.state}</Chip>
|
|
||||||
</Grid>
|
|
||||||
<Grid
|
|
||||||
item
|
|
||||||
xs={12}
|
|
||||||
sm={4}
|
|
||||||
container
|
|
||||||
justifyContent='flex-end'
|
|
||||||
alignItems='center'
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='success'
|
color='success'
|
||||||
onClick={() => {
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
if (thing?.type === 'text') {
|
if (thing?.type === 'text') {
|
||||||
onEditClick(thing)
|
onEditClick(thing)
|
||||||
} else {
|
} else {
|
||||||
@@ -122,42 +291,220 @@ const ThingCard = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
startDecorator={getThingIcon(thing?.type)}
|
|
||||||
>
|
|
||||||
{thing?.type === 'text'
|
|
||||||
? 'Change'
|
|
||||||
: thing?.type === 'number'
|
|
||||||
? 'Increment'
|
|
||||||
: 'Toggle'}
|
|
||||||
</Button>
|
|
||||||
<IconButton
|
|
||||||
color='primary'
|
|
||||||
onClick={() => onEditClick(thing)}
|
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '50%',
|
width: 40,
|
||||||
width: 30,
|
height: 40,
|
||||||
height: 30,
|
mx: 1,
|
||||||
ml: 1,
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': { backgroundColor: 'action.hover' },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit />
|
{getThingIcon(thing?.type)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
onEditClick(thing)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant='soft'
|
||||||
color='danger'
|
color='danger'
|
||||||
onClick={() => onDeleteClick(thing)}
|
size='sm'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
resetSwipe()
|
||||||
|
onDeleteClick(thing)
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mx: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main card content */}
|
||||||
|
<Box
|
||||||
|
ref={cardRef}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 64,
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
bgcolor: 'background.body',
|
||||||
|
transform: `translateX(${swipeTranslateX}px)`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||||
|
zIndex: 1,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: isSwipeRevealed
|
||||||
|
? 'background.surface'
|
||||||
|
: 'background.level1',
|
||||||
|
boxShadow: isSwipeRevealed ? 'none' : 'sm',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
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 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '20px',
|
||||||
|
cursor: 'grab',
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
|
||||||
|
'&:hover': {
|
||||||
|
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
cursor: 'grabbing',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{/* Drag indicator dots */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
sx={{
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
width: 30,
|
bgcolor: 'text.tertiary',
|
||||||
height: 30,
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{/* Avatar and Primary Action */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
mr: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getThingAvatar()}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content - Center */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Line 1: Name + State */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='title-sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
mr: 1,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{thing?.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color={
|
||||||
|
thing?.type === 'boolean' && thing?.state === 'true'
|
||||||
|
? 'success'
|
||||||
|
: 'primary'
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
fontSize: 11,
|
||||||
|
height: 20,
|
||||||
|
px: 1,
|
||||||
|
fontWeight: 'md',
|
||||||
|
flexShrink: 0,
|
||||||
ml: 1,
|
ml: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Delete fontSize='small' />
|
{thing?.state}
|
||||||
</IconButton>
|
</Chip>
|
||||||
</Grid>
|
</Box>
|
||||||
</Grid>
|
|
||||||
|
{/* Line 2: Type */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color='neutral'
|
||||||
|
sx={{
|
||||||
|
fontSize: 10,
|
||||||
|
height: 18,
|
||||||
|
px: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{thing?.type}
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -312,7 +659,16 @@ const ThingsView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth='md'>
|
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
// bgcolor: 'background.body',
|
||||||
|
// border: '1px solid',
|
||||||
|
// borderColor: 'divider',
|
||||||
|
// borderRadius: 'md',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{things.length === 0 && (
|
{things.length === 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -326,7 +682,6 @@ const ThingsView = () => {
|
|||||||
<Widgets
|
<Widgets
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '4rem',
|
fontSize: '4rem',
|
||||||
// color: 'text.disabled',
|
|
||||||
mb: 1,
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -344,6 +699,7 @@ const ThingsView = () => {
|
|||||||
onStateChangeRequest={handleStateChangeRequest}
|
onStateChangeRequest={handleStateChangeRequest}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
// variant='outlined'
|
// variant='outlined'
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
999
src/views/Timer/TimerDetails.jsx
Normal file
999
src/views/Timer/TimerDetails.jsx
Normal file
@@ -0,0 +1,999 @@
|
|||||||
|
import {
|
||||||
|
AccessTime,
|
||||||
|
Add,
|
||||||
|
BrowseGallery,
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
|
PauseCircle,
|
||||||
|
PlayArrow,
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
Container,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
Grid,
|
||||||
|
Input,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import moment from 'moment'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
|
import {
|
||||||
|
DeleteTimeSession,
|
||||||
|
GetChoreTimer,
|
||||||
|
UpdateTimeSession,
|
||||||
|
} from '../../utils/Fetcher'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
|
|
||||||
|
const TimerDetails = () => {
|
||||||
|
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 (
|
||||||
|
<Container maxWidth='lg' sx={{ py: 2, pb: 12 }}>
|
||||||
|
{/* Header */}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Alert color='neutral' sx={{ mb: 2 }}>
|
||||||
|
Loading timer data...
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !timerData && (
|
||||||
|
<Alert color='warning' sx={{ mb: 2 }}>
|
||||||
|
No timer data found for this chore.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && timerData && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Timer Summary */}
|
||||||
|
<Card
|
||||||
|
variant='plain'
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
{/* Active Time */}
|
||||||
|
<Grid item xs={6} sm={6} md={3}>
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayArrow
|
||||||
|
sx={{
|
||||||
|
fontSize: 16,
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Active Work
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'success.600',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(calculateCurrentActiveDuration())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Idle Time */}
|
||||||
|
<Grid item xs={6} sm={6} md={3}>
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PauseCircle
|
||||||
|
sx={{
|
||||||
|
fontSize: 16,
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Break Time
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'warning.600',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(calculateIdleTime())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Total Sessions */}
|
||||||
|
<Grid item xs={6} sm={6} md={3}>
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrowseGallery
|
||||||
|
sx={{
|
||||||
|
fontSize: 16,
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sessions
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'primary.600',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timerData.pauseLog?.length || 0}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Total Session Time */}
|
||||||
|
<Grid item xs={6} sm={6} md={3}>
|
||||||
|
<Card
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'md',
|
||||||
|
boxShadow: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 90,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'start',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccessTime
|
||||||
|
sx={{
|
||||||
|
fontSize: 16,
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Total Time
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
color: 'neutral.700',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(calculateTotalDuration())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
|
||||||
|
>
|
||||||
|
Work vs Break Distribution
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||||
|
{calculateCurrentActiveDuration() > 0
|
||||||
|
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
|
||||||
|
: 'No active time yet'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: 'neutral.200',
|
||||||
|
borderRadius: 'sm',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.round((calculateCurrentActiveDuration() / Math.max(calculateTotalDuration(), 1)) * 100)}%`,
|
||||||
|
backgroundColor: 'success.400',
|
||||||
|
borderRadius: 'sm',
|
||||||
|
transition: 'width 0.3s ease-in-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Session Breakdown */}
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography level='h4' sx={{ mb: 2 }}>
|
||||||
|
Session Breakdown
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!editingSessions[timerData.id] ? (
|
||||||
|
<Box>
|
||||||
|
{/* Read-only view */}
|
||||||
|
{timerData.pauseLog && timerData.pauseLog.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{ fontWeight: 'bold', mb: 2 }}
|
||||||
|
>
|
||||||
|
Work Sessions ({timerData.pauseLog.length})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<Card
|
||||||
|
key={pauseIndex}
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
minHeight: 'auto',
|
||||||
|
borderColor: isOngoing
|
||||||
|
? 'success.300'
|
||||||
|
: 'divider',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Session indicator */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: isOngoing
|
||||||
|
? 'success.500'
|
||||||
|
: 'neutral.400',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Duration - Main focus */}
|
||||||
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isOngoing
|
||||||
|
? 'success.600'
|
||||||
|
: 'text.primary',
|
||||||
|
lineHeight: 1,
|
||||||
|
mb: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(realTimeDuration)}
|
||||||
|
</Typography>
|
||||||
|
{isOngoing && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
color='success'
|
||||||
|
variant='soft'
|
||||||
|
sx={{ fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Session details */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'medium',
|
||||||
|
color: 'text.secondary',
|
||||||
|
mb: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Session #{pauseIndex + 1} • {sessionDate}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
color: 'text.tertiary',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{startTime}{' '}
|
||||||
|
{endTime ? `→ ${endTime}` : '→ ongoing'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!timerData.pauseLog || timerData.pauseLog.length === 0) && (
|
||||||
|
<Alert color='neutral'>
|
||||||
|
No work sessions found for this timer.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{/* Editing view */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Session Editor */}
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
|
||||||
|
Sessions
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
startDecorator={<Add />}
|
||||||
|
onClick={() => addPauseLogEntry(timerData.id)}
|
||||||
|
>
|
||||||
|
Add Session
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{editingSessions[timerData.id].pauseLog.map(
|
||||||
|
(pause, pauseIndex) => (
|
||||||
|
<Card
|
||||||
|
key={pauseIndex}
|
||||||
|
variant='soft'
|
||||||
|
sx={{ mb: 2, p: 2 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Session #{pauseIndex + 1}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
color='danger'
|
||||||
|
onClick={() =>
|
||||||
|
deletePauseLogEntry(timerData.id, pauseIndex)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns:
|
||||||
|
'repeat(auto-fit, minmax(250px, 1fr))',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl size='sm'>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ fontWeight: 'bold', mb: 1 }}
|
||||||
|
>
|
||||||
|
Start Time
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type='datetime-local'
|
||||||
|
value={moment(pause.start).format(
|
||||||
|
'YYYY-MM-DDTHH:mm:ss',
|
||||||
|
)}
|
||||||
|
onChange={e =>
|
||||||
|
updatePauseLogEntry(
|
||||||
|
timerData.id,
|
||||||
|
pauseIndex,
|
||||||
|
'start',
|
||||||
|
new Date(e.target.value).toISOString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size='sm'>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ fontWeight: 'bold', mb: 1 }}
|
||||||
|
>
|
||||||
|
End Time
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type='datetime-local'
|
||||||
|
value={
|
||||||
|
pause.end
|
||||||
|
? moment(pause.end).format(
|
||||||
|
'YYYY-MM-DDTHH:mm:ss',
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onChange={e =>
|
||||||
|
updatePauseLogEntry(
|
||||||
|
timerData.id,
|
||||||
|
pauseIndex,
|
||||||
|
'end',
|
||||||
|
e.target.value
|
||||||
|
? new Date(e.target.value).toISOString()
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Leave empty if session is ongoing
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ fontWeight: 'bold', mb: 1 }}
|
||||||
|
>
|
||||||
|
Duration (Auto-calculated)
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: 'background.surface',
|
||||||
|
borderRadius: 'sm',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(pause.duration)} (
|
||||||
|
{pause.duration}s)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sticky Bottom Actions */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
p: 2,
|
||||||
|
backgroundColor: 'background.surface',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
boxShadow: 'lg',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth='lg'>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
// justifyContent: 'space-between',
|
||||||
|
justifyContent: 'end',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* <Button variant='outlined' color='neutral' onClick={handleGoBack}>
|
||||||
|
Back to Chore
|
||||||
|
</Button> */}
|
||||||
|
|
||||||
|
{/* Right side - Action buttons */}
|
||||||
|
{!loading && timerData && !editingSessions[timerData.id] && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outlined'
|
||||||
|
color='danger'
|
||||||
|
onClick={() => confirmDeleteSession(timerData.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
startDecorator={<Edit />}
|
||||||
|
onClick={() => startEditingSession()}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save/Cancel buttons when editing */}
|
||||||
|
{!loading && timerData && editingSessions[timerData.id] && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
onClick={() => cancelEditingSession(timerData.id)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
onClick={() => saveSession(timerData.id)}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ConfirmationModal config={confirmDeleteConfig} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimerDetails
|
||||||
@@ -3,7 +3,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
|||||||
import CircleIcon from '@mui/icons-material/Circle'
|
import CircleIcon from '@mui/icons-material/Circle'
|
||||||
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'
|
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'
|
||||||
|
|
||||||
import { EventBusy, Toll } from '@mui/icons-material'
|
import { EventBusy, Group, Toll } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -27,7 +27,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import { ChoresGrouper } from '../../utils/Chores'
|
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 { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||||
import LoadingComponent from '../components/Loading'
|
import LoadingComponent from '../components/Loading'
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ const ChoreHistoryTimeline = ({ history }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPieChart = (data, size, isPrimary) => (
|
const renderPieChart = (data, size, isPrimary, chartType = null) => (
|
||||||
<PieChart width={size} height={size}>
|
<PieChart width={size} height={size}>
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
@@ -147,7 +147,16 @@ const renderPieChart = (data, size, isPrimary) => (
|
|||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
{isPrimary && <Tooltip />}
|
{isPrimary && (
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name, props) => {
|
||||||
|
if (chartType === 'tasksTime' && props.payload.count) {
|
||||||
|
return [`${value}h (${props.payload.count} times)`, name]
|
||||||
|
}
|
||||||
|
return [`${value}`, name]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isPrimary && (
|
{isPrimary && (
|
||||||
<Legend
|
<Legend
|
||||||
layout='horizontal'
|
layout='horizontal'
|
||||||
@@ -162,7 +171,7 @@ const renderPieChart = (data, size, isPrimary) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const USER_FILTER = (history, userId) => {
|
const USER_FILTER = (history, userId) => {
|
||||||
if (userId === undefined) return true
|
if (userId === undefined || userId === 'all') return true
|
||||||
return history.completedBy === userId
|
return history.completedBy === userId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +181,6 @@ const UserActivites = () => {
|
|||||||
const [tabValue, setTabValue] = React.useState(30)
|
const [tabValue, setTabValue] = React.useState(30)
|
||||||
const [selectedHistory, setSelectedHistory] = React.useState([])
|
const [selectedHistory, setSelectedHistory] = React.useState([])
|
||||||
const [enrichedHistory, setEnrichedHistory] = React.useState([])
|
const [enrichedHistory, setEnrichedHistory] = React.useState([])
|
||||||
const [selectedFilter, setSelectedFilter] = React.useState('Anyone')
|
|
||||||
const [selectedChart, setSelectedChart] = React.useState('history')
|
const [selectedChart, setSelectedChart] = React.useState('history')
|
||||||
|
|
||||||
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
|
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
|
||||||
@@ -183,18 +191,22 @@ const UserActivites = () => {
|
|||||||
const [choresPriorityChartData, setChoresPriorityChartData] = React.useState(
|
const [choresPriorityChartData, setChoresPriorityChartData] = React.useState(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
const [choresLabelsChartData, setChoresLabelsChartData] = React.useState([])
|
||||||
|
const [choresLabelsDurationChartData, setChoresLabelsDurationChartData] =
|
||||||
|
React.useState([])
|
||||||
|
const [tasksTimeChartData, setTasksTimeChartData] = React.useState([])
|
||||||
|
const [
|
||||||
|
choresAssigneeBreakdownChartData,
|
||||||
|
setChoresAssigneeBreakdownChartData,
|
||||||
|
] = React.useState([])
|
||||||
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
||||||
const {
|
const {
|
||||||
data: choresHistory,
|
data: choresHistory,
|
||||||
isChoresHistoryLoading,
|
isChoresHistoryLoading,
|
||||||
handleLimitChange: refetchHistory,
|
handleLimitChange: refetchHistory,
|
||||||
} = useChoresHistory(tabValue ? tabValue : 30, true)
|
} = useChoresHistory(tabValue ? tabValue : 30, true)
|
||||||
const {
|
const { data: circleMembersData } = useCircleMembers()
|
||||||
data: circleMembersData,
|
const [selectedUser, setSelectedUser] = React.useState('all')
|
||||||
isLoading: isCircleMembersLoading,
|
|
||||||
handleRefetch: handleCircleMembersRefetch,
|
|
||||||
} = useCircleMembers()
|
|
||||||
const [selectedUser, setSelectedUser] = React.useState(userProfile?.id)
|
|
||||||
const [circleUsers, setCircleUsers] = useState([])
|
const [circleUsers, setCircleUsers] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -204,7 +216,12 @@ const UserActivites = () => {
|
|||||||
}, [circleMembersData])
|
}, [circleMembersData])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isChoresHistoryLoading && !isChoresLoading && choresHistory) {
|
if (
|
||||||
|
!isChoresHistoryLoading &&
|
||||||
|
!isChoresLoading &&
|
||||||
|
choresHistory &&
|
||||||
|
choresData?.res
|
||||||
|
) {
|
||||||
const enrichedHistory = choresHistory.map(item => {
|
const enrichedHistory = choresHistory.map(item => {
|
||||||
const chore = choresData.res.find(chore => chore.id === item.choreId)
|
const chore = choresData.res.find(chore => chore.id === item.choreId)
|
||||||
return {
|
return {
|
||||||
@@ -214,23 +231,38 @@ const UserActivites = () => {
|
|||||||
})
|
})
|
||||||
setEnrichedHistory(enrichedHistory)
|
setEnrichedHistory(enrichedHistory)
|
||||||
|
|
||||||
setSelectedHistory(
|
const filteredHistory = enrichedHistory.filter(h =>
|
||||||
enrichedHistory.filter(h => USER_FILTER(h, selectedUser)),
|
USER_FILTER(h, selectedUser),
|
||||||
)
|
)
|
||||||
setHistoryPieChartData(generateHistoryPieChartData(enrichedHistory))
|
setSelectedHistory(filteredHistory)
|
||||||
|
setHistoryPieChartData(generateHistoryPieChartData(filteredHistory))
|
||||||
|
|
||||||
|
// Generate labels duration chart data when both chores and history are available
|
||||||
|
setChoresLabelsDurationChartData(
|
||||||
|
generateChoreLabelsWithDurationChartData(
|
||||||
|
choresData.res,
|
||||||
|
filteredHistory,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate tasks time chart data
|
||||||
|
setTasksTimeChartData(generateTasksTimeChartData(filteredHistory))
|
||||||
}
|
}
|
||||||
}, [isChoresHistoryLoading, isChoresLoading, choresHistory])
|
}, [
|
||||||
|
isChoresHistoryLoading,
|
||||||
|
isChoresLoading,
|
||||||
|
choresHistory,
|
||||||
|
choresData?.res,
|
||||||
|
selectedUser,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isChoresLoading && choresData) {
|
if (!isChoresLoading && choresData) {
|
||||||
const choreDuePieChartData = generateChoreDuePieChartData(choresData.res)
|
// Filter chores based on selected user
|
||||||
setChoreDuePieChartData(choreDuePieChartData)
|
const filteredChores =
|
||||||
setChoresAssignedChartData(generateChoreAssignedChartData(choresData.res))
|
selectedUser === 'all' || selectedUser === undefined
|
||||||
setChoresPriorityChartData(
|
? choresData.res
|
||||||
generateChorePriorityPieChartData(choresData.res),
|
: choresData.res.filter(chore => chore.assignedTo === selectedUser)
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [isChoresLoading, choresData])
|
|
||||||
|
|
||||||
const generateChoreAssignedChartData = chores => {
|
const generateChoreAssignedChartData = chores => {
|
||||||
var assignedToMe = 0
|
var assignedToMe = 0
|
||||||
@@ -261,8 +293,8 @@ const UserActivites = () => {
|
|||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateChoreDuePieChartData = chores => {
|
const generateChorePriorityPieChartData = chores => {
|
||||||
const groups = ChoresGrouper('due_date', chores, null)
|
const groups = ChoresGrouper('priority', chores, null)
|
||||||
return groups
|
return groups
|
||||||
.map(group => {
|
.map(group => {
|
||||||
return {
|
return {
|
||||||
@@ -274,8 +306,205 @@ const UserActivites = () => {
|
|||||||
})
|
})
|
||||||
.filter(item => item.value > 0)
|
.filter(item => item.value > 0)
|
||||||
}
|
}
|
||||||
const generateChorePriorityPieChartData = chores => {
|
|
||||||
const groups = ChoresGrouper('priority', chores, null)
|
const generateChoreLabelsChartData = chores => {
|
||||||
|
const labelCounts = {}
|
||||||
|
let unlabeledCount = 0
|
||||||
|
|
||||||
|
chores.forEach(chore => {
|
||||||
|
if (chore.labelsV2 && chore.labelsV2.length > 0) {
|
||||||
|
chore.labelsV2.forEach(label => {
|
||||||
|
if (labelCounts[label.id]) {
|
||||||
|
labelCounts[label.id].count++
|
||||||
|
} else {
|
||||||
|
labelCounts[label.id] = {
|
||||||
|
label: label.name,
|
||||||
|
count: 1,
|
||||||
|
color: label.color || TASK_COLOR.ANYTIME,
|
||||||
|
id: label.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
unlabeledCount++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = Object.values(labelCounts)
|
||||||
|
.map(item => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
color: item.color,
|
||||||
|
id: item.id,
|
||||||
|
}))
|
||||||
|
.filter(item => item.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value) // Sort by count descending
|
||||||
|
|
||||||
|
// Add unlabeled tasks if there are any
|
||||||
|
if (unlabeledCount > 0) {
|
||||||
|
result.push({
|
||||||
|
label: 'No Labels',
|
||||||
|
value: unlabeledCount,
|
||||||
|
color: TASK_COLOR.ANYTIME,
|
||||||
|
id: 'unlabeled',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateChoreAssigneeBreakdownChartData = chores => {
|
||||||
|
const assigneeCounts = {}
|
||||||
|
|
||||||
|
// Define a set of distinct colors for different assignees
|
||||||
|
|
||||||
|
const assigneeColors = Object.values(COLORS)
|
||||||
|
|
||||||
|
let colorIndex = 0
|
||||||
|
|
||||||
|
chores.forEach(chore => {
|
||||||
|
const assignee = circleUsers.find(
|
||||||
|
user => user.userId === chore.assignedTo,
|
||||||
|
)
|
||||||
|
const assigneeName = assignee ? assignee.displayName : 'Unassigned'
|
||||||
|
const assigneeId = chore.assignedTo || 'unassigned'
|
||||||
|
|
||||||
|
if (assigneeCounts[assigneeId]) {
|
||||||
|
assigneeCounts[assigneeId].count++
|
||||||
|
} else {
|
||||||
|
assigneeCounts[assigneeId] = {
|
||||||
|
label: assigneeName,
|
||||||
|
count: 1,
|
||||||
|
color:
|
||||||
|
assigneeId === 'unassigned'
|
||||||
|
? TASK_COLOR.ANYTIME
|
||||||
|
: assigneeColors[colorIndex % assigneeColors.length],
|
||||||
|
id: assigneeId,
|
||||||
|
}
|
||||||
|
if (assigneeId !== 'unassigned') {
|
||||||
|
colorIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(assigneeCounts)
|
||||||
|
.map(item => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
color: item.color,
|
||||||
|
id: item.id,
|
||||||
|
}))
|
||||||
|
.filter(item => item.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value) // Sort by count descending
|
||||||
|
}
|
||||||
|
|
||||||
|
const choreDuePieChartData = generateChoreDuePieChartData(filteredChores)
|
||||||
|
setChoreDuePieChartData(choreDuePieChartData)
|
||||||
|
setChoresAssignedChartData(generateChoreAssignedChartData(filteredChores))
|
||||||
|
setChoresPriorityChartData(
|
||||||
|
generateChorePriorityPieChartData(filteredChores),
|
||||||
|
)
|
||||||
|
setChoresLabelsChartData(generateChoreLabelsChartData(filteredChores))
|
||||||
|
setChoresAssigneeBreakdownChartData(
|
||||||
|
generateChoreAssigneeBreakdownChartData(filteredChores),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [isChoresLoading, choresData, userProfile?.id, circleUsers, selectedUser])
|
||||||
|
|
||||||
|
const generateChoreLabelsWithDurationChartData = (chores, history) => {
|
||||||
|
const labelDurations = {}
|
||||||
|
let unlabeledDuration = 0
|
||||||
|
|
||||||
|
// Iterate through ChoreHistory to get actual time spent
|
||||||
|
history.forEach(historyItem => {
|
||||||
|
const duration = historyItem.duration || 0 // duration in seconds from ChoreHistory
|
||||||
|
|
||||||
|
// Find the corresponding chore to get its labels
|
||||||
|
const chore = chores.find(c => c.id === historyItem.choreId)
|
||||||
|
|
||||||
|
if (chore && chore.labelsV2 && chore.labelsV2.length > 0) {
|
||||||
|
// If chore has labels, add duration to each label
|
||||||
|
chore.labelsV2.forEach(label => {
|
||||||
|
if (labelDurations[label.id]) {
|
||||||
|
labelDurations[label.id].duration += duration
|
||||||
|
} else {
|
||||||
|
labelDurations[label.id] = {
|
||||||
|
label: label.name,
|
||||||
|
duration: duration,
|
||||||
|
color: label.color || TASK_COLOR.ANYTIME,
|
||||||
|
id: label.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// If chore has no labels or chore not found, add to unlabeled
|
||||||
|
unlabeledDuration += duration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert seconds to hours for better readability
|
||||||
|
const result = Object.values(labelDurations)
|
||||||
|
.map(item => ({
|
||||||
|
label: item.label,
|
||||||
|
value: Math.round((item.duration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal
|
||||||
|
color: item.color,
|
||||||
|
id: item.id,
|
||||||
|
}))
|
||||||
|
.filter(item => item.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value) // Sort by duration descending
|
||||||
|
|
||||||
|
// Add unlabeled tasks duration if there is any
|
||||||
|
if (unlabeledDuration > 0) {
|
||||||
|
result.push({
|
||||||
|
label: 'No Labels',
|
||||||
|
value: Math.round((unlabeledDuration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal
|
||||||
|
color: TASK_COLOR.ANYTIME,
|
||||||
|
id: 'unlabeled',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateTasksTimeChartData = history => {
|
||||||
|
const taskDurations = {}
|
||||||
|
const colorValues = Object.values(COLORS)
|
||||||
|
|
||||||
|
// Iterate through ChoreHistory to get actual time spent per task
|
||||||
|
history.forEach(historyItem => {
|
||||||
|
const duration = historyItem.duration || 0 // duration in seconds from ChoreHistory
|
||||||
|
const taskName = historyItem.choreName || 'Unknown Task'
|
||||||
|
|
||||||
|
if (taskDurations[taskName]) {
|
||||||
|
taskDurations[taskName].duration += duration
|
||||||
|
taskDurations[taskName].count += 1
|
||||||
|
} else {
|
||||||
|
taskDurations[taskName] = {
|
||||||
|
taskName: taskName,
|
||||||
|
duration: duration,
|
||||||
|
count: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert seconds to hours and prepare chart data
|
||||||
|
const result = Object.values(taskDurations)
|
||||||
|
.map((item, index) => ({
|
||||||
|
label: item.taskName,
|
||||||
|
value: Math.round((item.duration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal
|
||||||
|
count: item.count,
|
||||||
|
color: colorValues[index % colorValues.length],
|
||||||
|
id: item.taskName,
|
||||||
|
}))
|
||||||
|
.filter(item => item.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value) // Sort by time spent descending
|
||||||
|
.slice(0, 10) // Show top 10 tasks only
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateChoreDuePieChartData = chores => {
|
||||||
|
const groups = ChoresGrouper('due_date', chores, null)
|
||||||
return groups
|
return groups
|
||||||
.map(group => {
|
.map(group => {
|
||||||
return {
|
return {
|
||||||
@@ -319,7 +548,6 @@ const UserActivites = () => {
|
|||||||
if (isChoresHistoryLoading || isChoresLoading) {
|
if (isChoresHistoryLoading || isChoresLoading) {
|
||||||
return <LoadingComponent />
|
return <LoadingComponent />
|
||||||
}
|
}
|
||||||
const COLORS = historyPieChartData.map(item => item.color)
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
history: {
|
history: {
|
||||||
data: historyPieChartData,
|
data: historyPieChartData,
|
||||||
@@ -331,18 +559,40 @@ const UserActivites = () => {
|
|||||||
title: 'Due Date',
|
title: 'Due Date',
|
||||||
description: 'Current tasks due date',
|
description: 'Current tasks due date',
|
||||||
},
|
},
|
||||||
assigned: {
|
// assigned: {
|
||||||
data: choresAssignedChartData,
|
// data: choresAssignedChartData,
|
||||||
title: 'Assignee',
|
// title: 'Assigned to me',
|
||||||
description: 'Tasks assigned to you vs others',
|
// description: 'Tasks assigned to you vs others',
|
||||||
},
|
// },
|
||||||
priority: {
|
priority: {
|
||||||
data: choresPriorityChartData,
|
data: choresPriorityChartData,
|
||||||
title: 'Priority',
|
title: 'Priority',
|
||||||
description: 'Tasks by priority',
|
description: 'Tasks by priority',
|
||||||
},
|
},
|
||||||
|
labels: {
|
||||||
|
data: choresLabelsChartData,
|
||||||
|
title: 'Labels',
|
||||||
|
description: 'Tasks by labels',
|
||||||
|
},
|
||||||
|
labelsDuration: {
|
||||||
|
data: choresLabelsDurationChartData,
|
||||||
|
title: 'Labels (time)',
|
||||||
|
description: 'Time spent by labels (hours)',
|
||||||
|
},
|
||||||
|
tasksTime: {
|
||||||
|
data: tasksTimeChartData,
|
||||||
|
title: 'Tasks (time)',
|
||||||
|
description: 'Time spent by individual tasks (hours)',
|
||||||
|
},
|
||||||
|
assigneeBreakdown: {
|
||||||
|
data: choresAssigneeBreakdownChartData,
|
||||||
|
title: 'by Assignee',
|
||||||
|
description: 'Tasks grouped by assignee',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (!userProfile) {
|
||||||
|
return <LoadingComponent />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!choresData.res?.length > 0 || !choresHistory?.length > 0) {
|
if (!choresData.res?.length > 0 || !choresHistory?.length > 0) {
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
@@ -379,71 +629,137 @@ const UserActivites = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
maxWidth='md'
|
maxWidth='xl'
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
px: { xs: 2, sm: 3 },
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box mb={1}>
|
<Typography
|
||||||
<Typography mb={2} level='h4'>
|
mb={3}
|
||||||
Points Overview
|
level='h4'
|
||||||
|
sx={{
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activities Overview
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', lg: 'row' },
|
||||||
|
gap: 3,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left Side - Timeline with Filters (Mobile: Full width, Desktop: Flexible) */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
|
||||||
|
{/* Improved Filter Bar - Now above timeline */}
|
||||||
|
<Card
|
||||||
|
variant='outlined'
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
p: 2,
|
||||||
|
mb: 3,
|
||||||
|
borderRadius: 12,
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
Filter Activities
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
>
|
||||||
|
{/* User Filter */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||||
|
Show activities for:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Select
|
<Select
|
||||||
sx={{
|
sx={{
|
||||||
width: 150,
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
variant='soft'
|
variant='outlined'
|
||||||
label='User'
|
|
||||||
value={selectedUser}
|
value={selectedUser}
|
||||||
onChange={(e, selected) => {
|
onChange={(e, selected) => {
|
||||||
setSelectedUser(selected)
|
setSelectedUser(selected)
|
||||||
setSelectedHistory(
|
setSelectedHistory(
|
||||||
enrichedHistory.filter(h => USER_FILTER(h, selected)),
|
enrichedHistory.filter(h => USER_FILTER(h, selected)),
|
||||||
)
|
)
|
||||||
console.log(
|
|
||||||
enrichedHistory,
|
|
||||||
selected,
|
|
||||||
enrichedHistory.filter(h => USER_FILTER(h, selected)),
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
renderValue={selected => (
|
renderValue={() => {
|
||||||
|
if (
|
||||||
|
selectedUser === undefined ||
|
||||||
|
selectedUser === 'all'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
startDecorator={
|
||||||
|
<Avatar color='primary' size='sm'>
|
||||||
|
<Group />
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
All Users
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
<Typography
|
<Typography
|
||||||
startDecorator={
|
startDecorator={
|
||||||
<Avatar
|
<Avatar
|
||||||
color='primary'
|
color='primary'
|
||||||
m={0}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
src={resolvePhotoURL(
|
src={resolvePhotoURL(
|
||||||
circleUsers.find(user => user.userId === selectedUser)
|
circleUsers.find(
|
||||||
?.image,
|
user => user.userId === selectedUser,
|
||||||
|
)?.image,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{
|
{circleUsers
|
||||||
circleUsers.find(user => user.userId === selectedUser)
|
.find(user => user.userId === selectedUser)
|
||||||
?.image
|
?.displayName?.charAt(0)}
|
||||||
}
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
circleUsers.find(user => user.userId === selectedUser)
|
circleUsers.find(
|
||||||
?.displayName
|
user => user.userId === selectedUser,
|
||||||
|
)?.displayName
|
||||||
}
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<Option value='all'>
|
||||||
|
<Typography
|
||||||
|
startDecorator={
|
||||||
|
<Avatar color='primary' size='sm'>
|
||||||
|
<Group />
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
All Users
|
||||||
|
</Typography>
|
||||||
|
</Option>
|
||||||
{circleUsers.map(user => (
|
{circleUsers.map(user => (
|
||||||
<Option key={user.userId} value={user.userId}>
|
<Option key={user.userId} value={user.userId}>
|
||||||
<Avatar
|
<Avatar
|
||||||
color='primary'
|
color='primary'
|
||||||
m={0}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
src={resolvePhotoURL(user.image)}
|
src={resolvePhotoURL(user.image)}
|
||||||
>
|
>
|
||||||
{user.image}
|
{user.displayName?.charAt(0)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography>{user.displayName}</Typography>
|
<Typography>{user.displayName}</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -458,42 +774,57 @@ const UserActivites = () => {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
|
|
||||||
|
{/* Time Period Filter */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||||
|
Time period:
|
||||||
|
</Typography>
|
||||||
<Tabs
|
<Tabs
|
||||||
onChange={(e, tabValue) => {
|
onChange={(e, tabValue) => {
|
||||||
setTabValue(tabValue)
|
setTabValue(tabValue)
|
||||||
refetchHistory(tabValue)
|
refetchHistory(tabValue)
|
||||||
}}
|
}}
|
||||||
defaultValue={7}
|
value={tabValue}
|
||||||
sx={{
|
sx={{
|
||||||
py: 0.5,
|
borderRadius: 8,
|
||||||
borderRadius: 16,
|
backgroundColor: 'background.surface',
|
||||||
maxWidth: 400,
|
border: '1px solid',
|
||||||
mb: 1,
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabList
|
<TabList
|
||||||
disableUnderline
|
disableUnderline
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 16,
|
borderRadius: 8,
|
||||||
backgroundColor: 'background.paper',
|
backgroundColor: 'transparent',
|
||||||
boxShadow: 1,
|
p: 0.5,
|
||||||
justifyContent: 'space-evenly',
|
gap: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ label: '7 Days', value: 7 },
|
{ label: '7 Days', value: 7 },
|
||||||
{ label: '30 Days', value: 30 },
|
{ label: '30 Days', value: 30 },
|
||||||
{ label: '90 Days', value: 90 },
|
{ label: '90 Days', value: 90 },
|
||||||
|
{ label: 'All Time', value: 365 },
|
||||||
].map((tab, index) => (
|
].map((tab, index) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={index}
|
key={index}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 16,
|
borderRadius: 6,
|
||||||
|
minWidth: 'auto',
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
fontSize: 'sm',
|
||||||
|
fontWeight: 500,
|
||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
color: 'text.primary',
|
color: 'primary.plainColor',
|
||||||
backgroundColor: 'primary.light',
|
backgroundColor: 'primary.softBg',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'neutral.softHoverBg',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
disableIndicator
|
disableIndicator
|
||||||
@@ -505,33 +836,157 @@ const UserActivites = () => {
|
|||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ mb: 4 }}>
|
</Stack>
|
||||||
<Typography level='h4' textAlign='center'>
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Current Filter Summary */}
|
||||||
|
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||||
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
Showing activities for{' '}
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||||
|
>
|
||||||
|
{selectedUser === undefined || selectedUser === 'all'
|
||||||
|
? 'All Users'
|
||||||
|
: circleUsers.find(user => user.userId === selectedUser)
|
||||||
|
?.displayName || 'Unknown User'}
|
||||||
|
</Typography>{' '}
|
||||||
|
over the{' '}
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||||
|
>
|
||||||
|
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ChoreHistoryTimeline history={selectedHistory} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: { xs: '100%', lg: '350px' },
|
||||||
|
position: { xs: 'static', lg: 'sticky' },
|
||||||
|
top: { lg: '20px' },
|
||||||
|
alignSelf: { lg: 'flex-start' },
|
||||||
|
maxHeight: { lg: 'calc(100vh - 40px)' },
|
||||||
|
overflowY: { lg: 'auto' },
|
||||||
|
order: { xs: -1, lg: 1 }, // Show charts first on mobile, last on desktop
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Charts Container */}
|
||||||
|
<Card
|
||||||
|
variant='outlined'
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 12,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Main Chart */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
minHeight: { lg: '400px' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level='h4' textAlign='center' sx={{ mb: 1 }}>
|
||||||
{chartData[selectedChart].title}
|
{chartData[selectedChart].title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-xs' textAlign='center'>
|
<Typography level='body-xs' textAlign='center' sx={{ mb: 2 }}>
|
||||||
{chartData[selectedChart].description}
|
{chartData[selectedChart].description}
|
||||||
</Typography>
|
</Typography>
|
||||||
{renderPieChart(chartData[selectedChart].data, 250, true)}
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderPieChart(
|
||||||
|
chartData[selectedChart].data,
|
||||||
|
240,
|
||||||
|
true,
|
||||||
|
selectedChart,
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Chart Selection Grid */}
|
||||||
|
<Box>
|
||||||
<Grid container spacing={1}>
|
<Grid container spacing={1}>
|
||||||
{Object.entries(chartData)
|
{Object.entries(chartData)
|
||||||
.filter(([key]) => key !== selectedChart)
|
.filter(([key]) => key !== selectedChart)
|
||||||
.map(([key, { data, title }]) => (
|
.map(([key, { data, title }]) => (
|
||||||
<Grid item key={key} xs={4}>
|
<Grid
|
||||||
|
item
|
||||||
|
key={key}
|
||||||
|
xs={4}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card
|
<Card
|
||||||
onClick={() => setSelectedChart(key)}
|
onClick={() => setSelectedChart(key)}
|
||||||
sx={{ cursor: 'pointer', p: 1 }}
|
variant='plain'
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
p: 1,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: 80,
|
||||||
|
maxWidth: 90,
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
boxShadow: 'sm',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
textAlign='center'
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
mb: 0.5,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Typography textAlign='center' level='body-xs' mb={-2}>
|
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{renderPieChart(data, 75, false)}
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderPieChart(data, 70, false)}
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
<ChoreHistoryTimeline history={selectedHistory} />
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
TabList,
|
TabList,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -50,16 +51,15 @@ const UserPoints = () => {
|
|||||||
const [selectedUser, setSelectedUser] = useState(userProfile?.id)
|
const [selectedUser, setSelectedUser] = useState(userProfile?.id)
|
||||||
const [circleUsers, setCircleUsers] = useState([])
|
const [circleUsers, setCircleUsers] = useState([])
|
||||||
const [selectedHistory, setSelectedHistory] = useState([])
|
const [selectedHistory, setSelectedHistory] = useState([])
|
||||||
const [userPointsBarChartData, setUserPointsBarChartData] = useState([])
|
|
||||||
|
|
||||||
const [choresHistory, setChoresHistory] = useState([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (circleMembersData && choresHistoryData && userProfile) {
|
if (circleMembersData && choresHistoryData && userProfile) {
|
||||||
setCircleUsers(circleMembersData.res)
|
setCircleUsers(circleMembersData.res)
|
||||||
setSelectedHistory(generateWeeklySummary(choresHistory, userProfile?.id))
|
setSelectedHistory(
|
||||||
|
generateWeeklySummary(choresHistoryData, userProfile?.id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [circleMembersData, choresHistoryData])
|
}, [circleMembersData, choresHistoryData, userProfile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (choresHistoryData) {
|
if (choresHistoryData) {
|
||||||
@@ -75,25 +75,12 @@ const UserPoints = () => {
|
|||||||
}
|
}
|
||||||
setSelectedHistory(history)
|
setSelectedHistory(history)
|
||||||
}
|
}
|
||||||
}, [selectedUser, choresHistoryData])
|
}, [selectedUser, choresHistoryData, tabValue])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedUser(userProfile?.id)
|
setSelectedUser(userProfile?.id)
|
||||||
}, [userProfile])
|
}, [userProfile])
|
||||||
|
|
||||||
const generateUserPointsHistory = history => {
|
|
||||||
const userPoints = {}
|
|
||||||
for (let i = 0; i < history.length; i++) {
|
|
||||||
const chore = history[i]
|
|
||||||
if (!userPoints[chore.completedBy]) {
|
|
||||||
userPoints[chore.completedBy] = chore.points ? chore.points : 0
|
|
||||||
} else {
|
|
||||||
userPoints[chore.completedBy] += chore.points ? chore.points : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return userPoints
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateWeeklySummary = (history, userId) => {
|
const generateWeeklySummary = (history, userId) => {
|
||||||
const daysAggregated = []
|
const daysAggregated = []
|
||||||
for (let i = 6; i > -1; i--) {
|
for (let i = 6; i > -1; i--) {
|
||||||
@@ -221,56 +208,79 @@ const UserPoints = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
maxWidth='md'
|
maxWidth='xl'
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
px: { xs: 2, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Typography
|
||||||
|
mb={3}
|
||||||
|
level='h4'
|
||||||
sx={{
|
sx={{
|
||||||
mb: 4,
|
alignSelf: 'flex-start',
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='h4'>Points Overview</Typography>
|
Points Overview
|
||||||
<Box
|
</Typography>
|
||||||
|
|
||||||
|
{/* Improved Filter Bar */}
|
||||||
|
<Card
|
||||||
|
variant='outlined'
|
||||||
sx={{
|
sx={{
|
||||||
gap: 1,
|
width: '100%',
|
||||||
my: 2,
|
p: 2,
|
||||||
display: 'flex',
|
mb: 3,
|
||||||
justifyContent: 'start',
|
borderRadius: 12,
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
Filter Points
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
>
|
||||||
|
{/* User Filter */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||||
|
Show points for:
|
||||||
|
</Typography>
|
||||||
<Select
|
<Select
|
||||||
sx={{
|
sx={{
|
||||||
width: 200,
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
variant='soft'
|
variant='outlined'
|
||||||
label='User'
|
|
||||||
value={selectedUser}
|
value={selectedUser}
|
||||||
onChange={(e, selected) => {
|
onChange={(e, selected) => {
|
||||||
setSelectedUser(selected)
|
setSelectedUser(selected)
|
||||||
setSelectedHistory(generateWeeklySummary(choresHistory, selected))
|
setSelectedHistory(
|
||||||
|
generateWeeklySummary(choresHistoryData, selected),
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
renderValue={selected => (
|
renderValue={() => {
|
||||||
|
return (
|
||||||
<Typography
|
<Typography
|
||||||
startDecorator={
|
startDecorator={
|
||||||
<Avatar
|
<Avatar
|
||||||
color='primary'
|
color='primary'
|
||||||
m={0}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
src={resolvePhotoURL(
|
src={resolvePhotoURL(
|
||||||
circleUsers.find(user => user.userId === selectedUser)
|
circleUsers.find(
|
||||||
?.image,
|
user => user.userId === selectedUser,
|
||||||
|
)?.image,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{
|
{circleUsers
|
||||||
circleUsers.find(user => user.userId === selectedUser)
|
.find(user => user.userId === selectedUser)
|
||||||
?.displayName[0]
|
?.displayName?.charAt(0)}
|
||||||
}
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -279,17 +289,17 @@ const UserPoints = () => {
|
|||||||
?.displayName
|
?.displayName
|
||||||
}
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{circleUsers.map(user => (
|
{circleUsers.map(user => (
|
||||||
<Option key={user.userId} value={user.userId}>
|
<Option key={user.userId} value={user.userId}>
|
||||||
<Avatar
|
<Avatar
|
||||||
color='primary'
|
color='primary'
|
||||||
m={0}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
src={resolvePhotoURL(user.image)}
|
src={resolvePhotoURL(user.image)}
|
||||||
>
|
>
|
||||||
{user.displayName[0]}
|
{user.displayName?.charAt(0)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography>{user.displayName}</Typography>
|
<Typography>{user.displayName}</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -303,8 +313,73 @@ const UserPoints = () => {
|
|||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Time Period Filter */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||||
|
Time period:
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
onChange={(e, tabValue) => {
|
||||||
|
setTabValue(tabValue)
|
||||||
|
handleChoresHistoryLimitChange(tabValue)
|
||||||
|
}}
|
||||||
|
value={tabValue}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'background.surface',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabList
|
||||||
|
disableUnderline
|
||||||
|
sx={{
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
p: 0.5,
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: '7 Days', value: 7 },
|
||||||
|
{ label: '6 Months', value: 6 * 30 },
|
||||||
|
{ label: 'All Time', value: 24 * 30 },
|
||||||
|
].map((tab, index) => (
|
||||||
|
<Tab
|
||||||
|
key={index}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 6,
|
||||||
|
minWidth: 'auto',
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
fontSize: 'sm',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&.Mui-selected': {
|
||||||
|
color: 'primary.plainColor',
|
||||||
|
backgroundColor: 'primary.softBg',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'neutral.softHoverBg',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
disableIndicator
|
||||||
|
value={tab.value}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Redeem Points Button */}
|
||||||
{circleUsers.find(user => user.userId === userProfile.id)?.role ===
|
{circleUsers.find(user => user.userId === userProfile.id)?.role ===
|
||||||
'admin' && (
|
'admin' && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||||
<Button
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
size='md'
|
size='md'
|
||||||
@@ -312,12 +387,50 @@ const UserPoints = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRedeemModalOpen(true)
|
setIsRedeemModalOpen(true)
|
||||||
}}
|
}}
|
||||||
|
sx={{ mt: 'auto' }}
|
||||||
>
|
>
|
||||||
Redeem Points
|
Redeem Points
|
||||||
</Button>
|
</Button>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Current Filter Summary */}
|
||||||
|
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||||
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
Showing points for{' '}
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||||
|
>
|
||||||
|
{circleUsers.find(user => user.userId === selectedUser)
|
||||||
|
?.displayName || 'Unknown User'}
|
||||||
|
</Typography>{' '}
|
||||||
|
over the{' '}
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||||
|
>
|
||||||
|
{tabValue === 24 * 30
|
||||||
|
? 'All Time'
|
||||||
|
: tabValue === 6 * 30
|
||||||
|
? 'Last 6 Months'
|
||||||
|
: `Last ${tabValue} Days`}
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 4,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Points Cards */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
// resposive width based on parent available space:
|
// resposive width based on parent available space:
|
||||||
@@ -344,7 +457,6 @@ const UserPoints = () => {
|
|||||||
if (!user) return 0
|
if (!user) return 0
|
||||||
return user.points - user.pointsRedeemed
|
return user.points - user.pointsRedeemed
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
color: 'success',
|
color: 'success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -374,63 +486,11 @@ const UserPoints = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography level='h4'>Points History</Typography>
|
|
||||||
|
|
||||||
<Box
|
{/* Points History Section */}
|
||||||
sx={{
|
<Typography level='h4' sx={{ mt: 2, mb: 2 }}>
|
||||||
// center vertically:
|
Points History
|
||||||
display: 'flex',
|
</Typography>
|
||||||
justifyContent: 'left',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
onChange={(e, tabValue) => {
|
|
||||||
setTabValue(tabValue)
|
|
||||||
handleChoresHistoryLimitChange(tabValue)
|
|
||||||
}}
|
|
||||||
defaultValue={tabValue}
|
|
||||||
sx={{
|
|
||||||
py: 0.5,
|
|
||||||
borderRadius: 16,
|
|
||||||
maxWidth: 400,
|
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TabList
|
|
||||||
disableUnderline
|
|
||||||
sx={{
|
|
||||||
borderRadius: 16,
|
|
||||||
backgroundColor: 'background.paper',
|
|
||||||
boxShadow: 1,
|
|
||||||
justifyContent: 'space-evenly',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: '7 Days', value: 7 },
|
|
||||||
// { label: '3 Month', value: 30 },
|
|
||||||
{ label: '6 Months', value: 6 * 30 },
|
|
||||||
{ label: 'All Time', value: 24 * 30 },
|
|
||||||
].map((tab, index) => (
|
|
||||||
<Tab
|
|
||||||
key={index}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 16,
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&.Mui-selected': {
|
|
||||||
color: 'text.primary',
|
|
||||||
backgroundColor: 'primary.light',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
disableIndicator
|
|
||||||
value={tab.value}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Tab>
|
|
||||||
))}
|
|
||||||
</TabList>
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -439,6 +499,7 @@ const UserPoints = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'left',
|
justifyContent: 'left',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
@@ -471,7 +532,8 @@ const UserPoints = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
{/* Bar Chart for points overtime : */}
|
|
||||||
|
{/* Bar Chart for points overtime */}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
||||||
<ResponsiveContainer height={300}>
|
<ResponsiveContainer height={300}>
|
||||||
<BarChart
|
<BarChart
|
||||||
@@ -480,22 +542,18 @@ const UserPoints = () => {
|
|||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray={'3 3'} />
|
<CartesianGrid strokeDasharray={'3 3'} />
|
||||||
<XAxis dataKey='label' axisLine={false} tickLine={false} />
|
<XAxis dataKey='label' axisLine={false} tickLine={false} />
|
||||||
|
|
||||||
<YAxis axisLine={false} tickLine={false} />
|
<YAxis axisLine={false} tickLine={false} />
|
||||||
|
|
||||||
<Bar
|
<Bar
|
||||||
fill='#4183F2'
|
fill='#4183F2'
|
||||||
dataKey='points'
|
dataKey='points'
|
||||||
barSize={30}
|
barSize={30}
|
||||||
radius={[5, 5, 0, 0]}
|
radius={[5, 5, 0, 0]}
|
||||||
>
|
/>
|
||||||
{/* Rounded top corners, blue fill, set bar width */}
|
|
||||||
{/* Add a slightly darker top section to the 'Jul' bar */}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<RedeemPointsModal
|
<RedeemPointsModal
|
||||||
config={{
|
config={{
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
@@ -507,7 +565,7 @@ const UserPoints = () => {
|
|||||||
user: circleUsers.find(user => user.userId === selectedUser),
|
user: circleUsers.find(user => user.userId === selectedUser),
|
||||||
onSave: ({ userId, points }) => {
|
onSave: ({ userId, points }) => {
|
||||||
RedeemPoints(userId, points, userProfile.circleID)
|
RedeemPoints(userId, points, userProfile.circleID)
|
||||||
.then(res => {
|
.then(() => {
|
||||||
setIsRedeemModalOpen(false)
|
setIsRedeemModalOpen(false)
|
||||||
handleCircleMembersRefetch()
|
handleCircleMembersRefetch()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import { Add, EditNotifications } from '@mui/icons-material'
|
import { Add, EditNotifications } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Button, Chip, Input, Option, Select, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
ModalOverflow,
|
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { FormControl } from '@mui/material'
|
import { FormControl } from '@mui/material'
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import FadeModal from '../../components/common/FadeModal'
|
||||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
@@ -27,6 +17,7 @@ import {
|
|||||||
} from './CustomParsers'
|
} from './CustomParsers'
|
||||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||||
|
|
||||||
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||||
import LearnMoreButton from './LearnMore'
|
import LearnMoreButton from './LearnMore'
|
||||||
import RichTextEditor from './RichTextEditor'
|
import RichTextEditor from './RichTextEditor'
|
||||||
@@ -63,6 +54,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
|
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
const mainInputRef = useRef(null)
|
const mainInputRef = useRef(null)
|
||||||
|
const richTextEditorRef = useRef(null)
|
||||||
const [priority, setPriority] = useState(0)
|
const [priority, setPriority] = useState(0)
|
||||||
const [dueDate, setDueDate] = useState(null)
|
const [dueDate, setDueDate] = useState(null)
|
||||||
const [description, setDescription] = useState(null)
|
const [description, setDescription] = useState(null)
|
||||||
@@ -77,6 +69,82 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [hasDescription, setHasDescription] = useState(false)
|
const [hasDescription, setHasDescription] = useState(false)
|
||||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||||
const [hasNotifications, setHasNotifications] = useState(false)
|
const [hasNotifications, setHasNotifications] = useState(false)
|
||||||
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(true)
|
||||||
|
|
||||||
|
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasDescription && richTextEditorRef.current) {
|
||||||
|
// Small delay to ensure the component is fully rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
richTextEditorRef.current.focus()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}, [hasDescription])
|
||||||
|
|
||||||
|
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = event => {
|
||||||
|
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||||
|
if (isHoldingCmd) {
|
||||||
|
// event.preventDefault()
|
||||||
|
setShowKeyboardShortcuts(true)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isHoldingCmd &&
|
||||||
|
event.key.toLowerCase() === 'e' &&
|
||||||
|
isModalOpen &&
|
||||||
|
!hasDescription
|
||||||
|
) {
|
||||||
|
setHasDescription(true)
|
||||||
|
setShowKeyboardShortcuts(false)
|
||||||
|
}
|
||||||
|
if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) {
|
||||||
|
// add subtask:
|
||||||
|
setHasSubTasks(true)
|
||||||
|
setShowKeyboardShortcuts(false)
|
||||||
|
// set focus on the first subtask input:
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isHoldingCmd &&
|
||||||
|
event.key.toLowerCase() === 'b' &&
|
||||||
|
isModalOpen &&
|
||||||
|
!dueDate
|
||||||
|
) {
|
||||||
|
// add due date:
|
||||||
|
setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'))
|
||||||
|
setShowKeyboardShortcuts(false)
|
||||||
|
}
|
||||||
|
// Enter key to create task
|
||||||
|
if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
(event.ctrlKey || event.metaKey) &&
|
||||||
|
isModalOpen
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
createChore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Escape key to cancel/close modal
|
||||||
|
if (event.key === 'Escape' && isModalOpen) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleCloseModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyUp = event => {
|
||||||
|
if (event.key === 'Control' || event.key === 'Meta') {
|
||||||
|
setShowKeyboardShortcuts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
window.addEventListener('keyup', handleKeyUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
window.removeEventListener('keyup', handleKeyUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isModalOpen && textareaRef.current) {
|
if (isModalOpen && textareaRef.current) {
|
||||||
textareaRef.current.focus()
|
textareaRef.current.focus()
|
||||||
@@ -329,14 +397,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setAssignees([])
|
setAssignees([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
console.log('Submitting task:', isPlusAccount(userProfile))
|
|
||||||
|
|
||||||
// createChore()
|
|
||||||
// handleCloseModal()
|
|
||||||
// setTaskText('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const createChore = () => {
|
const createChore = () => {
|
||||||
const chore = {
|
const chore = {
|
||||||
name: taskTitle,
|
name: taskTitle,
|
||||||
@@ -386,6 +446,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
|
|
||||||
handleCloseModal(false)
|
handleCloseModal(false)
|
||||||
}
|
}
|
||||||
|
handleCloseModal()
|
||||||
|
setTaskText('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -399,9 +461,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isModalOpen} onClose={handleCloseModal}>
|
<FadeModal
|
||||||
<ModalOverflow>
|
open={isModalOpen}
|
||||||
<ModalDialog size='lg' sx={{ minWidth: '100%' }}>
|
onClose={handleCloseModal}
|
||||||
|
size='lg'
|
||||||
|
fullWidth={true}
|
||||||
|
>
|
||||||
<Typography level='h4'>Create new task</Typography>
|
<Typography level='h4'>Create new task</Typography>
|
||||||
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
|
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
|
||||||
Experimental Feature
|
Experimental Feature
|
||||||
@@ -424,10 +489,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
task's due date, priority, and frequency.
|
task's due date, priority, and frequency.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography
|
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
|
||||||
level='body-sm'
|
|
||||||
sx={{ fontWeight: 'bold', mt: 2 }}
|
|
||||||
>
|
|
||||||
Examples:
|
Examples:
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -437,21 +499,20 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<strong>Priority:</strong>For highest priority any of
|
<strong>Priority:</strong>For highest priority any of the
|
||||||
the following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
||||||
<em>Important</em>, or <em>ASAP</em>. For lower
|
<em>Important</em>, or <em>ASAP</em>. For lower priorities,
|
||||||
priorities, use <em>P2</em>, <em>P3</em>, or <em>P4</em>
|
use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
|
||||||
.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Due date:</strong> Specify dates with phrases
|
<strong>Due date:</strong> Specify dates with phrases like{' '}
|
||||||
like <em>tomorrow</em>, <em>next week</em>,{' '}
|
<em>tomorrow</em>, <em>next week</em>, <em>Monday</em>, or{' '}
|
||||||
<em>Monday</em>, or <em>August 1st at 12pm</em>.
|
<em>August 1st at 12pm</em>.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Frequency:</strong> Set recurring tasks with
|
<strong>Frequency:</strong> Set recurring tasks with terms
|
||||||
terms like <em>daily</em>, <em>weekly</em>,{' '}
|
like <em>daily</em>, <em>weekly</em>, <em>monthly</em>,{' '}
|
||||||
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
|
<em>yearly</em>, or patterns such as{' '}
|
||||||
<em>every Tuesday and Thursday</em>.
|
<em>every Tuesday and Thursday</em>.
|
||||||
</li>
|
</li>
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -501,23 +562,36 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
sx={{ width: '100%', fontSize: '16px' }}
|
sx={{ width: '100%', fontSize: '16px' }}
|
||||||
/>
|
/>
|
||||||
</Box> */}
|
</Box> */}
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
{!hasDescription && (
|
{!hasDescription && (
|
||||||
<Button
|
<Button
|
||||||
startDecorator={<Add />}
|
startDecorator={<Add />}
|
||||||
variant='plain'
|
variant='plain'
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => setHasDescription(true)}
|
onClick={() => {
|
||||||
|
setHasDescription(true)
|
||||||
|
// Focus will be handled by the useEffect hook
|
||||||
|
}}
|
||||||
|
endDecorator={
|
||||||
|
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Description
|
Description
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasSubTasks && (
|
{!hasSubTasks && (
|
||||||
<Button
|
<Button
|
||||||
startDecorator={<Add />}
|
startDecorator={<Add />}
|
||||||
variant='plain'
|
variant='plain'
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => setHasSubTasks(true)}
|
onClick={() => {
|
||||||
|
setHasSubTasks(true)
|
||||||
|
}}
|
||||||
|
endDecorator={
|
||||||
|
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Subtasks
|
Subtasks
|
||||||
</Button>
|
</Button>
|
||||||
@@ -528,10 +602,11 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
variant='plain'
|
variant='plain'
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDueDate(
|
setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'))
|
||||||
moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Due Date
|
Due Date
|
||||||
</Button>
|
</Button>
|
||||||
@@ -545,9 +620,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setHasNotifications(true)
|
setHasNotifications(true)
|
||||||
setFrequencyHumanReadable('Once')
|
setFrequencyHumanReadable('Once')
|
||||||
setFrequency(null)
|
setFrequency(null)
|
||||||
setDueDate(
|
setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'))
|
||||||
moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit Notifications
|
Edit Notifications
|
||||||
@@ -560,6 +633,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
<Typography level='body-sm'>Description:</Typography>
|
<Typography level='body-sm'>Description:</Typography>
|
||||||
<div>
|
<div>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
|
ref={richTextEditorRef}
|
||||||
onChange={setDescription}
|
onChange={setDescription}
|
||||||
entityType={'chore_description'}
|
entityType={'chore_description'}
|
||||||
/>
|
/>
|
||||||
@@ -573,6 +647,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
editMode={true}
|
editMode={true}
|
||||||
tasks={subTasks ? subTasks : []}
|
tasks={subTasks ? subTasks : []}
|
||||||
setTasks={setSubTasks}
|
setTasks={setSubTasks}
|
||||||
|
shouldFocus={true}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -585,6 +660,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{priority > 0 && (
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography level='body-sm'>Priority</Typography>
|
<Typography level='body-sm'>Priority</Typography>
|
||||||
<Select
|
<Select
|
||||||
@@ -599,6 +675,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
<Option value='4'>P4</Option>
|
<Option value='4'>P4</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
)}
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography level='body-sm'>Due Date</Typography>
|
<Typography level='body-sm'>Due Date</Typography>
|
||||||
@@ -653,8 +730,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
<NotificationTemplate
|
<NotificationTemplate
|
||||||
onChange={metadata => {
|
onChange={metadata => {
|
||||||
if (
|
if (
|
||||||
metadata.notifications !==
|
metadata.notifications !== notificationMetadata.templates
|
||||||
notificationMetadata.templates
|
|
||||||
) {
|
) {
|
||||||
const newNotificaitonMetadata = {
|
const newNotificaitonMetadata = {
|
||||||
...notificationMetadata,
|
...notificationMetadata,
|
||||||
@@ -679,20 +755,24 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant='outlined' color='neutral' onClick={handleCloseModal}>
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
|
{showKeyboardShortcuts && (
|
||||||
|
<KeyboardShortcutHint
|
||||||
|
shortcut='Esc'
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
withCtrl={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='solid' color='primary' onClick={handleSubmit}>
|
<Button variant='solid' color='primary' onClick={createChore}>
|
||||||
Create
|
Create
|
||||||
|
{showKeyboardShortcuts && (
|
||||||
|
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</FadeModal>
|
||||||
</ModalOverflow>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ const ChoreActionMenu = ({
|
|||||||
onChangeDueDate,
|
onChangeDueDate,
|
||||||
onWriteNFC,
|
onWriteNFC,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpen,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
sx = {},
|
sx = {},
|
||||||
variant = 'soft',
|
variant = 'soft',
|
||||||
}) => {
|
}) => {
|
||||||
@@ -55,6 +58,9 @@ const ChoreActionMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||||
|
if (anchorEl) {
|
||||||
|
onOpen()
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||||
}
|
}
|
||||||
@@ -158,6 +164,8 @@ const ChoreActionMenu = ({
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
color='success'
|
color='success'
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
width: 25,
|
width: 25,
|
||||||
@@ -171,11 +179,16 @@ const ChoreActionMenu = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
size='lg'
|
size='md'
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={Boolean(anchorEl)}
|
open={Boolean(anchorEl)}
|
||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: '50%',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ import imageCompression from 'browser-image-compression'
|
|||||||
import Quill from 'quill'
|
import Quill from 'quill'
|
||||||
import 'quill/dist/quill.snow.css'
|
import 'quill/dist/quill.snow.css'
|
||||||
import QuillMarkdown from 'quilljs-markdown'
|
import QuillMarkdown from 'quilljs-markdown'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import { UploadFile } from '../../utils/TokenManager'
|
import { UploadFile } from '../../utils/TokenManager'
|
||||||
import './RichTextEditor.css'
|
import './RichTextEditor.css'
|
||||||
|
|
||||||
const RichTextEditor = ({
|
const RichTextEditor = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
value = '',
|
value = '',
|
||||||
onChange,
|
onChange,
|
||||||
isEditable = true,
|
isEditable = true,
|
||||||
@@ -17,12 +25,32 @@ const RichTextEditor = ({
|
|||||||
variant = 'outlined',
|
variant = 'outlined',
|
||||||
entityId,
|
entityId,
|
||||||
entityType,
|
entityType,
|
||||||
}) => {
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const { showError } = useNotification()
|
const { showError } = useNotification()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const quillRef = useRef(null)
|
const quillRef = useRef(null)
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
|
|
||||||
|
// Expose focus method to parent components
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
focus: () => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
blur: () => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.blur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
// Image upload handler - wrapped in useCallback to avoid recreating on every render
|
// Image upload handler - wrapped in useCallback to avoid recreating on every render
|
||||||
const handleImageUpload = useCallback(() => {
|
const handleImageUpload = useCallback(() => {
|
||||||
// Check if user has plus account
|
// Check if user has plus account
|
||||||
@@ -53,7 +81,10 @@ const RichTextEditor = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compress the image
|
// Compress the image
|
||||||
const compressedFile = await imageCompression(file, compressionOptions)
|
const compressedFile = await imageCompression(
|
||||||
|
file,
|
||||||
|
compressionOptions,
|
||||||
|
)
|
||||||
|
|
||||||
// Create new file with .jpg extension to ensure it's treated as JPEG
|
// Create new file with .jpg extension to ensure it's treated as JPEG
|
||||||
const compressedJpegFile = new File(
|
const compressedJpegFile = new File(
|
||||||
@@ -62,7 +93,9 @@ const RichTextEditor = ({
|
|||||||
{ type: 'image/jpeg' },
|
{ type: 'image/jpeg' },
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`)
|
console.log(
|
||||||
|
`Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
)
|
||||||
console.log(
|
console.log(
|
||||||
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
|
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
|
||||||
)
|
)
|
||||||
@@ -213,6 +246,9 @@ const RichTextEditor = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
RichTextEditor.displayName = 'RichTextEditor'
|
||||||
|
|
||||||
export default RichTextEditor
|
export default RichTextEditor
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Chip,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
List,
|
List,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { CompleteSubTask } from '../../utils/Fetcher'
|
import { CompleteSubTask } from '../../utils/Fetcher'
|
||||||
|
|
||||||
function SortableItem({
|
function SortableItem({
|
||||||
@@ -43,10 +45,12 @@ function SortableItem({
|
|||||||
setTasks,
|
setTasks,
|
||||||
level = 0,
|
level = 0,
|
||||||
editMode,
|
editMode,
|
||||||
|
performers = [],
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
useSortable({
|
useSortable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
data: { completedAt: task.completedAt, completedBy: task.completedBy },
|
||||||
// Add touch sensor options for better mobile scrolling
|
// Add touch sensor options for better mobile scrolling
|
||||||
options: {
|
options: {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
@@ -180,8 +184,8 @@ function SortableItem({
|
|||||||
value={editedText}
|
value={editedText}
|
||||||
onChange={e => setEditedText(e.target.value)}
|
onChange={e => setEditedText(e.target.value)}
|
||||||
onBlur={handleSave}
|
onBlur={handleSave}
|
||||||
onKeyPress={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') {
|
if (!(e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
handleSave()
|
handleSave()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -206,6 +210,14 @@ function SortableItem({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{new Date(task.completedAt).toLocaleString()}
|
{new Date(task.completedAt).toLocaleString()}
|
||||||
|
{performers.find(p => p.userId === task.completedBy) ? (
|
||||||
|
<Chip>
|
||||||
|
{
|
||||||
|
performers.find(p => p.userId === task.completedBy)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</Chip>
|
||||||
|
) : null}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -281,6 +293,7 @@ function SortableItem({
|
|||||||
setTasks={setTasks}
|
setTasks={setTasks}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
|
performers={performers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -289,8 +302,16 @@ function SortableItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
const SubTasks = ({
|
||||||
|
editMode = true,
|
||||||
|
choreId = 0,
|
||||||
|
tasks = [],
|
||||||
|
setTasks,
|
||||||
|
performers,
|
||||||
|
shouldFocus = false,
|
||||||
|
}) => {
|
||||||
const [newTask, setNewTask] = useState('')
|
const [newTask, setNewTask] = useState('')
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
|
||||||
const topLevelTasks = tasks.filter(task => task.parentId === null)
|
const topLevelTasks = tasks.filter(task => task.parentId === null)
|
||||||
|
|
||||||
@@ -313,7 +334,13 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
|||||||
|
|
||||||
// Update the task
|
// Update the task
|
||||||
const updatedTasks = tasks.map(task =>
|
const updatedTasks = tasks.map(task =>
|
||||||
task.id === taskId ? { ...task, completedAt: newCompletedAt } : task,
|
task.id === taskId
|
||||||
|
? {
|
||||||
|
...task,
|
||||||
|
completedAt: newCompletedAt,
|
||||||
|
completedBy: userProfile?.id,
|
||||||
|
}
|
||||||
|
: task,
|
||||||
)
|
)
|
||||||
|
|
||||||
// If completing a task, also complete all child tasks
|
// If completing a task, also complete all child tasks
|
||||||
@@ -469,11 +496,13 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
|||||||
allTasks={tasks}
|
allTasks={tasks}
|
||||||
setTasks={setTasks}
|
setTasks={setTasks}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
|
performers={performers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{editMode && (
|
{editMode && (
|
||||||
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Input
|
<Input
|
||||||
|
autoFocus={shouldFocus}
|
||||||
placeholder='Add new task...'
|
placeholder='Add new task...'
|
||||||
value={newTask}
|
value={newTask}
|
||||||
onChange={e => setNewTask(e.target.value)}
|
onChange={e => setNewTask(e.target.value)}
|
||||||
|
|||||||
Reference in New Issue
Block a user