Merge branch 'dev'

This commit is contained in:
Mo Tarbin
2025-07-12 00:41:31 -04:00
55 changed files with 9524 additions and 2954 deletions

View File

@@ -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 /> <AppContent />
<NotificationProvider> </AuthenticationProvider>
<AppContent />
</NotificationProvider>
</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

View 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

View 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

View File

@@ -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,

View File

@@ -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 />,

View File

@@ -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
// Update individual chore cache
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
if (!oldData) return { res: updatedChore }
return { res: { ...oldData.res, ...updatedChore } }
})
// Update chores list cache - add debugging
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [updatedChore] }
if (!oldData.res || !Array.isArray(oldData.res)) {
return { res: [updatedChore] }
}
// Check if the chore exists in the cache
const choreExists = oldData.res.some(
chore => chore.id === updatedChore.id,
)
// If it's a one-time chore that's completed, we might need to remove it
if (
eventData.type === 'chore.completed' &&
updatedChore.frequencyType === 'once'
) {
return {
res: oldData.res.filter(
chore => chore.id !== updatedChore.id,
),
}
}
// If chore update then also refetch chore details:
if (eventData.type === 'chore.updated') {
queryClient.invalidateQueries(['choreDetails', updatedChore.id])
queryClient.refetchQueries({
queryKey: ['choreDetails', updatedChore.id],
})
}
// Otherwise update the existing chore or add if it doesn't exist
return {
res: choreExists
? oldData.res.map(chore => {
if (chore.id === updatedChore.id) {
return { ...chore, ...updatedChore }
}
return chore
})
: [...oldData.res, updatedChore],
}
})
// If it's a specific chore event, also invalidate that chore's details
if (eventData.data.chore?.id) {
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
queryClient.invalidateQueries([
'choreDetails',
eventData.data.chore.id,
])
}
break 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
return {
res: oldData.res.filter(
chore => chore.id !== eventData.data.choreId,
),
}
})
// If it's a specific chore event, also invalidate that chore's details
if (eventData.data.chore?.id) {
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
queryClient.invalidateQueries([
'choreDetails',
eventData.data.chore.id,
])
}
break 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({
'Connection blocked due to repeated failures. Please try again later.', title: 'Connection Temporarily Disabled',
) message:
'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

View File

@@ -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>,
) )

View File

@@ -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

View 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)

View File

@@ -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: [],

View File

@@ -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,

View 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
})

View File

@@ -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 {

View File

@@ -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,17 +173,17 @@ 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')
Navigate(redirectUrl) Navigate(redirectUrl)
} 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')

View File

@@ -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,90 +69,88 @@ 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'>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} /> <Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography level='h4' sx={{ mb: 1 }}> <Typography level='h4' sx={{ mb: 1 }}>
Two-Factor Authentication Two-Factor Authentication
</Typography> </Typography>
<Typography level='body-md' sx={{ color: 'text.secondary' }}> <Typography level='body-md' sx={{ color: 'text.secondary' }}>
Enter the verification code from your authenticator app Enter the verification code from your authenticator app
</Typography>
</Box>
<Stack spacing={3}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{isBackupCode ? 'Backup Code' : 'Verification Code'}
</Typography> </Typography>
<Input
placeholder={
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
textAlign: 'center',
fontSize: '1.1em',
letterSpacing: isBackupCode ? 'normal' : '0.1em',
}}
slotProps={{
input: {
maxLength: isBackupCode ? 50 : 6,
pattern: isBackupCode ? undefined : '[0-9]*',
},
}}
startDecorator={<Smartphone />}
autoFocus
/>
</Box> </Box>
<Stack spacing={3}> {error && (
<Box> <Alert color='danger' size='sm'>
<Typography level='body-sm' sx={{ mb: 1 }}> {error}
{isBackupCode ? 'Backup Code' : 'Verification Code'}
</Typography>
<Input
placeholder={
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
textAlign: 'center',
fontSize: '1.1em',
letterSpacing: isBackupCode ? 'normal' : '0.1em',
}}
slotProps={{
input: {
maxLength: isBackupCode ? 50 : 6,
pattern: isBackupCode ? undefined : '[0-9]*',
},
}}
startDecorator={<Smartphone />}
autoFocus
/>
</Box>
{error && (
<Alert color='danger' size='sm'>
{error}
</Alert>
)}
<Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'>
<Link
component='button'
type='button'
onClick={() => {
setIsBackupCode(!isBackupCode)
setVerificationCode('')
setError('')
}}
sx={{ fontSize: 'sm' }}
>
{isBackupCode
? 'Use authenticator app instead'
: "Can't access your authenticator? Use a backup code"}
</Link>
</Box>
<Alert color='neutral' size='sm'>
<Typography level='body-xs'>
Having trouble? Make sure your authenticator app is synced and try
again. Each backup code can only be used once.
</Typography>
</Alert> </Alert>
</Stack> )}
</ModalDialog>
</Modal> <Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'>
<Link
component='button'
type='button'
onClick={() => {
setIsBackupCode(!isBackupCode)
setVerificationCode('')
setError('')
}}
sx={{ fontSize: 'sm' }}
>
{isBackupCode
? 'Use authenticator app instead'
: "Can't access your authenticator? Use a backup code"}
</Link>
</Box>
<Alert color='neutral' size='sm'>
<Typography level='body-xs'>
Having trouble? Make sure your authenticator app is synced and try
again. Each backup code can only be used once.
</Typography>
</Alert>
</Stack>
</FadeModal>
) )
} }

View File

@@ -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) {

View File

@@ -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>
} }
/> />
@@ -645,61 +760,113 @@ const ChoreView = () => {
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'column',
gap: 1, gap: 1,
alignContent: 'center', alignContent: 'center',
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<Button <Box
fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
sx={{ sx={{
flex: 4, display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
mb: 1,
}} }}
> >
<Box>Mark as done</Box> <Button
</Button> fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
sx={{
flex: 4,
}}
>
<Box>Mark as done</Box>
</Button>
<Button <Button
fullWidth fullWidth
size='lg' size='lg'
onClick={() => { onClick={() => {
setConfirmModelConfig({ setConfirmModelConfig({
isOpen: true, isOpen: true,
title: 'Skip Task', title: 'Skip Task',
message: 'Are you sure you want to skip this task?', message: 'Are you sure you want to skip this task?',
confirmText: 'Skip', confirmText: 'Skip',
cancelText: 'Cancel', cancelText: 'Cancel',
onClose: confirmed => { onClose: confirmed => {
if (confirmed) { if (confirmed) {
handleSkippingTask() handleSkippingTask()
} }
setConfirmModelConfig({}) setConfirmModelConfig({})
}, },
}) })
}} }}
disabled={ disabled={
chore.lastCompletedDate !== null && chore.frequencyType === 'once' chore.lastCompletedDate !== null &&
} chore.frequencyType === 'once'
startDecorator={<SwitchAccessShortcut />} }
sx={{ startDecorator={<SwitchAccessShortcut />}
flex: 1, sx={{
}} flex: 1,
> }}
<Box>Skip</Box> >
</Button> <Box>Skip</Box>
</Button>
</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> </Box>
<Snackbar <Snackbar
@@ -728,6 +895,7 @@ const ChoreView = () => {
</Typography> </Typography>
</Snackbar> </Snackbar>
<ConfirmationModal config={confirmModelConfig} /> <ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
</Card> </Card>
</Container> </Container>
) )

View 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

View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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'
}
for (let i = 0; i < chores.length; i++) { if (template.value < 0) {
return 'reminder'
} else if (template.value === 0) {
return 'due'
} else {
return 'overdue'
}
}
const chore = chores[i]; const notificationType = getNotificationType()
const chorePreferences = JSON.parse(chore.notificationMetadata)
if ( chore.notification ===false || chore.nextDueDate === null) { // Truncate chore name if too long for better readability
continue; const maxChoreNameLength = 25
const truncatedName =
choreName.length > maxChoreNameLength
? `${choreName.substring(0, maxChoreNameLength)}...`
: choreName
// Generate time-based descriptive text
const getTimeDescription = () => {
if (!template || !template.value || !template.unit) {
return 'soon'
}
const { value, unit } = template
const absValue = Math.abs(value)
switch (unit) {
case 'm':
if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago'
if (absValue < 60)
return value < 0
? `in ${absValue} minutes`
: `${absValue} minutes ago`
break
case 'h':
if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago'
if (absValue < 24)
return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago`
break
case 'd':
if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday'
if (absValue === 7) return value < 0 ? 'next week' : 'last week'
if (absValue < 7)
return value < 0 ? `in ${absValue} days` : `${absValue} days ago`
if (absValue < 30) {
const weeks = Math.round(absValue / 7)
return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago`
} }
scheduleDueNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) break
schedulePreDueNotification(chore, userProfile, allPerformers,chorePreferences, devicePreferences,notifications) default:
scheduleNaggingNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
} }
LocalNotifications.schedule({
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
}
const messages = {
reminder: {
title: `📋 ${truncatedName}`,
body: `Reminder: Due ${getTimeDescription()}`,
},
due: {
title: `🔔 ${truncatedName}`,
body: 'Due now - Time to get started!',
},
overdue: {
title: `${truncatedName}`,
body: `Overdue ${getTimeDescription()} - Complete when you can`,
},
}
// Fallback to due if type not found
const messageTemplate = messages[notificationType] || messages.due
return {
title: messageTemplate.title,
body: messageTemplate.body,
}
}
const cancelPendingNotifications = async () => {
try {
const pending = await LocalNotifications.getPending()
if (pending.notifications.length > 0) {
await LocalNotifications.cancel({ notifications: pending.notifications })
console.log('Cancelled pending notifications:', pending.notifications)
} else {
console.log('No pending notifications to cancel.')
}
} catch (error) {
console.error('Error cancelling pending notifications:', error)
}
}
const scheduleChoreNotification = async (
chores,
userProfile,
allPerformers,
) => {
await cancelPendingNotifications()
const notifications = []
for (let i = 0; i < chores.length; i++) {
const chore = chores[i]
try {
if (chore.notification === false || chore.nextDueDate === null) {
continue
}
scheduleNotificationFromTemplate(
chore,
userProfile,
allPerformers,
notifications, notifications,
}); )
} catch (error) {
console.error(
'Error parsing notification metadata for chore:',
chore.id,
error,
)
continue
}
}
LocalNotifications.schedule({
notifications,
})
return notifications
} }
const scheduleDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => { 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 }

