From c2f5569010e93c48b5ddc0893fefe47928d50daa Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Wed, 9 Jul 2025 21:23:34 -0400 Subject: [PATCH] feat: add TimerEditModal component for managing timer sessions with real-time updates and editing capabilities --- src/views/Modals/Inputs/TimerEditModal.jsx | 870 +++++++++++++++++++++ 1 file changed, 870 insertions(+) create mode 100644 src/views/Modals/Inputs/TimerEditModal.jsx diff --git a/src/views/Modals/Inputs/TimerEditModal.jsx b/src/views/Modals/Inputs/TimerEditModal.jsx new file mode 100644 index 0000000..eb395ad --- /dev/null +++ b/src/views/Modals/Inputs/TimerEditModal.jsx @@ -0,0 +1,870 @@ +import { Add, Delete, Edit } from '@mui/icons-material' +import { + Alert, + Box, + Button, + Card, + Chip, + FormControl, + FormHelperText, + Input, + Typography, +} from '@mui/joy' +import moment from 'moment' +import { useEffect, useState } from 'react' +import FadeModal from '../../../components/common/FadeModal' +import { useNotification } from '../../../service/NotificationProvider' +import { + DeleteTimeSession, + GetChoreTimer, + UpdateTimeSession, +} from '../../../utils/Fetcher' +import ConfirmationModal from './ConfirmationModal' + +const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => { + const [timerData, setTimerData] = useState(null) + const [loading, setLoading] = useState(false) + const [editingSessions, setEditingSessions] = useState({}) + const [confirmDeleteConfig, setConfirmDeleteConfig] = useState({}) + const [currentTime, setCurrentTime] = useState(new Date()) + const { showError, showSuccess } = useNotification() + + // Fetch timer data when modal opens + useEffect(() => { + if (isOpen && choreId) { + fetchTimerData() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, choreId]) + + // Real-time update interval for active timers + useEffect(() => { + let interval + if (isOpen && timerData && !timerData.endTime) { + // Update every second if timer is active + interval = setInterval(() => { + setCurrentTime(new Date()) + }, 1000) + } + return () => { + if (interval) clearInterval(interval) + } + }, [isOpen, timerData]) + + const fetchTimerData = async () => { + setLoading(true) + try { + const response = await GetChoreTimer(choreId) + if (response.ok) { + const data = await response.json() + setTimerData(data.res) // data.res is the timer session object + } else { + showError({ + title: 'Failed to fetch timer data', + message: 'Please try again.', + }) + } + } catch (error) { + showError({ + title: 'Error fetching timer data', + message: error.message, + }) + } finally { + setLoading(false) + } + } + + const formatTime = seconds => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + return `${hours.toString().padStart(2, '0')}:${minutes + .toString() + .padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + const formatDuration = seconds => { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return `${hours}h ${minutes}m` + } + + const startEditingSession = () => { + if (timerData) { + setEditingSessions(prev => ({ + ...prev, + [timerData.id]: { + startTime: moment(timerData.startTime).format('YYYY-MM-DDTHH:mm:ss'), + endTime: timerData.endTime + ? moment(timerData.endTime).format('YYYY-MM-DDTHH:mm:ss') + : '', + duration: timerData.duration, + formattedDuration: formatTime(timerData.duration), + pauseLog: timerData.pauseLog || [], + }, + })) + } + } + + const addPauseLogEntry = sessionId => { + setEditingSessions(prev => ({ + ...prev, + [sessionId]: { + ...prev[sessionId], + pauseLog: [ + ...prev[sessionId].pauseLog, + { + start: new Date().toISOString(), + end: null, + duration: 0, + updatedBy: 0, // This should be current user ID + }, + ], + }, + })) + } + + const updatePauseLogEntry = (sessionId, pauseIndex, field, value) => { + setEditingSessions(prev => { + const updatedPauseLog = prev[sessionId].pauseLog.map((pause, index) => { + if (index === pauseIndex) { + const updatedPause = { ...pause, [field]: value } + + // Auto-calculate duration if both start and end are present + if (updatedPause.start && updatedPause.end) { + const startTime = new Date(updatedPause.start) + const endTime = new Date(updatedPause.end) + updatedPause.duration = Math.floor((endTime - startTime) / 1000) + } + + return updatedPause + } + return pause + }) + + return { + ...prev, + [sessionId]: { + ...prev[sessionId], + pauseLog: updatedPauseLog, + }, + } + }) + } + + const deletePauseLogEntry = (sessionId, pauseIndex) => { + setEditingSessions(prev => ({ + ...prev, + [sessionId]: { + ...prev[sessionId], + pauseLog: prev[sessionId].pauseLog.filter( + (_, index) => index !== pauseIndex, + ), + }, + })) + } + + const cancelEditingSession = sessionId => { + setEditingSessions(prev => { + // eslint-disable-next-line no-unused-vars + const { [sessionId]: removed, ...rest } = prev + return rest + }) + } + + const saveSession = async sessionId => { + const editingData = editingSessions[sessionId] + if (!editingData) return + + setLoading(true) + try { + // Use the auto-calculated duration from the editing session + const updateData = { + startTime: new Date(editingData.startTime).toISOString(), + endTime: editingData.endTime + ? new Date(editingData.endTime).toISOString() + : null, + duration: editingData.duration, + pauseLog: editingData.pauseLog, + } + + const response = await UpdateTimeSession(choreId, sessionId, updateData) + if (response.ok) { + showSuccess({ + title: 'Session updated', + message: 'Timer session has been updated successfully.', + }) + await fetchTimerData() + cancelEditingSession(sessionId) + onTimerUpdate?.() + } else { + showError({ + title: 'Failed to update session', + message: 'Please try again.', + }) + } + } catch (error) { + showError({ + title: 'Error updating session', + message: error.message, + }) + } finally { + setLoading(false) + } + } + + const deleteSession = async sessionId => { + setLoading(true) + try { + const response = await DeleteTimeSession(choreId, sessionId) + if (response.ok) { + showSuccess({ + title: 'Session deleted', + message: 'Timer session has been deleted successfully.', + }) + await fetchTimerData() + onTimerUpdate?.() + } else { + showError({ + title: 'Failed to delete session', + message: 'Please try again.', + }) + } + } catch (error) { + showError({ + title: 'Error deleting session', + message: error.message, + }) + } finally { + setLoading(false) + } + } + + const confirmDeleteSession = sessionId => { + setConfirmDeleteConfig({ + isOpen: true, + title: 'Delete Timer Session', + message: 'Are you sure you want to delete this timer session?', + confirmText: 'Delete', + cancelText: 'Cancel', + color: 'danger', + onClose: isConfirmed => { + if (isConfirmed) { + deleteSession(sessionId) + } + setConfirmDeleteConfig({}) + setEditingSessions({}) + onClose?.() + }, + }) + } + + const handleClose = () => { + setEditingSessions({}) + onClose?.() + } + + // Calculate total duration from start to now/end (real-time) + const calculateTotalDuration = () => { + if (!timerData) return 0 + + const startTime = new Date(timerData.startTime) + const endTime = timerData.endTime + ? new Date(timerData.endTime) + : currentTime + + return Math.floor((endTime - startTime) / 1000) // in seconds + } + + // Calculate current active duration (including ongoing session) (real-time) + const calculateCurrentActiveDuration = () => { + if (!timerData || !timerData.pauseLog) return 0 + + let totalActive = 0 + const now = currentTime + + timerData.pauseLog.forEach(session => { + if (session.start && session.end) { + // Completed session + totalActive += Math.floor( + (new Date(session.end) - new Date(session.start)) / 1000, + ) + } else if (session.start && !session.end) { + // Ongoing session - real-time calculation + totalActive += Math.floor((now - new Date(session.start)) / 1000) + } + }) + + return totalActive + } + + // Calculate idle time (total time minus active time) (real-time) + const calculateIdleTime = () => { + const totalDuration = calculateTotalDuration() + const activeDuration = calculateCurrentActiveDuration() + + return Math.max(0, totalDuration - activeDuration) + } + + return ( + <> + + Timer Details + + {loading && ( + + Loading timer data... + + )} + + {!loading && !timerData && ( + + No timer data found for this chore. + + )} + + {!loading && timerData && ( + + {/* Timer Summary */} + + {/* Header with timeline */} + + {/* Stats Grid */} + + {/* Active Time */} + + + {formatDuration(calculateCurrentActiveDuration())} + + + Active Work + + + + {/* Idle Time */} + + + {formatDuration(calculateIdleTime())} + + + Break Time + + + + {/* Total Sessions */} + + + {timerData.pauseLog?.length || 0} + + + Work Sessions + + + + {/* Total Session Time */} + + + {formatTime(calculateTotalDuration())} + + + Total Time + + + + + {/* Progress Bar */} + + + + Work vs Break Distribution + + + {calculateCurrentActiveDuration() > 0 + ? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active` + : 'No active time yet'} + + + + + + + + + {/* Time Session */} + + + Session Breakdown + + + + {!editingSessions[timerData.id] ? ( + + {/* Read-only view */} + {/* Sessions */} + {timerData.pauseLog && timerData.pauseLog.length > 0 && ( + + + Work Sessions ({timerData.pauseLog.length}) + + + + {timerData.pauseLog + .sort((a, b) => moment(b.start) - moment(a.start)) + .map((pause, pauseIndex) => { + const isOngoing = !pause.end + const sessionDate = moment(pause.start).format( + 'MMM DD', + ) + const startTime = moment(pause.start).format( + 'HH:mm', + ) + const endTime = pause.end + ? moment(pause.end).format('HH:mm') + : null + + const realTimeDuration = isOngoing + ? Math.max( + 0, + Math.floor( + (currentTime - new Date(pause.start)) / + 1000, + ), + ) + : pause.duration + + return ( + + {/* Session indicator */} + + + {/* Duration - Main focus */} + + + {formatDuration(realTimeDuration)} + + {isOngoing && ( + + Live + + )} + + + {/* Session details */} + + + Session #{pauseIndex + 1} • {sessionDate} + + + {startTime}{' '} + {endTime ? `→ ${endTime}` : '→ ongoing'} + + + + ) + })} + + + )} + + ) : ( + + {/* Editing view */} + + {/* Session Editor */} + + + + Sessions + + + + + {editingSessions[timerData.id].pauseLog.map( + (pause, pauseIndex) => ( + + + + Session #{pauseIndex + 1} + + + + + + + + Start Time + + + updatePauseLogEntry( + timerData.id, + pauseIndex, + 'start', + new Date(e.target.value).toISOString(), + ) + } + /> + + + + + End Time + + + updatePauseLogEntry( + timerData.id, + pauseIndex, + 'end', + e.target.value + ? new Date( + e.target.value, + ).toISOString() + : null, + ) + } + /> + + Leave empty if session is ongoing + + + + + + Duration (Auto-calculated) + + + {formatDuration(pause.duration)} ( + {pause.duration}s) + + + + + ), + )} + + + + )} + + + + {!timerData && ( + + No timer session found for this chore. + + )} + + )} + + + + + + + + {/* Action buttons on the right */} + {!loading && timerData && !editingSessions[timerData.id] && ( + <> + + + + )} + + {/* Save button when editing */} + {!loading && timerData && editingSessions[timerData.id] && ( + + )} + + + + + + + ) +} + +export default TimerEditModal