diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js index 55c009e..cbebd5f 100644 --- a/src/hooks/useSSE.js +++ b/src/hooks/useSSE.js @@ -1,6 +1,7 @@ import { useQueryClient } from '@tanstack/react-query' import { EventSourcePolyfill } from 'event-source-polyfill' import { useCallback, useEffect, useRef, useState } from 'react' +import { useUserProfile } from '../queries/UserQueries' import { useAlerts } from '../service/AlertsProvider' import { useNotification } from '../service/NotificationProvider' import { apiManager, isTokenValid } from '../utils/TokenManager' @@ -15,6 +16,7 @@ const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes export const useSSE = () => { + const { data: userProfile } = useUserProfile() const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED) const [lastEvent, setLastEvent] = useState(null) const [error, setError] = useState(null) @@ -65,12 +67,14 @@ export const useSSE = () => { case 'chore.updated': case 'chore.completed': case 'chore.skipped': { - showNotification({ - type: 'info', - title: `Task ${eventData.type.replace('chore.', '')}`, - message: `${eventData.data.user.displayName} ${eventData.type.replace('chore.', '')} "${eventData.data.chore.name}"`, - duration: 5000, - }) + if (eventData?.data?.user?.id !== userProfile?.id) { + showNotification({ + type: 'info', + title: `Task ${eventData.type.replace('chore.', '')}`, + message: `${eventData.data.user.displayName} ${eventData.type.replace('chore.', '')} "${eventData.data.chore.name}"`, + duration: 5000, + }) + } const updatedChore = eventData.data.chore // Update individual chore cache diff --git a/src/service/AlertsProvider.jsx b/src/service/AlertsProvider.jsx index 6f29486..0815f3d 100644 --- a/src/service/AlertsProvider.jsx +++ b/src/service/AlertsProvider.jsx @@ -43,11 +43,12 @@ export const AlertsProvider = ({ children }) => { {visibleAlert && ( diff --git a/src/views/ChoreEdit/TimePassedCard.jsx b/src/views/ChoreEdit/TimePassedCard.jsx index ae2a195..e1d5a0c 100644 --- a/src/views/ChoreEdit/TimePassedCard.jsx +++ b/src/views/ChoreEdit/TimePassedCard.jsx @@ -1,8 +1,16 @@ -import { Flag, Pause, PlayArrow, Schedule } from '@mui/icons-material' +import { + Flag, + OpenInFull, + Pause, + PlayArrow, + Schedule, +} from '@mui/icons-material' import { Box, Card, Chip, Typography } from '@mui/joy' import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' const TimePassedCard = ({ chore, handleAction, onShowDetails }) => { + const navigate = useNavigate() const [time, setTime] = useState(0) const [shouldAnimate, setShouldAnimate] = useState(false) const [prevStatus, setPrevStatus] = useState(null) // Initialize as null @@ -96,8 +104,30 @@ const TimePassedCard = ({ chore, handleAction, onShowDetails }) => { }, }, transition: 'all 0.3s ease', + cursor: 'pointer', + }} + onClick={e => { + // if this click on this element itself and not its children: + if (e.target !== e.currentTarget) return + navigate('./timer') }} > + { + e.stopPropagation() + navigate('./timer') + }} + > Timer Details - + {/* Restart timer - + */} Clear & Reset diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx index 710677b..45bc99b 100644 --- a/src/views/Timer/TimerDetails.jsx +++ b/src/views/Timer/TimerDetails.jsx @@ -18,30 +18,46 @@ import { FormControl, FormHelperText, Grid, + IconButton, Input, Typography, } from '@mui/joy' import moment from 'moment' -import { useEffect, useState } from 'react' -import { useNavigate, useParams } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' import { useNotification } from '../../service/NotificationProvider' import { - DeleteTimeSession, GetChoreTimer, + PauseChore, + StartChore, 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 [timerActionLoading, setTimerActionLoading] = useState(false) const { showError, showSuccess } = useNotification() + // Swipe functionality state for session cards + const [sessionSwipeStates, setSessionSwipeStates] = useState({}) + const swipeThreshold = 80 + const maxSwipeDistance = 160 + const dragStartX = useRef(0) + const [isDragging, setIsDragging] = useState(false) + const [isTouchDevice, setIsTouchDevice] = useState(false) + + // Detect if device supports touch + useEffect(() => { + const checkTouchDevice = () => { + setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0) + } + checkTouchDevice() + }, []) + // Fetch timer data when component mounts useEffect(() => { if (choreId) { @@ -227,53 +243,63 @@ const TimerDetails = () => { } } - const deleteSession = async sessionId => { - setLoading(true) + // Timer control functions + const handleStartTimer = async () => { + setTimerActionLoading(true) try { - const response = await DeleteTimeSession(choreId, sessionId) + const response = await StartChore(choreId) if (response.ok) { showSuccess({ - title: 'Session deleted', - message: 'Timer session has been deleted successfully.', + title: 'Timer Started', + message: 'Work session has been started successfully.', }) await fetchTimerData() - // Navigate back after successful deletion - navigate(`/chores/${choreId}`) } else { showError({ - title: 'Failed to delete session', + title: 'Failed to start timer', message: 'Please try again.', }) } } catch (error) { showError({ - title: 'Error deleting session', + title: 'Error starting timer', message: error.message, }) } finally { - setLoading(false) + setTimerActionLoading(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 handlePauseTimer = async () => { + setTimerActionLoading(true) + try { + const response = await PauseChore(choreId) + if (response.ok) { + showSuccess({ + title: 'Timer Paused', + message: 'Work session has been paused.', + }) + await fetchTimerData() + } else { + showError({ + title: 'Failed to pause timer', + message: 'Please try again.', + }) + } + } catch (error) { + showError({ + title: 'Error pausing timer', + message: error.message, + }) + } finally { + setTimerActionLoading(false) + } } - const handleGoBack = () => { - navigate(`/chores/${choreId}`) + // Determine if timer is currently running + const isTimerRunning = () => { + if (!timerData || !timerData.pauseLog) return false + return timerData.pauseLog.some(session => session.start && !session.end) } // Calculate total duration from start to now/end (real-time) @@ -318,8 +344,165 @@ const TimerDetails = () => { return Math.max(0, totalDuration - activeDuration) } + // Swipe functionality methods + const getSessionSwipeState = sessionIndex => { + return ( + sessionSwipeStates[sessionIndex] || { + translateX: 0, + isRevealed: false, + } + ) + } + + const updateSessionSwipeState = (sessionIndex, newState) => { + setSessionSwipeStates(prev => ({ + ...prev, + [sessionIndex]: { + ...prev[sessionIndex], + ...newState, + }, + })) + } + + const resetSessionSwipe = sessionIndex => { + updateSessionSwipeState(sessionIndex, { + translateX: 0, + isRevealed: false, + }) + } + + const resetAllSwipes = () => { + setSessionSwipeStates({}) + } + + // Touch handlers for swipe + const handleSessionTouchStart = e => { + dragStartX.current = e.touches[0].clientX + setIsDragging(true) + } + + const handleSessionTouchMove = (e, sessionIndex) => { + if (!isDragging) return + + const currentX = e.touches[0].clientX + const deltaX = currentX - dragStartX.current + const currentState = getSessionSwipeState(sessionIndex) + + if (currentState.isRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + updateSessionSwipeState(sessionIndex, { translateX: clampedDelta }) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + updateSessionSwipeState(sessionIndex, { translateX: clampedDelta }) + } + } + } + + const handleSessionTouchEnd = (e, sessionIndex) => { + if (!isDragging) return + setIsDragging(false) + + const currentState = getSessionSwipeState(sessionIndex) + + if (currentState.isRevealed) { + if (currentState.translateX > -swipeThreshold) { + resetSessionSwipe(sessionIndex) + } else { + updateSessionSwipeState(sessionIndex, { + translateX: -maxSwipeDistance, + isRevealed: true, + }) + } + } else { + if (Math.abs(currentState.translateX) > swipeThreshold) { + updateSessionSwipeState(sessionIndex, { + translateX: -maxSwipeDistance, + isRevealed: true, + }) + } else { + resetSessionSwipe(sessionIndex) + } + } + } + + // Mouse handlers for swipe (desktop) + const handleSessionMouseDown = e => { + dragStartX.current = e.clientX + setIsDragging(true) + } + + const handleSessionMouseMove = (e, sessionIndex) => { + if (!isDragging) return + + const currentX = e.clientX + const deltaX = currentX - dragStartX.current + const currentState = getSessionSwipeState(sessionIndex) + + if (currentState.isRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + updateSessionSwipeState(sessionIndex, { translateX: clampedDelta }) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + updateSessionSwipeState(sessionIndex, { translateX: clampedDelta }) + } + } + } + + const handleSessionMouseUp = (e, sessionIndex) => { + if (!isDragging) return + setIsDragging(false) + + const currentState = getSessionSwipeState(sessionIndex) + + if (currentState.isRevealed) { + if (currentState.translateX > -swipeThreshold) { + resetSessionSwipe(sessionIndex) + } else { + updateSessionSwipeState(sessionIndex, { + translateX: -maxSwipeDistance, + isRevealed: true, + }) + } + } else { + if (Math.abs(currentState.translateX) > swipeThreshold) { + updateSessionSwipeState(sessionIndex, { + translateX: -maxSwipeDistance, + isRevealed: true, + }) + } else { + resetSessionSwipe(sessionIndex) + } + } + } + + const handleEditSession = () => { + resetAllSwipes() + // Trigger the existing edit functionality + startEditingSession() + } + + const handleDeleteSession = sessionIndex => { + resetAllSwipes() + // For now, just show an alert since we'd need to implement session deletion API + showError({ + title: 'Delete Session', + message: `Session #${sessionIndex + 1} deletion would be implemented here`, + }) + } + + // Reset swipes when editing mode changes + useEffect(() => { + resetAllSwipes() + }, [editingSessions]) + return ( - + {/* Header */} {loading && ( @@ -387,8 +570,8 @@ const TimerDetails = () => { { { { { }} /> + + {/* Timeline Graph */} + + + Activity Timeline + + + {timerData && + timerData.pauseLog && + timerData.pauseLog.length > 0 ? ( + + {/* Timeline visualization */} + + {(() => { + const totalDuration = calculateTotalDuration() + const startTime = new Date(timerData.startTime) + + return timerData.pauseLog.map((session, index) => { + const sessionStart = new Date(session.start) + const sessionEnd = session.end + ? new Date(session.end) + : currentTime + + // Calculate position and width as percentages + const startOffset = Math.max( + 0, + (sessionStart - startTime) / 1000, + ) + const sessionDuration = Math.max( + 0, + (sessionEnd - sessionStart) / 1000, + ) + + const leftPercent = + (startOffset / Math.max(totalDuration, 1)) * 100 + const widthPercent = + (sessionDuration / Math.max(totalDuration, 1)) * 100 + + const isOngoing = !session.end + + return ( + + ) + }) + })()} + + + {/* Legend and time markers */} + + {/* Legend */} + + + + + Active Work + + + + + + Break Time + + + {isTimerRunning() && ( + + + + Live Session + + + )} + + + {/* Time markers */} + + + Started: {moment(timerData.startTime).format('HH:mm')} + + {timerData.endTime && ( + + Ended: {moment(timerData.endTime).format('HH:mm')} + + )} + {!timerData.endTime && ( + + Now: {moment(currentTime).format('HH:mm')} + + )} + + Active:{' '} + {calculateCurrentActiveDuration() > 0 + ? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}%` + : '0%'} + + + + + ) : ( + + + No activity timeline available. Start working to see your + activity pattern. + + + )} + {/* Session Breakdown */} - - Session Breakdown - + + Session Breakdown + {!editingSessions[timerData.id] && ( + + )} + {editingSessions[timerData.id] && ( + + + + + )} + {!editingSessions[timerData.id] ? ( @@ -656,93 +1094,233 @@ const TimerDetails = () => { ) : pause.duration + const swipeState = getSessionSwipeState(pauseIndex) + return ( - - {/* Session indicator */} + {/* Action buttons underneath (revealed on swipe) */} - - {/* Duration - Main focus */} - - - {formatDuration(realTimeDuration)} - - {isOngoing && ( - - Live - - )} - - - {/* Session details */} - - { + e.stopPropagation() + handleEditSession() + }} sx={{ - fontWeight: 'medium', - color: 'text.secondary', - mb: 0.2, + width: 40, + height: 40, + mx: 1, }} > - Session #{pauseIndex + 1} • {sessionDate} - - + + + { + e.stopPropagation() + handleDeleteSession(pauseIndex) + }} sx={{ - color: 'text.tertiary', - fontFamily: 'monospace', + width: 40, + height: 40, + mx: 1, }} > - {startTime}{' '} - {endTime ? `→ ${endTime}` : '→ ongoing'} - + + - + + {/* Session Card */} + { + if (swipeState.isRevealed) { + resetSessionSwipe(pauseIndex) + return + } + // Optional: Navigate to session details + }} + onTouchStart={handleSessionTouchStart} + onTouchMove={e => + handleSessionTouchMove(e, pauseIndex) + } + onTouchEnd={e => + handleSessionTouchEnd(e, pauseIndex) + } + onMouseDown={handleSessionMouseDown} + onMouseMove={e => + handleSessionMouseMove(e, pauseIndex) + } + onMouseUp={e => + handleSessionMouseUp(e, pauseIndex) + } + > + {/* Session indicator */} + + + {/* Duration - Main focus */} + + + {formatDuration(realTimeDuration)} + + {isOngoing && ( + + Live + + )} + + + {/* Session details */} + + + Session #{pauseIndex + 1} • {sessionDate} + + + {startTime}{' '} + {endTime ? `→ ${endTime}` : '→ ongoing'} + + + + {/* Right drag indicator (desktop only) */} + {!isTouchDevice && ( + + {/* Drag indicator dots */} + + {[...Array(3)].map((_, i) => ( + + ))} + + + )} + + ) })} @@ -917,81 +1495,73 @@ const TimerDetails = () => { )} - {/* Sticky Bottom Actions */} - - - - {/* */} - - {/* Right side - Action buttons */} - {!loading && timerData && !editingSessions[timerData.id] && ( - - - - - )} - - {/* Save/Cancel buttons when editing */} - {!loading && timerData && editingSessions[timerData.id] && ( - - - - - )} - - - - - + {/* Floating Timer Control Button */} + {!loading && timerData && ( + + {isTimerRunning() ? ( + + ) : ( + + )} + + )} ) } diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 864f757..a1fd787 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -159,7 +159,7 @@ const NavBar = () => { sx={{ '& .MuiDrawer-content': { position: 'fixed', - top: 'calc(env(safe-area-inset-top, 0px) + 35px)', + top: 'calc(env(safe-area-inset-top, 0px) + 45px)', left: 0, height: 'calc(100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))', @@ -224,7 +224,7 @@ const NavBar = () => { p: 1, color: 'text.tertiary', textAlign: 'center', - bottom: 0, + mb: 'calc(env(safe-area-inset-bottom, 0px) + 45px)', // mb: -2, }} >