View File

@@ -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,112 +32,90 @@ const MultiSelectHelp = ({ isVisible = true }) => {
</IconButton> </IconButton>
{/* Help Modal */} {/* Help Modal */}
<Modal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}> <FadeModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
<ModalDialog <Box
variant='outlined'
size='md'
sx={{ sx={{
maxWidth: 500, display: 'flex',
p: 3, alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}} }}
> >
<Box <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
sx={{ <Keyboard color='primary' />
display: 'flex', <Typography level='title-lg'>Multi-select Mode</Typography>
alignItems: 'center', </Box>
justifyContent: 'space-between', <IconButton
mb: 2, variant='plain'
}} size='sm'
onClick={() => setIsHelpOpen(false)}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Close />
<Keyboard color='primary' /> </IconButton>
<Typography level='title-lg'>Multi-select Mode</Typography> </Box>
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
Use these keyboard shortcuts to work more efficiently with multiple
tasks:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Selection shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
<Typography level='title-sm' sx={{ mb: 1.5, color: 'primary.600' }}>
Selection
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Ctrl', 'A']}
description='Select all visible tasks'
/>
<ShortcutItem
keys={['Esc']}
description='Clear selection or exit multi-select mode'
/>
</Box> </Box>
<IconButton </Card>
variant='plain'
size='sm'
onClick={() => setIsHelpOpen(false)}
>
<Close />
</IconButton>
</Box>
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}> {/* Action shortcuts */}
Use these keyboard shortcuts to work more efficiently with multiple <Card variant='soft' sx={{ p: 2 }}>
tasks: <Typography level='title-sm' sx={{ mb: 1.5, color: 'success.600' }}>
</Typography> Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Enter']}
description='Mark selected tasks as completed'
/>
<ShortcutItem
keys={['Del', '⌫']}
description='Delete selected tasks'
/>
</Box>
</Card>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> {/* Interface shortcuts */}
{/* Selection shortcuts */} <Card variant='soft' sx={{ p: 2 }}>
<Card variant='soft' sx={{ p: 2 }}> <Typography level='title-sm' sx={{ mb: 1.5, color: 'warning.600' }}>
<Typography Interface
level='title-sm' </Typography>
sx={{ mb: 1.5, color: 'primary.600' }} <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
> <ShortcutItem
Selection keys={['Ctrl', 'K']}
</Typography> description='Quick add new task'
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> />
<ShortcutItem </Box>
keys={['Ctrl', 'A']} </Card>
description='Select all visible tasks' </Box>
/> <Divider sx={{ my: 3 }} />
<ShortcutItem <Box sx={{ display: 'flex', justifyContent: 'center' }}>
keys={['Esc']} <Button
description='Clear selection or exit multi-select mode' variant='soft'
/> onClick={() => setIsHelpOpen(false)}
</Box> sx={{ minWidth: 120 }}
</Card> >
Got it!
{/* Action shortcuts */} </Button>
<Card variant='soft' sx={{ p: 2 }}> </Box>
<Typography </FadeModal>
level='title-sm'
sx={{ mb: 1.5, color: 'success.600' }}
>
Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Enter']}
description='Mark selected tasks as completed'
/>
<ShortcutItem
keys={['Del', '⌫']}
description='Delete selected tasks'
/>
</Box>
</Card>
{/* Interface shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
<Typography
level='title-sm'
sx={{ mb: 1.5, color: 'warning.600' }}
>
Interface
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Ctrl', 'K']}
description='Quick add new task'
/>
</Box>
</Card>
</Box>
<Divider sx={{ my: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant='soft'
onClick={() => setIsHelpOpen(false)}
sx={{ minWidth: 120 }}
>
Got it!
</Button>
</Box>
</ModalDialog>
</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

View File

@@ -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,40 +103,47 @@ 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(() => {
if (!choresLoading && !membersLoading && userProfile) { ;(async () => {
setPerformers(membersData.res) if (!choresLoading && !membersLoading && userProfile) {
const sortedChores = choresData.res.sort(ChoreSorter) setPerformers(membersData.res)
setChores(sortedChores) const sortedChores = choresData.res.sort(ChoreSorter)
setFilteredChores(sortedChores) setChores(sortedChores)
const sections = ChoresGrouper( setFilteredChores(sortedChores)
selectedChoreSection, const sections = ChoresGrouper(
sortedChores, selectedChoreSection,
ChoreFilters(userProfile)[selectedChoreFilter], sortedChores,
) ChoreFilters(userProfile)[selectedChoreFilter],
setChoreSections(sections)
if (localStorage.getItem('openChoreSections') === null) {
setSelectedChoreSectionWithCache(selectedChoreSection)
setOpenChoreSections(
Object.keys(sections).reduce((acc, key) => {
acc[key] = true
return acc
}, {}),
) )
} setChoreSections(sections)
if (localStorage.getItem('openChoreSections') === null) {
setSelectedChoreSectionWithCache(selectedChoreSection)
setOpenChoreSections(
Object.keys(sections).reduce((acc, key) => {
acc[key] = true
return acc
}, {}),
)
}
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,24 +1133,36 @@ const MyChores = () => {
</IconButton> </IconButton>
{/* Multi-select Toggle Button */} {/* Multi-select Toggle Button */}
<IconButton <Box sx={{ position: 'relative', display: 'inline-flex' }}>
variant={isMultiSelectMode ? 'solid' : 'outlined'} <IconButton
color={isMultiSelectMode ? 'primary' : 'neutral'} variant={isMultiSelectMode ? 'solid' : 'outlined'}
size='sm' color={isMultiSelectMode ? 'primary' : 'neutral'}
sx={{ size='sm'
height: 32, sx={{
width: 32, height: 32,
borderRadius: '50%', width: 32,
}} borderRadius: '50%',
onClick={toggleMultiSelectMode} }}
title={ onClick={toggleMultiSelectMode}
isMultiSelectMode title={
? 'Exit Multi-select Mode' isMultiSelectMode
: 'Enable Multi-select Mode' ? 'Exit Multi-select Mode (Ctrl+S)'
} : 'Enable Multi-select Mode (Ctrl+S)'
> }
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />} >
</IconButton> {isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</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 && (

View File

@@ -1,30 +1,36 @@
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(() => {
getNotificationPreferences().then(data => { // Only run the effect on native platforms
// if optOut is true then don't show the snackbar if (Capacitor.isNativePlatform()) {
if (data?.optOut === true || data?.granted === true) { getNotificationPreferences().then(data => {
return // if optOut is true then don't show the snackbar
} if (data?.optOut === true || data?.granted === true) {
setOpen(true) return
}) }
setOpen(true)
})
}
}, []) }, [])
// Return early if not on a native platform
if (!Capacitor.isNativePlatform()) {
return null
}
return ( return (
<Snackbar <Snackbar
// autoHideDuration={5000} // autoHideDuration={5000}

View File

@@ -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' },

View File

@@ -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 }}>

View File

@@ -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' if (Math.abs(performedAt - dueDate) <= gracePeriod) {
color = 'warning' return (
icon = <Timelapse /> <Chip
size='sm'
variant='solid'
color='success'
startDecorator={<Check />}
>
On Time
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
return (
<Chip size='sm' variant='soft' color='primary' startDecorator={<Check />}>
Early
</Chip>
)
} else { } else {
text = 'No Due Date' return (
color = 'neutral' <Chip
icon = <CalendarViewDay /> size='sm'
variant='solid'
color='warning'
startDecorator={<Timelapse />}
>
Late
</Chip>
)
} }
return (
<Chip startDecorator={icon} color={color}>
{text}
</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 }}>
{' '}
{/* Removed vertical margin */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography level='body1' sx={{ fontWeight: 'md' }}>
{historyEntry.performedAt
? moment(historyEntry.performedAt).format(
'ddd MM/DD/yyyy HH:mm',
)
: 'Skipped'}
</Typography>
{getCompletedChip(historyEntry)}
</Box>
<Typography level='body2' color='text.tertiary'>
<Chip>
{
performers.find(p => p.userId === historyEntry.completedBy)
?.displayName
} }
</Chip>{' '} : {},
completed borderRadius: 'sm',
{historyEntry.completedBy !== historyEntry.assignedTo && ( transition: 'background-color 0.2s',
<> }}
{', '} >
assigned to{' '} <ListItemContent>
<Chip> <Grid container spacing={1} alignItems='center'>
{ {/* First Row/Column: Status and Time Info */}
performers.find(p => p.userId === historyEntry.assignedTo) <Grid xs={12} sm={8}>
?.displayName <Box
} sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{getStatusAvatar()}
<Typography
level='body-sm'
sx={{
color: 'text.secondary',
fontWeight: 'md',
}}
>
{historyEntry.status === 0
? 'In Progress'
: historyEntry.status === 1
? 'Completed'
: 'Skipped'}
</Typography>
<Chip size='sm' startDecorator={<EventNote />}>
{moment(
historyEntry.performedAt || historyEntry.updatedAt,
).format('MMM DD, h:mm A')}
</Chip> </Chip>
</>
)} <Box sx={{ display: 'flex', gap: 0.5 }}>
</Typography> {getCompletedChip(historyEntry)}
{historyEntry.dueDate && ( </Box>
<Typography level='body2' color='text.tertiary'> </Box>
Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')} </Grid>
</Typography>
)} {/* Second Row/Column: Completion Status (right side on desktop) */}
{historyEntry.notes && ( <Grid xs={12} sm={4}>
<Typography level='body2' color='text.tertiary'> <Box
Note: {historyEntry.notes} sx={{
</Typography> display: 'flex',
)} 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
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
</Typography>
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={<CheckCircle />}
>
{assignedTo.displayName}
</Chip>
</>
)}
{historyEntry.notes && (
<Chip
size='sm'
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,
{formatTimeDifference( }}
historyEntry.performedAt, >
allHistory[index + 1].performedAt, <Typography
)}{' '} level='body-xs'
before sx={{
</Typography> color: 'text.tertiary',
)} backgroundColor: 'background.surface',
</ListDivider> px: 1,
</> fontSize: '0.75rem',
}}
>
{formatTimeDifference(
historyEntry.performedAt || historyEntry.updatedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
</ListDivider>
)} )}
</> </>
) )

