Files
donetick/src/views/Timer/TimerDetails.jsx

1446 lines
52 KiB
JavaScript

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 <LoadingComponent />
}
return (
<Container maxWidth='lg' sx={{ py: 2 }}>
{/* 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'
color='success'
sx={{
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'
color='warning'
sx={{
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: 'text.secondary',
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: 'text.secondary',
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>
{/* Timeline Graph */}
<Box sx={{ mt: 3 }}>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', fontWeight: 'medium', mb: 2 }}
>
Activity Timeline
</Typography>
{timerData &&
timerData.pauseLog &&
timerData.pauseLog.length > 0 ? (
<Box>
{/* Timeline visualization */}
<Box
sx={{
height: 40,
backgroundColor: 'neutral.100',
borderRadius: 'sm',
position: 'relative',
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
mb: 2,
}}
>
{(() => {
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 (
<Box
key={index}
sx={{
position: 'absolute',
left: `${leftPercent}%`,
width: `${widthPercent}%`,
height: '100%',
backgroundColor: isOngoing
? 'success.500'
: 'success.400',
borderRight: isOngoing ? '2px solid' : 'none',
borderRightColor: 'success.600',
transition: 'all 0.3s ease-in-out',
'&:hover': {
backgroundColor: isOngoing
? 'success.600'
: 'success.500',
zIndex: 1,
},
}}
title={`Session ${index + 1}: ${formatDuration(sessionDuration)} ${isOngoing ? '(ongoing)' : ''}`}
/>
)
})
})()}
</Box>
{/* Legend and time markers */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 2,
}}
>
{/* Legend */}
<Box
sx={{ display: 'flex', gap: 2, alignItems: 'center' }}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Box
sx={{
width: 12,
height: 12,
backgroundColor: 'success.400',
borderRadius: 'xs',
}}
/>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Active Work
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Box
sx={{
width: 12,
height: 12,
backgroundColor: 'neutral.100',
borderRadius: 'xs',
border: '1px solid',
borderColor: 'divider',
}}
/>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Break Time
</Typography>
</Box>
{isTimerRunning() && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Box
sx={{
width: 12,
height: 12,
backgroundColor: 'success.500',
borderRadius: 'xs',
border: '2px solid',
borderColor: 'success.600',
}}
/>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Live Session
</Typography>
</Box>
)}
</Box>
{/* Time markers */}
<Box
sx={{ display: 'flex', gap: 2, alignItems: 'center' }}
>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Started: {fmt.time(timerData.startTime)}
</Typography>
{timerData.endTime && (
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Ended: {fmt.time(timerData.endTime)}
</Typography>
)}
{!timerData.endTime && (
<Typography
level='body-xs'
sx={{ color: 'success.500' }}
>
Now: {fmt.time(currentTime)}
</Typography>
)}
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
Active:{' '}
{calculateCurrentActiveDuration() > 0
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}%`
: '0%'}
</Typography>
</Box>
</Box>
</Box>
) : (
<Alert color='neutral' variant='soft' sx={{ py: 2 }}>
<Typography level='body-sm'>
No activity timeline available. Start working to see your
activity pattern.
</Typography>
</Alert>
)}
</Box>
</Box>
</Card>
{/* Session Breakdown */}
<Box sx={{ mt: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography level='h4'>Session Breakdown</Typography>
{!editingSessions[timerData.id] && (
<Button
variant='outlined'
color='primary'
startDecorator={<Edit />}
onClick={() => startEditingSession()}
size='sm'
>
Edit
</Button>
)}
{editingSessions[timerData.id] && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant='outlined'
onClick={() => cancelEditingSession(timerData.id)}
size='sm'
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={() => saveSession(timerData.id)}
loading={loading}
size='sm'
>
Save Changes
</Button>
</Box>
)}
</Box>
{!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>
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{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 (
<SwipeableListItem
key={pauseIndex}
swipeActionOpen={
showMoreInfoId === pauseIndex
? 'trailing'
: null
}
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow:
'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction
onClick={() => handleEditSession()}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'neutral.softBg',
color: 'neutral.700',
px: 3,
height: '100%',
}}
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography
level='body-xs'
sx={{ mt: 0.5 }}
>
Edit
</Typography>
</Box>
</SwipeAction>
<SwipeAction
onClick={() =>
handleDeleteSession(pauseIndex)
}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.700',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography
level='body-xs'
sx={{ mt: 0.5 }}
>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
{/* Session Card Content */}
<Card
variant='soft'
sx={{
p: 2,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 2,
minHeight: 'auto',
borderColor: isOngoing
? 'success.300'
: 'divider',
borderRadius: 0,
borderBottom: '1px solid',
minWidth: '100%',
}}
>
{/* 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>
<Box
sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}
>
{isOngoing && (
<Chip
size='sm'
color='success'
variant='soft'
sx={{ fontSize: '0.7rem' }}
>
Live
</Chip>
)}
{/* User chip showing who started the session */}
{pause.updatedBy &&
pause.updatedBy !== 0 &&
(() => {
const sessionUser = getMemberById(
pause.updatedBy,
)
return sessionUser ? (
<Chip
size='sm'
variant='outlined'
color='neutral'
startDecorator={
<Avatar
size='sm'
src={resolvePhotoURL(
sessionUser?.image,
)}
sx={{ width: 16, height: 16 }}
>
{sessionUser?.displayName?.charAt(
0,
) ||
sessionUser?.name?.charAt(
0,
) || <Person />}
</Avatar>
}
sx={{ fontSize: '0.7rem' }}
>
{sessionUser?.displayName ||
sessionUser?.name ||
'Unknown'}
</Chip>
) : null
})()}
</Box>
</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>
<IconButton
color='neutral'
variant='plain'
size='sm'
onClick={e => {
e.stopPropagation()
if (showMoreInfoId === pauseIndex) {
setShowMoreInfoId(null)
} else {
setShowMoreInfoId(pauseIndex)
}
}}
>
<MoreVert sx={{ fontSize: 18 }} />
</IconButton>
</Card>
</SwipeableListItem>
)
})}
</SwipeableList>
</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>
)}
{/* Floating Timer Control Button */}
{!loading && timerData && (
<IconButton
color={isTimerRunning() ? 'warning' : 'success'}
variant='solid'
onClick={isTimerRunning() ? handlePauseTimer : handleStartTimer}
loading={timerActionLoading}
disabled={loading}
sx={{
position: 'fixed',
bottom: getSafeBottom(16, 16),
left: 16,
width: 56,
height: 56,
borderRadius: '50%',
zIndex: 1000,
boxShadow: 'lg',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: timerActionLoading ? 'scale(0.95)' : 'scale(1)',
animation: isTimerRunning()
? 'pulse-warning 2s infinite'
: 'pulse-success 2s infinite',
'&:hover': {
transform: 'scale(1.1)',
boxShadow: 'xl',
},
'&:active': {
transform: 'scale(0.95)',
},
'@keyframes pulse-success': {
'0%': {
boxShadow:
'0 4px 12px rgba(76, 175, 80, 0.3), 0 0 0 0 rgba(76, 175, 80, 0.7)',
},
'70%': {
boxShadow:
'0 4px 12px rgba(76, 175, 80, 0.3), 0 0 0 10px rgba(76, 175, 80, 0)',
},
'100%': {
boxShadow:
'0 4px 12px rgba(76, 175, 80, 0.3), 0 0 0 0 rgba(76, 175, 80, 0)',
},
},
'@keyframes pulse-warning': {
'0%': {
boxShadow:
'0 4px 12px rgba(255, 152, 0, 0.3), 0 0 0 0 rgba(255, 152, 0, 0.7)',
},
'70%': {
boxShadow:
'0 4px 12px rgba(255, 152, 0, 0.3), 0 0 0 10px rgba(255, 152, 0, 0)',
},
'100%': {
boxShadow:
'0 4px 12px rgba(255, 152, 0, 0.3), 0 0 0 0 rgba(255, 152, 0, 0)',
},
},
}}
title={isTimerRunning() ? 'Pause Timer' : 'Start Timer'}
>
{isTimerRunning() ? (
<PauseCircle sx={{ fontSize: 24 }} />
) : (
<PlayArrow sx={{ fontSize: 24 }} />
)}
</IconButton>
)}
</Container>
)
}
export default TimerDetails