import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
AccessTime,
Add,
BrowseGallery,
Delete,
Edit,
MoreVert,
PauseCircle,
Person,
PlayArrow,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Alert,
Avatar,
Box,
Button,
Card,
CardContent,
Chip,
Container,
FormControl,
FormHelperText,
Grid,
IconButton,
Input,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import {
useChoreTimer,
usePauseChore,
useStartChore,
useUpdateTimeSession,
} from '../../queries/TimeQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getSafeBottom } from '../../utils/SafeAreaUtils'
import LoadingComponent from '../components/Loading'
// Effectively "can this action be queued offline?" — requires the offline
// feature, otherwise there is no command queue to replay it later.
const isNetworkError = err =>
isOfflineFeatureEnabled() &&
err instanceof TypeError &&
err.message === 'Failed to fetch'
const TimerDetails = () => {
const { choreId } = useParams()
const { fmt } = useLocalization()
const [timerData, setTimerData] = useState(null)
const [loading, setLoading] = useState(false)
const [editingSessions, setEditingSessions] = useState({})
const [currentTime, setCurrentTime] = useState(new Date())
const [timerActionLoading, setTimerActionLoading] = useState(false)
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const { showError, showSuccess } = useNotification()
// Fetch circle members data
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
// Timer hooks
const { data: choreTimer, refetch: refetchTimer } = useChoreTimer(choreId)
const startChore = useStartChore()
const pauseChore = usePauseChore()
const updateTimeSession = useUpdateTimeSession()
const members = circleMembersData?.res || []
// Helper function to find member by user ID
const getMemberById = userId => {
return members?.find(member => member.userId === userId)
}
// Update timerData when choreTimer data changes
useEffect(() => {
if (choreTimer?.res) {
setTimerData(choreTimer.res)
}
}, [choreTimer])
// 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 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,
}
updateTimeSession.mutate(
{ choreId, sessionId, sessionData: updateData },
{
onSuccess: () => {
showSuccess({
title: 'Session updated',
message: 'Timer session has been updated successfully.',
})
refetchTimer()
cancelEditingSession(sessionId)
},
onError: () => {
showError({
title: 'Failed to update session',
message: 'Please try again.',
})
},
},
)
} catch (error) {
showError({
title: 'Error updating session',
message: error.message,
})
} finally {
setLoading(false)
}
}
// Timer control functions
const handleStartTimer = () => {
setTimerActionLoading(true)
startChore.mutate(choreId, {
onSuccess: () => {
showSuccess({
title: 'Timer Started',
message: 'Work session has been started successfully.',
})
refetchTimer()
},
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
choreId,
{ id: choreId },
)
showSuccess({
title: 'Start queued',
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
},
})
return
}
showError({
title: 'Failed to start timer',
message: 'Please try again.',
})
},
onSettled: () => {
setTimerActionLoading(false)
},
})
}
const handlePauseTimer = () => {
setTimerActionLoading(true)
pauseChore.mutate(choreId, {
onSuccess: () => {
showSuccess({
title: 'Timer Paused',
message: 'Work session has been paused.',
})
refetchTimer()
},
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
choreId,
{ id: choreId },
)
showSuccess({
title: 'Pause queued',
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
},
})
return
}
showError({
title: 'Failed to pause timer',
message: 'Please try again.',
})
},
onSettled: () => {
setTimerActionLoading(false)
},
})
}
// 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)
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)
}
const handleEditSession = () => {
startEditingSession()
}
const handleDeleteSession = sessionIndex => {
// 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`,
})
}
if (loading || isCircleMembersLoading) {
return
}
return (
{/* Header */}
{loading && (
Loading timer data...
)}
{!loading && !timerData && (
No timer data found for this chore.
)}
{!loading && timerData && (
{/* Timer Summary */}
{/* Stats Grid */}
{/* Active Time */}
Active Work
{formatDuration(calculateCurrentActiveDuration())}
{/* Idle Time */}
Break Time
{formatDuration(calculateIdleTime())}
{/* Total Sessions */}
Sessions
{timerData.pauseLog?.length || 0}
{/* Total Session Time */}
Total Time
{formatTime(calculateTotalDuration())}
{/* Progress Bar */}
Work vs Break Distribution
{calculateCurrentActiveDuration() > 0
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
: 'No active time yet'}
{/* 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: {fmt.time(timerData.startTime)}
{timerData.endTime && (
Ended: {fmt.time(timerData.endTime)}
)}
{!timerData.endTime && (
Now: {fmt.time(currentTime)}
)}
Active:{' '}
{calculateCurrentActiveDuration() > 0
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}%`
: '0%'}
) : (
No activity timeline available. Start working to see your
activity pattern.
)}
{/* Session Breakdown */}
Session Breakdown
{!editingSessions[timerData.id] && (
}
onClick={() => startEditingSession()}
size='sm'
>
Edit
)}
{editingSessions[timerData.id] && (
)}
{!editingSessions[timerData.id] ? (
{/* Read-only view */}
{timerData.pauseLog && timerData.pauseLog.length > 0 && (
Work Sessions ({timerData.pauseLog.length})
{timerData.pauseLog
.sort((a, b) => moment(b.start) - moment(a.start))
.map((pause, pauseIndex) => {
const isOngoing = !pause.end
const sessionDate = moment(pause.start).format(
'MMM DD',
)
const startTime = fmt.time(pause.start)
const endTime = pause.end ? fmt.time(pause.end) : null
const realTimeDuration = isOngoing
? Math.max(
0,
Math.floor(
(currentTime - new Date(pause.start)) / 1000,
),
)
: pause.duration
return (
handleEditSession()}
>
Edit
handleDeleteSession(pauseIndex)
}
>
Delete
}
>
{/* Session Card Content */}
{/* Session indicator */}
{/* Duration - Main focus */}
{formatDuration(realTimeDuration)}
{isOngoing && (
Live
)}
{/* User chip showing who started the session */}
{pause.updatedBy &&
pause.updatedBy !== 0 &&
(() => {
const sessionUser = getMemberById(
pause.updatedBy,
)
return sessionUser ? (
{sessionUser?.displayName?.charAt(
0,
) ||
sessionUser?.name?.charAt(
0,
) || }
}
sx={{ fontSize: '0.7rem' }}
>
{sessionUser?.displayName ||
sessionUser?.name ||
'Unknown'}
) : null
})()}
{/* Session details */}
Session #{pauseIndex + 1} • {sessionDate}
{startTime}{' '}
{endTime ? `→ ${endTime}` : '→ ongoing'}
{
e.stopPropagation()
if (showMoreInfoId === pauseIndex) {
setShowMoreInfoId(null)
} else {
setShowMoreInfoId(pauseIndex)
}
}}
>
)
})}
)}
{(!timerData.pauseLog || timerData.pauseLog.length === 0) && (
No work sessions found for this timer.
)}
) : (
{/* Editing view */}
{/* Session Editor */}
Sessions
}
onClick={() => addPauseLogEntry(timerData.id)}
>
Add Session
{editingSessions[timerData.id].pauseLog.map(
(pause, pauseIndex) => (
Session #{pauseIndex + 1}
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)
),
)}
)}
)}
{/* Floating Timer Control Button */}
{!loading && timerData && (
{isTimerRunning() ? (
) : (
)}
)}
)
}
export default TimerDetails