View File

@@ -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 => ( sx={{
<div // bgcolor: 'background.body',
key={label} // border: '1px solid',
className='grid w-full grid-cols-[1fr,auto,auto] rounded-lg border border-zinc-200/80 p-4 shadow-sm dark:bg-zinc-900' // borderColor: 'divider',
// borderRadius: 'md',
overflow: 'hidden',
}}
>
{userLabels.length === 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
> >
<Chip <Typography level='title-md' gutterBottom>
variant='outlined' No labels available. Add a new label to get started.
color='primary' </Typography>
size='lg' </Box>
sx={{ )}
background: label.color, {userLabels.map(label => (
borderColor: label.color, <LabelCard
color: getTextColorFromBackgroundColor(label.color), key={label.id}
}} label={label}
> onEditClick={handleEditLabel}
{label.name} onDeleteClick={handleDeleteClicked}
</Chip> currentUserId={userProfile?.id}
/>
<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> </Box>
{userLabels.length === 0 && (
<Typography textAlign='center' mt={2}>
No labels available. Add a new label to get started.
</Typography>
)}
{modalOpen && ( {modalOpen && (
<LabelModal <LabelModal

View File

@@ -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,93 +22,91 @@ 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> <FormLabel>Due Date</FormLabel>
<FormLabel>Due Date</FormLabel> <Input
<Input type='datetime-local'
type='datetime-local' value={dueDate}
value={dueDate} onChange={e => {
onChange={e => { setDueDate(e.target.value)
setDueDate(e.target.value) }}
}} />
/> <FormLabel>Completed Date</FormLabel>
<FormLabel>Completed Date</FormLabel> <Input
<Input type='datetime-local'
type='datetime-local' value={completedDate}
value={completedDate} onChange={e => {
onChange={e => { setCompletedDate(e.target.value)
setCompletedDate(e.target.value) }}
}} />
/> <FormLabel>Note</FormLabel>
<FormLabel>Note</FormLabel> <Input
<Input fullWidth
fullWidth multiline
multiline label='Additional Notes'
label='Additional Notes' placeholder='Additional Notes'
placeholder='Additional Notes' value={notes}
value={notes} onChange={e => {
onChange={e => { if (e.target.value.trim() === '') {
if (e.target.value.trim() === '') { setNotes(null)
setNotes(null) return
return }
} setNotes(e.target.value)
setNotes(e.target.value) }}
}} size='md'
size='md' sx={{
sx={{ mb: 1,
mb: 1, }}
}} />
/>
{/* 3 button save , cancel and delete */} {/* 3 button save , cancel and delete */}
<Box display={'flex'} justifyContent={'space-around'} mt={1}> <Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button <Button
onClick={() => onClick={() =>
config.onSave({ config.onSave({
id: historyRecord.id, id: historyRecord.id,
performedAt: moment(completedDate).toISOString(), performedAt: moment(completedDate).toISOString(),
dueDate: moment(dueDate).toISOString(), dueDate: moment(dueDate).toISOString(),
notes, notes,
}) })
} }
fullWidth fullWidth
sx={{ mr: 1 }} sx={{ mr: 1 }}
> >
Save Save
</Button> </Button>
<Button onClick={config.onClose} variant='outlined'> <Button onClick={config.onClose} variant='outlined'>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true) setIsDeleteModalOpen(true)
}}
variant='outlined'
color='danger'
>
Delete
</Button>
</Box>
<ConfirmationModal
config={{
isOpen: isDeleteModalOpen,
onClose: isConfirm => {
if (isConfirm) {
config.onDelete(historyRecord.id)
}
setIsDeleteModalOpen(false)
},
title: 'Delete History',
message: 'Are you sure you want to delete this history?',
confirmText: 'Delete',
cancelText: 'Cancel',
}} }}
/> variant='outlined'
</ModalDialog> color='danger'
</Modal> >
Delete
</Button>
</Box>
<ConfirmationModal
config={{
isOpen: isDeleteModalOpen,
onClose: isConfirm => {
if (isConfirm) {
config.onDelete(historyRecord.id)
}
setIsDeleteModalOpen(false)
},
title: 'Delete History',
message: 'Are you sure you want to delete this history?',
confirmText: 'Delete',
cancelText: 'Cancel',
}}
/>
</FadeModal>
) )
} }
export default EditHistoryModal export default EditHistoryModal

View File

@@ -1,44 +1,116 @@
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)
config.onClose(isConfirmed)
} const handleAction = useCallback(
isConfirmed => {
config.onClose(isConfirmed)
},
[config],
)
// Keyboard shortcuts for confirmation modal
useEffect(() => {
const handleKeyDown = event => {
if (!config?.isOpen) return
// Show keyboard shortcuts when Ctrl/Cmd is pressed
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
// Ctrl/Cmd + Y for confirm
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
event.preventDefault()
handleAction(true)
return
}
// Ctrl/Cmd + X for cancel
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
event.preventDefault()
handleAction(false)
return
}
// Escape key for cancel
if (event.key === 'Escape') {
event.preventDefault()
handleAction(false)
return
}
// Enter key for confirm
if (event.key === 'Enter') {
event.preventDefault()
handleAction(true)
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
}
if (config?.isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [config?.isOpen, handleAction])
return ( return (
<Modal open={config?.isOpen} onClose={config?.onClose}> <FadeModal
<ModalDialog> open={config?.isOpen}
<Typography level='h4' mb={1}> onClose={config?.onClose}
{config?.title} size='sm'
</Typography> unmountDelay={250}
>
<Typography level='h4' mb={1}>
{config?.title}
</Typography>
<Typography level='body-md' gutterBottom> <Typography level='body-md' gutterBottom>
{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} }
</Button> >
<Button {config?.confirmText}
onClick={() => { </Button>
handleAction(false)
}} <Button
variant='outlined' onClick={() => {
> handleAction(false)
{config?.cancelText} }}
</Button> variant='outlined'
</Box> endDecorator={
</ModalDialog> <KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
</Modal> }
>
{config?.cancelText}
</Button>
</Box>
</FadeModal>
) )
} }
export default ConfirmationModal export default ConfirmationModal

View File

@@ -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,87 +58,80 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
} }
return ( return (
<Modal open={isOpen} onClose={onClose}> <FadeModal open={isOpen} onClose={onClose}>
<ModalDialog> <Typography level='h4'>
{/* <ModalClose /> */} {currentThing?.id ? 'Edit' : 'Create'} Thing
<Typography level='h4'> </Typography>
{currentThing?.id ? 'Edit' : 'Create'} Thing <FormControl>
</Typography> <Typography>Name</Typography>
<Textarea
placeholder='Thing name'
value={name}
onChange={e => setName(e.target.value)}
sx={{ minWidth: 300 }}
/>
<FormHelperText color='danger'>{errors.name}</FormHelperText>
</FormControl>
<FormControl>
<Typography>Type</Typography>
<Select value={type} sx={{ minWidth: 300 }}>
{['text', 'number', 'boolean'].map(type => (
<Option value={type} key={type} onClick={() => setType(type)}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Option>
))}
</Select>
<FormHelperText color='danger'>{errors.type}</FormHelperText>
</FormControl>
{type === 'text' && (
<FormControl> <FormControl>
<Typography>Name</Typography> <Typography>Value</Typography>
<Textarea <Input
placeholder='Thing name' placeholder='Thing value'
value={name} value={state || ''}
onChange={e => setName(e.target.value)} onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }} sx={{ minWidth: 300 }}
/> />
<FormHelperText color='danger'>{errors.name}</FormHelperText> <FormHelperText color='danger'>{errors.state}</FormHelperText>
</FormControl> </FormControl>
)}
{type === 'number' && (
<FormControl> <FormControl>
<Typography>Type</Typography> <Typography>Value</Typography>
<Select value={type} sx={{ minWidth: 300 }}> <Input
{['text', 'number', 'boolean'].map(type => ( placeholder='Thing value'
<Option value={type} key={type} onClick={() => setType(type)}> type='number'
{type.charAt(0).toUpperCase() + type.slice(1)} value={state || ''}
onChange={e => {
setState(e.target.value)
}}
sx={{ minWidth: 300 }}
/>
</FormControl>
)}
{type === 'boolean' && (
<FormControl>
<Typography>Value</Typography>
<Select sx={{ minWidth: 300 }} value={state}>
{['true', 'false'].map(value => (
<Option value={value} key={value} onClick={() => setState(value)}>
{value.charAt(0).toUpperCase() + value.slice(1)}
</Option> </Option>
))} ))}
</Select> </Select>
<FormHelperText color='danger'>{errors.type}</FormHelperText>
</FormControl> </FormControl>
{type === 'text' && ( )}
<FormControl>
<Typography>Value</Typography>
<Input
placeholder='Thing value'
value={state || ''}
onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }}
/>
<FormHelperText color='danger'>{errors.state}</FormHelperText>
</FormControl>
)}
{type === 'number' && (
<FormControl>
<Typography>Value</Typography>
<Input
placeholder='Thing value'
type='number'
value={state || ''}
onChange={e => {
setState(e.target.value)
}}
sx={{ minWidth: 300 }}
/>
</FormControl>
)}
{type === 'boolean' && (
<FormControl>
<Typography>Value</Typography>
<Select sx={{ minWidth: 300 }} value={state}>
{['true', 'false'].map(value => (
<Option
value={value}
key={value}
onClick={() => setState(value)}
>
{value.charAt(0).toUpperCase() + value.slice(1)}
</Option>
))}
</Select>
</FormControl>
)}
<Box display={'flex'} justifyContent={'space-around'} mt={1}> <Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}> <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{currentThing?.id ? 'Update' : 'Create'} {currentThing?.id ? 'Update' : 'Create'}
</Button> </Button>
<Button onClick={onClose} variant='outlined'> <Button onClick={onClose} variant='outlined'>
{currentThing?.id ? 'Cancel' : 'Close'} {currentThing?.id ? 'Cancel' : 'Close'}
</Button> </Button>
</Box> </Box>
</ModalDialog> </FadeModal>
</Modal>
) )
} }
export default CreateThingModal export default CreateThingModal

View File

@@ -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,26 +13,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
} }
return ( return (
<Modal open={isOpen} onClose={onClose}> <FadeModal open={isOpen} onClose={onClose}>
<ModalDialog> <Typography variant='h4'>{title}</Typography>
{/* <ModalClose /> */} <Input
<Typography variant='h4'>{title}</Typography> sx={{ mt: 3 }}
<Input type='date'
sx={{ mt: 3 }} value={date}
type='date' onChange={e => setDate(e.target.value)}
value={date} />
onChange={e => setDate(e.target.value)} <Box display={'flex'} justifyContent={'space-around'} mt={1}>
/> <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
<Box display={'flex'} justifyContent={'space-around'} mt={1}> Save
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}> </Button>
Save <Button onClick={onClose} variant='outlined'>
</Button> Cancel
<Button onClick={onClose} variant='outlined'> </Button>
Cancel </Box>
</Button> </FadeModal>
</Box>
</ModalDialog>
</Modal>
) )
} }
export default DateModal export default DateModal

View File

@@ -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,31 +38,29 @@ 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>
<Typography>Value</Typography> <Typography>Value</Typography>
<Input <Input
placeholder='Thing value' placeholder='Thing value'
value={state || ''} value={state || ''}
onChange={e => setState(e.target.value)} onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }} sx={{ minWidth: 300 }}
/> />
<FormHelperText color='danger'>{errors.state}</FormHelperText> <FormHelperText color='danger'>{errors.state}</FormHelperText>
</FormControl> </FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}> <Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}> <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{currentThing?.id ? 'Update' : 'Create'} {currentThing?.id ? 'Update' : 'Create'}
</Button> </Button>
<Button onClick={onClose} variant='outlined'> <Button onClick={onClose} variant='outlined'>
{currentThing?.id ? 'Cancel' : 'Close'} {currentThing?.id ? 'Cancel' : 'Close'}
</Button> </Button>
</Box> </Box>
</ModalDialog> </FadeModal>
</Modal>
) )
} }
export default EditThingStateModal export default EditThingStateModal

View File

@@ -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,79 +89,77 @@ 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>
<FormControl>
<Typography gutterBottom level='body-sm' alignSelf='start'>
Name
</Typography> </Typography>
<Input
fullWidth
id='labelName'
value={labelName}
onChange={e => setLabelName(e.target.value)}
/>
</FormControl>
<FormControl> <FormControl>
<Typography gutterBottom level='body-sm' alignSelf='start'> <Typography gutterBottom level='body-sm' alignSelf='start'>
Name Color
</Typography> </Typography>
<Input <Select
fullWidth value={color}
id='labelName' onChange={(e, value) => value && setColor(value)}
value={labelName} renderValue={selected => (
onChange={e => setLabelName(e.target.value)} <Typography
/> startDecorator={
</FormControl> <Box
className='size-4'
borderRadius={10}
sx={{ background: selected.value }}
/>
}
>
{selected.label}
</Typography>
)}
>
{LABEL_COLORS.map(val => (
<Option key={val.value} value={val.value}>
<Box className='flex items-center justify-between'>
<Box
width={20}
height={20}
borderRadius={10}
sx={{ background: val.value }}
/>
<Typography sx={{ ml: 1 }} variant='caption'>
{val.name}
</Typography>
</Box>
</Option>
))}
</Select>
</FormControl>
<FormControl> {error && (
<Typography gutterBottom level='body-sm' alignSelf='start'> <Typography color='warning' level='body-sm'>
Color {error}
</Typography> </Typography>
<Select )}
value={color}
onChange={(e, value) => value && setColor(value)}
renderValue={selected => (
<Typography
startDecorator={
<Box
className='size-4'
borderRadius={10}
sx={{ background: selected.value }}
/>
}
>
{selected.label}
</Typography>
)}
>
{LABEL_COLORS.map(val => (
<Option key={val.value} value={val.value}>
<Box className='flex items-center justify-between'>
<Box
width={20}
height={20}
borderRadius={10}
sx={{ background: val.value }}
/>
<Typography sx={{ ml: 1 }} variant='caption'>
{val.name}
</Typography>
</Box>
</Option>
))}
</Select>
</FormControl>
{error && ( <Box display='flex' justifyContent='space-around' mt={1}>
<Typography color='warning' level='body-sm'> <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{error} {label ? 'Save Changes' : 'Add Label'}
</Typography> </Button>
)} <Button onClick={onClose} variant='outlined'>
Cancel
<Box display='flex' justifyContent='space-around' mt={1}> </Button>
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}> </Box>
{label ? 'Save Changes' : 'Add Label'} </FadeModal>
</Button>
<Button onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ModalDialog>
</Modal>
) )
} }

View File

@@ -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,78 +39,76 @@ 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
</Typography>
<Typography level='body-md' gutterBottom>
Please enter your new password.
</Typography>
<FormControl>
<Typography level='body2' alignSelf={'start'}>
New Password
</Typography>
<Input
margin='normal'
required
fullWidth
name='password'
label='Password'
type='password'
id='password'
value={password}
onChange={e => {
setPasswordTouched(true)
setPassword(e.target.value)
}}
/>
</FormControl>
<FormControl>
<Typography level='body2' alignSelf={'start'}>
Confirm Password
</Typography>
<Input
margin='normal'
required
fullWidth
name='confirmPassword'
label='confirmPassword'
type='password'
id='confirmPassword'
value={confirmPassword}
onChange={e => {
setConfirmPasswordTouched(true)
setConfirmPassword(e.target.value)
}}
/>
<FormHelperText>{passwordError}</FormHelperText>
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
disabled={passwordError != null}
onClick={() => {
handleAction(true)
}}
fullWidth
sx={{ mr: 1 }}
>
Change Password Change Password
</Typography> </Button>
<Button
<Typography level='body-md' gutterBottom> onClick={() => {
Please enter your new password. handleAction(false)
</Typography> }}
<FormControl> variant='outlined'
<Typography level='body2' alignSelf={'start'}> >
New Password Cancel
</Typography> </Button>
<Input </Box>
margin='normal' </FadeModal>
required
fullWidth
name='password'
label='Password'
type='password'
id='password'
value={password}
onChange={e => {
setPasswordTouched(true)
setPassword(e.target.value)
}}
/>
</FormControl>
<FormControl>
<Typography level='body2' alignSelf={'start'}>
Confirm Password
</Typography>
<Input
margin='normal'
required
fullWidth
name='confirmPassword'
label='confirmPassword'
type='password'
id='confirmPassword'
value={confirmPassword}
onChange={e => {
setConfirmPasswordTouched(true)
setConfirmPassword(e.target.value)
}}
/>
<FormHelperText>{passwordError}</FormHelperText>
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
disabled={passwordError != null}
onClick={() => {
handleAction(true)
}}
fullWidth
sx={{ mr: 1 }}
>
Change Password
</Button>
<Button
onClick={() => {
handleAction(false)
}}
variant='outlined'
>
Cancel
</Button>
</Box>
</ModalDialog>
</Modal>
) )
} }
export default PassowrdChangeModal export default PassowrdChangeModal

View File

@@ -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,33 +18,31 @@ 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) => ( <Option
<Option value={item.id}
value={item.id} key={item[displayKey]}
key={item[displayKey]} onClick={() => {
onClick={() => { setSelected(item.id)
setSelected(item.id) }}
}} >
> {item[displayKey]}
{item[displayKey]} </Option>
</Option> ))}
))} </Select>
</Select>
<Box display={'flex'} justifyContent={'space-around'} mt={1}> <Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}> <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
Save Save
</Button> </Button>
<Button onClick={onClose} variant='outlined'> <Button onClick={onClose} variant='outlined'>
Cancel Cancel
</Button> </Button>
</Box> </Box>
</ModalDialog> </FadeModal>
</Modal>
) )
} }
export default SelectModal export default SelectModal

View File

@@ -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,29 +19,26 @@ function TextModal({
} }
return ( return (
<Modal open={isOpen} onClose={onClose}> <FadeModal open={isOpen} onClose={onClose}>
<ModalDialog> <Typography variant='h4'>{title}</Typography>
{/* <ModalClose /> */} <Textarea
<Typography variant='h4'>{title}</Typography> placeholder='Type in here…'
<Textarea value={text}
placeholder='Type in here…' onChange={e => setText(e.target.value)}
value={text} minRows={2}
onChange={e => setText(e.target.value)} maxRows={4}
minRows={2} sx={{ minWidth: 300 }}
maxRows={4} />
sx={{ minWidth: 300 }}
/>
<Box display={'flex'} justifyContent={'space-around'} mt={1}> <Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button onClick={handleSave} fullWidth sx={{ mr: 1 }}> <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{okText ? okText : 'Save'} {okText ? okText : 'Save'}
</Button> </Button>
<Button onClick={onClose} variant='outlined'> <Button onClick={onClose} variant='outlined'>
{cancelText ? cancelText : 'Cancel'} {cancelText ? cancelText : 'Cancel'}
</Button> </Button>
</Box> </Box>
</ModalDialog> </FadeModal>
</Modal>
) )
} }
export default TextModal export default TextModal

View 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

View File

@@ -1,57 +1,44 @@
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> <Typography level='h4' sx={{ mb: 2 }}>
<ModalDialog size='md' sx={{ minWidth: 360 }}> Select User
<Typography level='h4' sx={{ mb: 2 }}> </Typography>
Select User <List sx={{ mb: 2 }}>
</Typography> {performers.map(user => (
<List sx={{ mb: 2 }}> <ListItem
{performers.map(user => ( key={user.id}
<ListItem sx={{
key={user.id} cursor: 'pointer',
sx={{ '&:hover': {
cursor: 'pointer', backgroundColor: 'rgba(0, 0, 0, 0.04)',
'&:hover': { },
backgroundColor: 'rgba(0, 0, 0, 0.04)', }}
}, onClick={() => {
}} onSelect(user)
onClick={() => { onClose()
onSelect(user) }}
onClose() >
}} <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
> <Avatar
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> size='lg'
<Avatar src={user.image || user.avatar}
size='lg' alt={user.displayName || user.name}
src={user.image || user.avatar} />
alt={user.displayName || user.name} <Typography>{user.displayName || user.name}</Typography>
/> </Box>
<Typography>{user.displayName || user.name}</Typography> </ListItem>
</Box> ))}
</ListItem> </List>
))} <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
</List> <Button variant='outlined' color='neutral' onClick={onClose}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> Cancel
<Button variant='outlined' color='neutral' onClick={onClose}> </Button>
Cancel </Box>
</Button> </FadeModal>
</Box>
</ModalDialog>
</ModalOverflow>
</Modal>
) )
} }

View File

@@ -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,63 +52,61 @@ 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>
{nfcStatus === 'success' ? ( {nfcStatus === 'success' ? (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
) : (
<>
<Typography level='body-md' gutterBottom> <Typography level='body-md' gutterBottom>
URL written to NFC tag successfully! {nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography> </Typography>
) : ( <Input
<> value={getURL()}
<Typography level='body-md' gutterBottom> fullWidth
{nfcStatus === 'error' readOnly
? errorMessage label='URL'
: 'Press the button below to write to NFC.'} sx={{ mt: 1 }}
</Typography> endDecorator={
<Input <CopyAll
value={getURL()} sx={{ cursor: 'pointer' }}
fullWidth onClick={() => {
readOnly navigator.clipboard.writeText(getURL())
label='URL' alert('URL copied to clipboard!')
sx={{ mt: 1 }} }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/> />
</ListItem> }
<Box display={'flex'} justifyContent={'space-around'} mt={1}> />
<Button <ListItem>
onClick={() => writeToNFC(getURL())} <Checkbox
fullWidth checked={isAutoCompleteWhenScan}
sx={{ mr: 1 }} onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
disabled={nfcStatus === 'writing'} label='Auto-complete when scanned'
> />
Write NFC </ListItem>
</Button> <Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button onClick={requestNFCAccess} variant='outlined'> <Button
Request Access onClick={() => writeToNFC(getURL())}
</Button> fullWidth
</Box> sx={{ mr: 1 }}
</> disabled={nfcStatus === 'writing'}
)} >
</ModalDialog> Write NFC
</Modal> </Button>
<Button onClick={requestNFCAccess} variant='outlined'>
Request Access
</Button>
</Box>
</>
)}
</FadeModal>
) )
} }

View File

@@ -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)
if (numValue > config.available) {
setPoints(config.available)
return
}
if (numValue < 0) {
setPoints(0)
return
}
setPoints(numValue)
}
const predefinedPoints = [1, 5, 10, 25] const addPredefinedPoints = point => {
const newPoints = points + point
if (newPoints > config.available) {
setPoints(config.available)
return
}
setPoints(newPoints)
}
const canRedeem = points > 0 && points <= config.available
return ( return (
<Modal open={config?.isOpen} onClose={config?.onClose}> <FadeModal open={config?.isOpen} onClose={config?.onClose} size='md'>
<ModalDialog> {/* Header Section */}
<Typography level='h4' mb={1}> <Stack spacing={2}>
Redeem Points <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
</Typography> <CreditCard
<FormLabel> sx={{
Points to Redeem ({config.available ? config.available : 0} points fontSize: '1.5rem',
available) }}
</FormLabel> />
<Input <Typography level='h4' sx={{ fontWeight: 600 }}>
type='number' Redeem Points
value={points} </Typography>
slotProps={{
input: { min: 0, max: config.available ? config.available : 0 },
}}
onChange={e => {
if (e.target.value > config.available) {
setPoints(config.available)
return
}
setPoints(e.target.value)
}}
/>
<FormLabel>Or select from predefined points:</FormLabel>
<Box display='flex' justifyContent='space-evenly' mb={1}>
{predefinedPoints.map(point => (
<IconButton
variant='outlined'
disabled={points + point > config.available}
sx={{ borderRadius: '50%' }}
key={point}
onClick={() => {
const newPoints = points + point
if (newPoints > config.available) {
setPoints(config.available)
return
}
setPoints(newPoints)
}}
>
{point}
</IconButton>
))}
</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

View File

@@ -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

View File

@@ -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 />

View 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

View File

@@ -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={{
<Box py: 1.5,
sx={{ px: 2,
display: 'flex', borderRadius: 'sm',
justifyContent: 'space-between', transition: 'background-color 0.2s',
alignItems: 'center', '&:hover': {
}} backgroundColor: 'background.level1',
> },
<Typography level='body1' sx={{ fontWeight: 'md' }}> }}
{moment(history.updatedAt).format( >
'ddd MM/DD/yyyy HH:mm:ss', <ListItemContent>
)} <Grid container spacing={1} alignItems='center'>
</Typography> {/* First Row: Status and Time Info */}
<Chip>{history.state}</Chip> <Grid xs={12} sm={8}>
</Box> <Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
<Avatar
size='sm'
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>
<Chip
size='sm'
variant='soft'
color='primary'
startDecorator={<Schedule />}
>
{moment(history.updatedAt).format('MMM DD, h:mm A')}
</Chip>
</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'> >
{formatTimeDifference( <Typography
history.createdAt, level='body-xs'
thingsHistory[index + 1].createdAt, sx={{
)}{' '} color: 'text.tertiary',
before backgroundColor: 'background.surface',
</Typography> px: 1,
)} fontSize: '0.75rem',
</ListDivider> }}
</> >
{formatTimeDifference(
history.createdAt,
thingsHistory[index + 1].createdAt,
)}{' '}
before
</Typography>
</ListDivider>
)} )}
</> </Box>
))} ))}
</List> </List>
</Box> </Box>

View File

@@ -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
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}
> >
<Box <IconButton
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
}}
onClick={() => Navigate(`/things/${thing?.id}`)}
>
<Typography level='title-lg'>{thing?.name}</Typography>
<Chip
size='sm'
sx={{
ml: 1,
}}
>
{thing?.type}
</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={{
borderRadius: '50%', width: 40,
width: 30, height: 40,
height: 30, mx: 1,
ml: 1,
}} }}
> >
<Delete fontSize='small' /> <Delete sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</Grid> </Box>
</Grid>
{/* 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%',
bgcolor: 'text.tertiary',
}}
/>
))}
</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,
}}
>
{thing?.state}
</Chip>
</Box>
{/* 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,38 +659,47 @@ const ThingsView = () => {
} }
return ( return (
<Container maxWidth='md'> <Container maxWidth='md' sx={{ px: 0 }}>
{things.length === 0 && ( <Box
<Box sx={{
sx={{ // bgcolor: 'background.body',
display: 'flex', // border: '1px solid',
justifyContent: 'center', // borderColor: 'divider',
alignItems: 'center', // borderRadius: 'md',
flexDirection: 'column', overflow: 'hidden',
height: '50vh', }}
}} >
> {things.length === 0 && (
<Widgets <Box
sx={{ sx={{
fontSize: '4rem', display: 'flex',
// color: 'text.disabled', justifyContent: 'center',
mb: 1, alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}} }}
>
<Widgets
sx={{
fontSize: '4rem',
mb: 1,
}}
/>
<Typography level='title-md' gutterBottom>
No things has been created/found
</Typography>
</Box>
)}
{things.map(thing => (
<ThingCard
key={thing?.id}
thing={thing}
onEditClick={handleEditClick}
onDeleteClick={handleDeleteClick}
onStateChangeRequest={handleStateChangeRequest}
/> />
<Typography level='title-md' gutterBottom> ))}
No things has been created/found </Box>
</Typography>
</Box>
)}
{things.map(thing => (
<ThingCard
key={thing?.id}
thing={thing}
onEditClick={handleEditClick}
onDeleteClick={handleDeleteClick}
onStateChangeRequest={handleStateChangeRequest}
/>
))}
<Box <Box
// variant='outlined' // variant='outlined'
sx={{ sx={{

View 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

View File

@@ -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,51 +231,276 @@ 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
const filteredChores =
selectedUser === 'all' || selectedUser === undefined
? choresData.res
: choresData.res.filter(chore => chore.assignedTo === selectedUser)
const generateChoreAssignedChartData = chores => {
var assignedToMe = 0
var assignedToOthers = 0
chores.forEach(chore => {
if (chore.assignedTo === userProfile?.id) {
assignedToMe++
} else assignedToOthers++
})
const group = []
if (assignedToMe > 0) {
group.push({
label: `Assigned to me`,
value: assignedToMe,
color: TASK_COLOR.ASSIGNED_TO_ME,
id: 1,
})
}
if (assignedToOthers > 0) {
group.push({
label: `Assigned to others`,
value: assignedToOthers,
color: TASK_COLOR.ASSIGNED_TO_OTHERS,
id: 2,
})
}
return group
}
const generateChorePriorityPieChartData = chores => {
const groups = ChoresGrouper('priority', chores, null)
return groups
.map(group => {
return {
label: group.name,
value: group.content.length,
color: group.color,
id: group.name,
}
})
.filter(item => item.value > 0)
}
const generateChoreLabelsChartData = chores => {
const labelCounts = {}
let unlabeledCount = 0
chores.forEach(chore => {
if (chore.labelsV2 && chore.labelsV2.length > 0) {
chore.labelsV2.forEach(label => {
if (labelCounts[label.id]) {
labelCounts[label.id].count++
} else {
labelCounts[label.id] = {
label: label.name,
count: 1,
color: label.color || TASK_COLOR.ANYTIME,
id: label.id,
}
}
})
} else {
unlabeledCount++
}
})
const result = Object.values(labelCounts)
.map(item => ({
label: item.label,
value: item.count,
color: item.color,
id: item.id,
}))
.filter(item => item.value > 0)
.sort((a, b) => b.value - a.value) // Sort by count descending
// Add unlabeled tasks if there are any
if (unlabeledCount > 0) {
result.push({
label: 'No Labels',
value: unlabeledCount,
color: TASK_COLOR.ANYTIME,
id: 'unlabeled',
})
}
return result
}
const generateChoreAssigneeBreakdownChartData = chores => {
const assigneeCounts = {}
// Define a set of distinct colors for different assignees
const assigneeColors = Object.values(COLORS)
let colorIndex = 0
chores.forEach(chore => {
const assignee = circleUsers.find(
user => user.userId === chore.assignedTo,
)
const assigneeName = assignee ? assignee.displayName : 'Unassigned'
const assigneeId = chore.assignedTo || 'unassigned'
if (assigneeCounts[assigneeId]) {
assigneeCounts[assigneeId].count++
} else {
assigneeCounts[assigneeId] = {
label: assigneeName,
count: 1,
color:
assigneeId === 'unassigned'
? TASK_COLOR.ANYTIME
: assigneeColors[colorIndex % assigneeColors.length],
id: assigneeId,
}
if (assigneeId !== 'unassigned') {
colorIndex++
}
}
})
return Object.values(assigneeCounts)
.map(item => ({
label: item.label,
value: item.count,
color: item.color,
id: item.id,
}))
.filter(item => item.value > 0)
.sort((a, b) => b.value - a.value) // Sort by count descending
}
const choreDuePieChartData = generateChoreDuePieChartData(filteredChores)
setChoreDuePieChartData(choreDuePieChartData) setChoreDuePieChartData(choreDuePieChartData)
setChoresAssignedChartData(generateChoreAssignedChartData(choresData.res)) setChoresAssignedChartData(generateChoreAssignedChartData(filteredChores))
setChoresPriorityChartData( setChoresPriorityChartData(
generateChorePriorityPieChartData(choresData.res), generateChorePriorityPieChartData(filteredChores),
)
setChoresLabelsChartData(generateChoreLabelsChartData(filteredChores))
setChoresAssigneeBreakdownChartData(
generateChoreAssigneeBreakdownChartData(filteredChores),
) )
} }
}, [isChoresLoading, choresData]) }, [isChoresLoading, choresData, userProfile?.id, circleUsers, selectedUser])
const generateChoreAssignedChartData = chores => { const generateChoreLabelsWithDurationChartData = (chores, history) => {
var assignedToMe = 0 const labelDurations = {}
var assignedToOthers = 0 let unlabeledDuration = 0
chores.forEach(chore => {
if (chore.assignedTo === userProfile?.id) { // Iterate through ChoreHistory to get actual time spent
assignedToMe++ history.forEach(historyItem => {
} else assignedToOthers++ const duration = historyItem.duration || 0 // duration in seconds from ChoreHistory
// Find the corresponding chore to get its labels
const chore = chores.find(c => c.id === historyItem.choreId)
if (chore && chore.labelsV2 && chore.labelsV2.length > 0) {
// If chore has labels, add duration to each label
chore.labelsV2.forEach(label => {
if (labelDurations[label.id]) {
labelDurations[label.id].duration += duration
} else {
labelDurations[label.id] = {
label: label.name,
duration: duration,
color: label.color || TASK_COLOR.ANYTIME,
id: label.id,
}
}
})
} else {
// If chore has no labels or chore not found, add to unlabeled
unlabeledDuration += duration
}
}) })
const group = [] // Convert seconds to hours for better readability
if (assignedToMe > 0) { const result = Object.values(labelDurations)
group.push({ .map(item => ({
label: `Assigned to me`, label: item.label,
value: assignedToMe, value: Math.round((item.duration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal
color: TASK_COLOR.ASSIGNED_TO_ME, color: item.color,
id: 1, id: item.id,
}))
.filter(item => item.value > 0)
.sort((a, b) => b.value - a.value) // Sort by duration descending
// Add unlabeled tasks duration if there is any
if (unlabeledDuration > 0) {
result.push({
label: 'No Labels',
value: Math.round((unlabeledDuration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal
color: TASK_COLOR.ANYTIME,
id: 'unlabeled',
}) })
} }
if (assignedToOthers > 0) {
group.push({ return result
label: `Assigned to others`, }
value: assignedToOthers,
color: TASK_COLOR.ASSIGNED_TO_OTHERS, const generateTasksTimeChartData = history => {
id: 2, const taskDurations = {}
}) const colorValues = Object.values(COLORS)
}
return group // 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 generateChoreDuePieChartData = chores => {
@@ -274,19 +516,6 @@ const UserActivites = () => {
}) })
.filter(item => item.value > 0) .filter(item => item.value > 0)
} }
const generateChorePriorityPieChartData = chores => {
const groups = ChoresGrouper('priority', chores, null)
return groups
.map(group => {
return {
label: group.name,
value: group.content.length,
color: group.color,
id: group.name,
}
})
.filter(item => item.value > 0)
}
const generateHistoryPieChartData = history => { const generateHistoryPieChartData = history => {
const totalCompleted = const totalCompleted =
@@ -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,159 +629,364 @@ 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'
</Typography> sx={{
<Select alignSelf: 'flex-start',
sx={{ }}
width: 150, >
}} Activities Overview
variant='soft' </Typography>
label='User'
value={selectedUser} {/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */}
onChange={(e, selected) => { <Box
setSelectedUser(selected) sx={{
setSelectedHistory( display: 'flex',
enrichedHistory.filter(h => USER_FILTER(h, selected)), flexDirection: { xs: 'column', lg: 'row' },
) gap: 3,
console.log( alignItems: 'flex-start',
enrichedHistory, }}
selected, >
enrichedHistory.filter(h => USER_FILTER(h, selected)), {/* Left Side - Timeline with Filters (Mobile: Full width, Desktop: Flexible) */}
) <Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
}} {/* Improved Filter Bar - Now above timeline */}
renderValue={selected => ( <Card
<Typography variant='outlined'
startDecorator={
<Avatar
color='primary'
m={0}
size='sm'
src={resolvePhotoURL(
circleUsers.find(user => user.userId === selectedUser)
?.image,
)}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.image
}
</Avatar>
}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName
}
</Typography>
)}
>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
m={0}
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.image}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</Select>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
<Tabs
onChange={(e, tabValue) => {
setTabValue(tabValue)
refetchHistory(tabValue)
}}
defaultValue={7}
sx={{
py: 0.5,
borderRadius: 16,
maxWidth: 400,
mb: 1,
}}
>
<TabList
disableUnderline
sx={{ sx={{
borderRadius: 16, width: '100%',
backgroundColor: 'background.paper', p: 2,
boxShadow: 1, mb: 3,
justifyContent: 'space-evenly', 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}>
{ label: '7 Days', value: 7 }, <Typography level='title-sm' sx={{ color: 'text.secondary' }}>
{ label: '30 Days', value: 30 }, Filter Activities
{ label: '90 Days', value: 90 }, </Typography>
].map((tab, index) => (
<Tab <Stack
key={index} 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>
<Select
sx={{
width: '100%',
}}
variant='outlined'
value={selectedUser}
onChange={(e, selected) => {
setSelectedUser(selected)
setSelectedHistory(
enrichedHistory.filter(h => USER_FILTER(h, selected)),
)
}}
renderValue={() => {
if (
selectedUser === undefined ||
selectedUser === 'all'
) {
return (
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
)
}
return (
<Typography
startDecorator={
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(
circleUsers.find(
user => user.userId === selectedUser,
)?.image,
)}
>
{circleUsers
.find(user => user.userId === selectedUser)
?.displayName?.charAt(0)}
</Avatar>
}
>
{
circleUsers.find(
user => user.userId === selectedUser,
)?.displayName
}
</Typography>
)
}}
>
<Option value='all'>
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
</Option>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.displayName?.charAt(0)}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</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)
refetchHistory(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: '30 Days', value: 30 },
{ label: '90 Days', value: 90 },
{ label: 'All Time', value: 365 },
].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>
</Stack>
</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={{ sx={{
borderRadius: 16, display: 'flex',
color: 'text.secondary', flexDirection: 'column',
'&.Mui-selected': { alignItems: 'center',
color: 'text.primary', justifyContent: 'center',
backgroundColor: 'primary.light', textAlign: 'center',
}, minHeight: { lg: '400px' },
}} }}
disableIndicator
value={tab.value}
> >
{tab.label} <Typography level='h4' textAlign='center' sx={{ mb: 1 }}>
</Tab> {chartData[selectedChart].title}
))}
</TabList>
</Tabs>
</Box>
<Box sx={{ mb: 4 }}>
<Typography level='h4' textAlign='center'>
{chartData[selectedChart].title}
</Typography>
<Typography level='body-xs' textAlign='center'>
{chartData[selectedChart].description}
</Typography>
{renderPieChart(chartData[selectedChart].data, 250, true)}
</Box>
<Grid container spacing={1}>
{Object.entries(chartData)
.filter(([key]) => key !== selectedChart)
.map(([key, { data, title }]) => (
<Grid item key={key} xs={4}>
<Card
onClick={() => setSelectedChart(key)}
sx={{ cursor: 'pointer', p: 1 }}
>
<Typography textAlign='center' level='body-xs' mb={-2}>
{title}
</Typography> </Typography>
{renderPieChart(data, 75, false)} <Typography level='body-xs' textAlign='center' sx={{ mb: 2 }}>
</Card> {chartData[selectedChart].description}
</Grid> </Typography>
))} <Box
</Grid> sx={{
<ChoreHistoryTimeline history={selectedHistory} /> display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{renderPieChart(
chartData[selectedChart].data,
240,
true,
selectedChart,
)}
</Box>
</Box>
<Divider />
{/* Chart Selection Grid */}
<Box>
<Grid container spacing={1}>
{Object.entries(chartData)
.filter(([key]) => key !== selectedChart)
.map(([key, { data, title }]) => (
<Grid
item
key={key}
xs={4}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Card
onClick={() => setSelectedChart(key)}
variant='plain'
sx={{
cursor: 'pointer',
p: 1,
transition: 'all 0.2s ease-in-out',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 80,
maxWidth: 90,
'&:hover': {
transform: 'scale(1.02)',
boxShadow: 'sm',
},
}}
>
<Typography
textAlign='center'
level='body-xs'
sx={{
mb: 0.5,
fontSize: '0.65rem',
lineHeight: 1.2,
}}
>
{title}
</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{renderPieChart(data, 70, false)}
</Box>
</Card>
</Grid>
))}
</Grid>
</Box>
</Stack>
</Card>
</Box>
</Box>
</Container> </Container>
) )
} }

View File

@@ -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,103 +208,229 @@ 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 },
}} }}
> >
<Typography
mb={3}
level='h4'
sx={{
alignSelf: 'flex-start',
}}
>
Points Overview
</Typography>
{/* Improved Filter Bar */}
<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 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
sx={{
width: '100%',
}}
variant='outlined'
value={selectedUser}
onChange={(e, selected) => {
setSelectedUser(selected)
setSelectedHistory(
generateWeeklySummary(choresHistoryData, selected),
)
}}
renderValue={() => {
return (
<Typography
startDecorator={
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(
circleUsers.find(
user => user.userId === selectedUser,
)?.image,
)}
>
{circleUsers
.find(user => user.userId === selectedUser)
?.displayName?.charAt(0)}
</Avatar>
}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName
}
</Typography>
)
}}
>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.displayName?.charAt(0)}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</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 ===
'admin' && (
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<Button
variant='soft'
size='md'
startDecorator={<CreditCard />}
onClick={() => {
setIsRedeemModalOpen(true)
}}
sx={{ mt: 'auto' }}
>
Redeem Points
</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={{ sx={{
mb: 4, mb: 4,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 1, gap: 3,
}} }}
> >
<Typography level='h4'>Points Overview</Typography> {/* Points Cards */}
<Box
sx={{
gap: 1,
my: 2,
display: 'flex',
justifyContent: 'start',
}}
>
<Select
sx={{
width: 200,
}}
variant='soft'
label='User'
value={selectedUser}
onChange={(e, selected) => {
setSelectedUser(selected)
setSelectedHistory(generateWeeklySummary(choresHistory, selected))
}}
renderValue={selected => (
<Typography
startDecorator={
<Avatar
color='primary'
m={0}
size='sm'
src={resolvePhotoURL(
circleUsers.find(user => user.userId === selectedUser)
?.image,
)}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName[0]
}
</Avatar>
}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName
}
</Typography>
)}
>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
m={0}
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.displayName[0]}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</Select>
{circleUsers.find(user => user.userId === userProfile.id)?.role ===
'admin' && (
<Button
variant='soft'
size='md'
startDecorator={<CreditCard />}
onClick={() => {
setIsRedeemModalOpen(true)
}}
>
Redeem Points
</Button>
)}
</Box>
<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()
}) })

View File

@@ -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,101 +461,100 @@ 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}
<Typography level='h4'>Create new task</Typography> size='lg'
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'> fullWidth={true}
Experimental Feature >
</Chip> <Typography level='h4'>Create new task</Typography>
<Box> <Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
<Box Experimental Feature
sx={{ </Chip>
display: 'flex', <Box>
flexDirection: 'row', <Box
alignItems: 'center', sx={{
}} display: 'flex',
> flexDirection: 'row',
<Typography level='body-sm'>Task in a sentence:</Typography> alignItems: 'center',
<LearnMoreButton }}
content={ >
<> <Typography level='body-sm'>Task in a sentence:</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}> <LearnMoreButton
This feature lets you create a task simply by typing a content={
sentence. It attempt parses the sentence to identify the <>
task&apos;s due date, priority, and frequency. <Typography level='body-sm' sx={{ mb: 1 }}>
</Typography> This feature lets you create a task simply by typing a
sentence. It attempt parses the sentence to identify the
task&apos;s due date, priority, and frequency.
</Typography>
<Typography <Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
level='body-sm' Examples:
sx={{ fontWeight: 'bold', mt: 2 }} </Typography>
>
Examples:
</Typography>
<Typography <Typography
level='body-sm' level='body-sm'
component='ul' component='ul'
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 like{' '}
<strong>Due date:</strong> Specify dates with phrases <em>tomorrow</em>, <em>next week</em>, <em>Monday</em>, or{' '}
like <em>tomorrow</em>, <em>next week</em>,{' '} <em>August 1st at 12pm</em>.
<em>Monday</em>, or <em>August 1st at 12pm</em>. </li>
</li> <li>
<li> <strong>Frequency:</strong> Set recurring tasks with terms
<strong>Frequency:</strong> Set recurring tasks with like <em>daily</em>, <em>weekly</em>, <em>monthly</em>,{' '}
terms like <em>daily</em>, <em>weekly</em>,{' '} <em>yearly</em>, or patterns such as{' '}
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '} <em>every Tuesday and Thursday</em>.
<em>every Tuesday and Thursday</em>. </li>
</li> </Typography>
</Typography> </>
</> }
} />
/> </Box>
</Box>
<SmartTaskTitleInput <SmartTaskTitleInput
autoFocus autoFocus
value={taskText} value={taskText}
placeholder='Type your full text here...' placeholder='Type your full text here...'
onChange={text => { onChange={text => {
setTaskText(text) setTaskText(text)
}} }}
customRenderer={renderedParts} customRenderer={renderedParts}
onEnterPressed={handleEnterPressed} onEnterPressed={handleEnterPressed}
suggestions={{ suggestions={{
'#': { '#': {
value: 'id', value: 'id',
display: 'name', display: 'name',
options: userLabels ? userLabels : [], options: userLabels ? userLabels : [],
}, },
'!': { '!': {
value: 'id', value: 'id',
display: 'name', display: 'name',
options: [ options: [
{ id: '1', name: 'P1' }, { id: '1', name: 'P1' },
{ id: '2', name: 'P2' }, { id: '2', name: 'P2' },
{ id: '3', name: 'P3' }, { id: '3', name: 'P3' },
{ id: '4', name: 'P4' }, { id: '4', name: 'P4' },
], ],
}, },
'@': { '@': {
value: 'userId', value: 'userId',
display: 'displayName', display: 'displayName',
options: circleMembers?.res || [], options: circleMembers?.res || [],
}, },
}} }}
/> />
</Box> </Box>
{/* <Box> {/* <Box>
<Typography level='body-sm'>Title:</Typography> <Typography level='body-sm'>Title:</Typography>
<Input <Input
value={taskTitle} value={taskTitle}
@@ -501,126 +562,142 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
sx={{ width: '100%', fontSize: '16px' }} sx={{ width: '100%', fontSize: '16px' }}
/> />
</Box> */} </Box> */}
<Box>
{!hasDescription && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => setHasDescription(true)}
>
Description
</Button>
)}
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => setHasSubTasks(true)}
>
Subtasks
</Button>
)}
{!dueDate && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => {
setDueDate(
moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
)
}}
>
Due Date
</Button>
)}
{!hasNotifications && dueDate && (
<Button
startDecorator={<EditNotifications />}
variant='plain'
size='sm'
onClick={() => {
setHasNotifications(true)
setFrequencyHumanReadable('Once')
setFrequency(null)
setDueDate(
moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
)
}}
>
Edit Notifications
</Button>
)}
</Box>
{hasDescription && ( <Box>
<Box> {!hasDescription && (
<Typography level='body-sm'>Description:</Typography> <Button
<div> startDecorator={<Add />}
<RichTextEditor variant='plain'
onChange={setDescription} size='sm'
entityType={'chore_description'} onClick={() => {
/> setHasDescription(true)
</div> // Focus will be handled by the useEffect hook
</Box> }}
)} endDecorator={
{hasSubTasks && ( showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
<Box> }
<Typography level='body-sm'>Subtasks:</Typography> >
<SubTasks Description
editMode={true} </Button>
tasks={subTasks ? subTasks : []} )}
setTasks={setSubTasks}
/>
</Box>
)}
<Box {!hasSubTasks && (
sx={{ <Button
marginTop: 2, startDecorator={<Add />}
display: 'flex', variant='plain'
flexDirection: 'row', size='sm'
gap: 2, onClick={() => {
setHasSubTasks(true)
}}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
}
>
Subtasks
</Button>
)}
{!dueDate && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => {
setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'))
}}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' />
}
>
Due Date
</Button>
)}
{!hasNotifications && dueDate && (
<Button
startDecorator={<EditNotifications />}
variant='plain'
size='sm'
onClick={() => {
setHasNotifications(true)
setFrequencyHumanReadable('Once')
setFrequency(null)
setDueDate(moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'))
}} }}
> >
<FormControl> Edit Notifications
<Typography level='body-sm'>Priority</Typography> </Button>
<Select )}
defaultValue={0} </Box>
value={priority}
onChange={(e, value) => setPriority(value)} {hasDescription && (
> <Box>
<Option value='0'>No Priority</Option> <Typography level='body-sm'>Description:</Typography>
<Option value='1'>P1</Option> <div>
<Option value='2'>P2</Option> <RichTextEditor
<Option value='3'>P3</Option> ref={richTextEditorRef}
<Option value='4'>P4</Option> onChange={setDescription}
</Select> entityType={'chore_description'}
</FormControl> />
{dueDate && ( </div>
<FormControl> </Box>
<Typography level='body-sm'>Due Date</Typography> )}
<Input {hasSubTasks && (
type='datetime-local' <Box>
value={dueDate} <Typography level='body-sm'>Subtasks:</Typography>
onChange={e => setDueDate(e.target.value)} <SubTasks
sx={{ width: '100%', fontSize: '16px' }} editMode={true}
/> tasks={subTasks ? subTasks : []}
</FormControl> setTasks={setSubTasks}
)} shouldFocus={true}
</Box> />
<Box </Box>
sx={{ )}
marginTop: 2,
display: 'flex', <Box
flexDirection: 'row', sx={{
justifyContent: 'start', marginTop: 2,
gap: 2, display: 'flex',
}} flexDirection: 'row',
> gap: 2,
{/* <FormControl> }}
>
{priority > 0 && (
<FormControl>
<Typography level='body-sm'>Priority</Typography>
<Select
defaultValue={0}
value={priority}
onChange={(e, value) => setPriority(value)}
>
<Option value='0'>No Priority</Option>
<Option value='1'>P1</Option>
<Option value='2'>P2</Option>
<Option value='3'>P3</Option>
<Option value='4'>P4</Option>
</Select>
</FormControl>
)}
{dueDate && (
<FormControl>
<Typography level='body-sm'>Due Date</Typography>
<Input
type='datetime-local'
value={dueDate}
onChange={e => setDueDate(e.target.value)}
sx={{ width: '100%', fontSize: '16px' }}
/>
</FormControl>
)}
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: 2,
}}
>
{/* <FormControl>
<Typography level='body-sm'>Assignees</Typography> <Typography level='body-sm'>Assignees</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{assignees.length > 0 ? ( {assignees.length > 0 ? (
@@ -641,58 +718,61 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
)} )}
</Box> </Box>
</FormControl> */} </FormControl> */}
{hasNotifications && dueDate && ( {hasNotifications && dueDate && (
<Box
sx={{
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography level='body-sm'>Notification Schedule</Typography>
<Box sx={{ p: 0.5 }}>
<NotificationTemplate
onChange={metadata => {
if (
metadata.notifications !==
notificationMetadata.templates
) {
const newNotificaitonMetadata = {
...notificationMetadata,
templates: metadata.notifications,
}
setNotificationMetadata(newNotificaitonMetadata)
}
}}
value={notificationMetadata}
showTimeline={false}
/>
</Box>
</Box>
)}
</Box>
<Box <Box
sx={{ sx={{
marginTop: 2, flexDirection: 'column',
display: 'flex', alignItems: 'center',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}} }}
> >
<Button <Typography level='body-sm'>Notification Schedule</Typography>
variant='outlined' <Box sx={{ p: 0.5 }}>
color='neutral' <NotificationTemplate
onClick={handleCloseModal} onChange={metadata => {
> if (
Cancel metadata.notifications !== notificationMetadata.templates
</Button> ) {
<Button variant='solid' color='primary' onClick={handleSubmit}> const newNotificaitonMetadata = {
Create ...notificationMetadata,
</Button> templates: metadata.notifications,
}
setNotificationMetadata(newNotificaitonMetadata)
}
}}
value={notificationMetadata}
showTimeline={false}
/>
</Box>
</Box> </Box>
</ModalDialog> )}
</ModalOverflow> </Box>
</Modal> <Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<Button variant='outlined' color='neutral' onClick={handleCloseModal}>
Cancel
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='Esc'
sx={{ ml: 1 }}
withCtrl={false}
/>
)}
</Button>
<Button variant='solid' color='primary' onClick={createChore}>
Create
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
</Box>
</FadeModal>
) )
} }

View File

@@ -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 => {

View File

@@ -2,217 +2,253 @@ 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 = '', (
onChange, {
isEditable = true, value = '',
placeholder = 'Enter description...', onChange,
variant = 'outlined', isEditable = true,
entityId, placeholder = 'Enter description...',
entityType, variant = 'outlined',
}) => { entityId,
const { showError } = useNotification() entityType,
const { data: userProfile } = useUserProfile() },
const quillRef = useRef(null) ref,
const editorRef = useRef(null) ) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
const quillRef = useRef(null)
const editorRef = useRef(null)
// Image upload handler - wrapped in useCallback to avoid recreating on every render // Expose focus method to parent components
const handleImageUpload = useCallback(() => { useImperativeHandle(
// Check if user has plus account ref,
if (!isPlusAccount(userProfile)) { () => ({
showError({ focus: () => {
title: 'Plus Feature', if (editorRef.current) {
message: editorRef.current.focus()
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.', }
}) },
return blur: () => {
} if (editorRef.current) {
editorRef.current.blur()
}
},
}),
[],
)
const input = document.createElement('input') // Image upload handler - wrapped in useCallback to avoid recreating on every render
input.setAttribute('type', 'file') const handleImageUpload = useCallback(() => {
input.setAttribute('accept', 'image/*') // Check if user has plus account
input.click() if (!isPlusAccount(userProfile)) {
input.onchange = async () => { showError({
const file = input.files[0] title: 'Plus Feature',
if (!file) return message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
try {
// Define compression options based on entity type ( this need a revist later)
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images
useWebWorker: true,
fileType: 'image/jpeg',
}
// Compress the image
const compressedFile = await imageCompression(file, compressionOptions)
// Create new file with .jpg extension to ensure it's treated as JPEG
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
console.log(`Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
)
// Upload compressed image to backend
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityId', entityId)
formData.append('entityType', entityType)
const response = await UploadFile('/assets/chore', {
method: 'POST',
body: formData,
}) })
return
}
if (response.status === 507) { const input = document.createElement('input')
showError({ input.setAttribute('type', 'file')
title: 'Storage Quota Exceeded', input.setAttribute('accept', 'image/*')
message: 'You have exceeded your quota for uploading files.', input.click()
input.onchange = async () => {
const file = input.files[0]
if (!file) return
try {
// Define compression options based on entity type ( this need a revist later)
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images
useWebWorker: true,
fileType: 'image/jpeg',
}
// Compress the image
const compressedFile = await imageCompression(
file,
compressionOptions,
)
// Create new file with .jpg extension to ensure it's treated as JPEG
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
console.log(
`Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`,
)
console.log(
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
)
// Upload compressed image to backend
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityId', entityId)
formData.append('entityType', entityType)
const response = await UploadFile('/assets/chore', {
method: 'POST',
body: formData,
}) })
return
} else if (response.status === 413) { if (response.status === 507) {
showError({ showError({
title: 'File Too Large', title: 'Storage Quota Exceeded',
message: 'The file you are trying to upload is too large.', message: 'You have exceeded your quota for uploading files.',
}) })
return return
} else if (response.status === 403 && !isPlusAccount()) { } else if (response.status === 413) {
showError({ showError({
title: 'Upgrade Required', title: 'File Too Large',
message: message: 'The file you are trying to upload is too large.',
'Image uploads are only available for Plus accounts. Please ', })
}) return
return } else if (response.status === 403 && !isPlusAccount()) {
} else if (response.status === 403) { showError({
showError({ title: 'Upgrade Required',
title: 'Permission Denied', message:
message: 'You do not have permission to upload files.', 'Image uploads are only available for Plus accounts. Please ',
}) })
return return
} else if (!response.ok) { } else if (response.status === 403) {
showError({
title: 'Permission Denied',
message: 'You do not have permission to upload files.',
})
return
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
})
return
}
const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign)
// Insert image into Quill
const quill = editorRef.current
const range = quill.getSelection()
quill.insertEmbed(range ? range.index : 0, 'image', url)
} catch (error) {
console.error('Error during image processing or upload:', error)
showError({ showError({
title: 'Upload Failed', title: 'Upload Failed',
message: 'Failed to upload image.', message: 'An error occurred while processing the image.',
}) })
return
} }
const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign)
// Insert image into Quill
const quill = editorRef.current
const range = quill.getSelection()
quill.insertEmbed(range ? range.index : 0, 'image', url)
} catch (error) {
console.error('Error during image processing or upload:', error)
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
})
} }
} }, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback
}, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback
useEffect(() => { useEffect(() => {
if (!quillRef.current) return if (!quillRef.current) return
if (!editorRef.current && isEditable) { if (!editorRef.current && isEditable) {
editorRef.current = new Quill(quillRef.current, { editorRef.current = new Quill(quillRef.current, {
theme: variant === 'bubble' ? 'bubble' : 'snow', theme: variant === 'bubble' ? 'bubble' : 'snow',
modules: { modules: {
toolbar: { toolbar: {
container: [ container: [
[{ header: [1, 2, 3, 4, false] }], [{ header: [1, 2, 3, 4, false] }],
['bold', 'italic', 'underline', 'strike'], ['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'], ['blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }], [{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'], ['link', 'image'],
['clean'], ['clean'],
], ],
handlers: { handlers: {
image: handleImageUpload, image: handleImageUpload,
},
}, },
}, },
}, placeholder: placeholder,
placeholder: placeholder, })
}) new QuillMarkdown(editorRef.current, {})
new QuillMarkdown(editorRef.current, {}) editorRef.current.root.innerHTML = value
editorRef.current.root.innerHTML = value editorRef.current.on('text-change', () => {
editorRef.current.on('text-change', () => { if (onChange) {
if (onChange) { onChange(editorRef.current.root.innerHTML)
onChange(editorRef.current.root.innerHTML) }
})
}
// If switching to read-only mode, disable Quill instance
if (editorRef.current && !isEditable) {
// editorRef.current.disable()
editorRef.current.readOnly = true
// If switching back to editable, enable Quill
if (editorRef.current && isEditable) {
// editorRef.current.enable()
editorRef.current.readOnly = false
} }
}) }
} }, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array
// If switching to read-only mode, disable Quill instance
if (editorRef.current && !isEditable) {
// editorRef.current.disable()
editorRef.current.readOnly = true
// If switching back to editable, enable Quill useEffect(() => {
if (editorRef.current && isEditable) { if (editorRef.current && isEditable) {
// editorRef.current.enable() if (editorRef.current.root.innerHTML !== value) {
editorRef.current.readOnly = false editorRef.current.root.innerHTML = value || ''
}
} }
} }, [value, isEditable])
}, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array
useEffect(() => { if (!isEditable) {
if (editorRef.current && isEditable) { // Display-only mode: render HTML
if (editorRef.current.root.innerHTML !== value) { return (
editorRef.current.root.innerHTML = value || '' <div
} className='editor-view-mode'
style={{
minHeight: 120,
overflow: 'scroll',
// border:
// '1px solid var(--joy-palette-neutral-outlinedBorder, #DDE7EE)',
borderRadius: 8,
padding: 16,
background: 'var(--joy-palette-background-surface, #fff)',
color: 'var(--joy-palette-text-primary, #1A2027)',
fontFamily:
'var(--joy-fontFamily-body, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif)',
fontSize: 16,
boxShadow:
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
}}
dangerouslySetInnerHTML={{ __html: value }}
/>
)
} }
}, [value, isEditable])
if (!isEditable) {
// Display-only mode: render HTML
return ( return (
<div <div className={`quill-root quill-variant-${variant}`}>
className='editor-view-mode' <div
style={{ ref={quillRef}
minHeight: 120, style={{
overflow: 'scroll', minHeight: 120,
// border: background: 'var(--joy-palette-background-surface, #fff)',
// '1px solid var(--joy-palette-neutral-outlinedBorder, #DDE7EE)', }}
borderRadius: 8, />
padding: 16, </div>
background: 'var(--joy-palette-background-surface, #fff)',
color: 'var(--joy-palette-text-primary, #1A2027)',
fontFamily:
'var(--joy-fontFamily-body, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif)',
fontSize: 16,
boxShadow:
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
}}
dangerouslySetInnerHTML={{ __html: value }}
/>
) )
} },
)
return ( RichTextEditor.displayName = 'RichTextEditor'
<div className={`quill-root quill-variant-${variant}`}>
<div
ref={quillRef}
style={{
minHeight: 120,
background: 'var(--joy-palette-background-surface, #fff)',
}}
/>
</div>
)
}
export default RichTextEditor export default RichTextEditor

View File

@@ -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)}