1+ import React , { useState } from "react"
2+
3+ import AccountTreeTwoToneIcon from "@mui/icons-material/AccountTreeTwoTone"
4+ import AccountTreeIcon from "@mui/icons-material/AccountTree"
5+ import ReplayIcon from "@mui/icons-material/Replay"
6+ import { Add , Delete } from "@mui/icons-material"
7+
8+ import { DateTime , Duration } from "luxon"
9+
10+ import type { UUID } from "./uuid"
11+ import {
12+ useLocalStorage , CustomSerializable ,
13+ defaultSerializer , durationSerializer ,
14+ datetimeMaybeSerializer ,
15+ } from "./localStorageTools"
16+ import { TimerData , saveTimer , getTimerDuration } from "./timerUtils"
17+ import { AddTimerDialog } from "./TimerDialogs"
18+ import { PauseCircle , PlayButton , SemiCircle , FinishedBox } from "./svgTools"
19+
20+ export default function Timer ( props : {
21+ id : UUID ,
22+ currentTime : DateTime ,
23+ triggerStart ?: ( ) => void ,
24+ triggerStop ?: ( ) => void ,
25+ onDelete ?: ( ) => void ,
26+ notifyWhenFinished ?: boolean ,
27+ siblingRunning ?: UUID ,
28+ } ) {
29+
30+ function useUUIDStore < T > ( stateName : string , defaultValue : T , serializer : CustomSerializable < T > = defaultSerializer ) : [ T , ( newValue : T ) => void , ( ) => void ] {
31+ return useLocalStorage < T > ( `${ props . id } -${ stateName } ` , defaultValue , serializer )
32+ }
33+
34+ const [ name , setName , clearName ] = useUUIDStore < string > ( "name" , "unnamed" )
35+ const [ totalTime , setTotalTime , clearTotalTime ] = useUUIDStore < Duration > ( "totalTime" , Duration . fromMillis ( 0 ) , durationSerializer )
36+ const [ parentID , setParentID , clearParentID ] = useUUIDStore < UUID | "root" > ( "parentID" , "root" )
37+ const [ childrenIDs , setChildrenIDs , clearChildrenIDs ] = useUUIDStore < UUID [ ] > ( "childrenIDs" , [ ] )
38+
39+ const [ childRunning , setChildRunning , clearChildRunning ] = useUUIDStore < UUID | undefined > ( "childRunning" , undefined )
40+ const [ started , setStarted , clearStarted ] = useUUIDStore < DateTime | undefined > ( "started" , undefined , datetimeMaybeSerializer )
41+ const [ finished , setFinished , clearFinished ] = useUUIDStore < boolean > ( "finished" , false )
42+ const [ elapsed , setElapsed , clearElapsed ] = useUUIDStore < Duration > ( "elapsed" , Duration . fromMillis ( 0 ) , durationSerializer )
43+
44+ const [ expanded , setExpanded ] = useState < boolean > ( false )
45+ const [ addDialogOpen , setAddDialogOpen ] = useState < boolean > ( false )
46+
47+ const addTimer = ( timer : TimerData ) => {
48+ saveTimer ( timer )
49+ setChildrenIDs ( [ ...childrenIDs , timer . id ] )
50+ }
51+
52+ const clearSelf = ( ) => {
53+ clearName ( )
54+ clearTotalTime ( )
55+ clearParentID ( )
56+ clearChildrenIDs ( )
57+ clearChildRunning ( )
58+ clearStarted ( )
59+ clearFinished ( )
60+ clearElapsed ( )
61+ }
62+
63+ const currentSegment = ( started !== undefined ) ? props . currentTime . diff ( started ) : Duration . fromMillis ( 0 )
64+ const timeRemaining = totalTime . minus ( currentSegment . plus ( elapsed ) )
65+ const childrenTime : Duration = childrenIDs . reduce (
66+ ( acc , cid ) => acc . plus ( getTimerDuration ( cid ) ) , Duration . fromMillis ( 0 ) )
67+ . shiftTo ( "milliseconds" )
68+ const unallocatedTime = totalTime . minus ( childrenTime )
69+
70+ if ( timeRemaining . shiftTo ( "milliseconds" ) . milliseconds < 10 && started ) {
71+ if ( props . notifyWhenFinished ) {
72+ new Notification ( "Timer finished" , { body : name } )
73+ }
74+ setStarted ( undefined )
75+ setFinished ( true )
76+ props . triggerStop && props . triggerStop ( )
77+ }
78+
79+ const toggleExpanded = ( ) => { setExpanded ( ! expanded ) }
80+
81+ const startTimer = ( ) => {
82+ setStarted ( props . currentTime )
83+ props . triggerStart && props . triggerStart ( )
84+ }
85+
86+ const stopTimer = ( ) => {
87+ setElapsed ( elapsed . plus ( props . currentTime . diff ( started || DateTime . local ( ) ) ) )
88+ setStarted ( undefined )
89+ if ( childRunning !== undefined ) {
90+ setChildRunning ( "__NONE__" as UUID )
91+ }
92+ props . triggerStop && props . triggerStop ( )
93+ }
94+
95+ if ( props . siblingRunning && props . siblingRunning !== props . id && started ) {
96+ setElapsed ( elapsed . plus ( props . currentTime . diff ( started || DateTime . local ( ) ) ) )
97+ setStarted ( undefined )
98+ }
99+
100+ return (
101+ < li className = "Timer" >
102+ < h2 >
103+ < TimerControl
104+ running = { started !== undefined }
105+ finished = { finished }
106+ startable = { totalTime . minus ( childrenTime ) . shiftTo ( "milliseconds" ) . milliseconds > 0 }
107+ percentRemaining = { timeRemaining . shiftTo ( "milliseconds" ) . milliseconds / totalTime . shiftTo ( "milliseconds" ) . milliseconds }
108+ onStart = { startTimer }
109+ onStop = { stopTimer }
110+ />
111+ { ` ${ name } ` }
112+ { expanded ?
113+ < AccountTreeIcon
114+ className = "IconButton"
115+ onClick = { toggleExpanded }
116+ />
117+ :
118+ < AccountTreeTwoToneIcon
119+ className = "IconButton"
120+ onClick = { toggleExpanded }
121+ />
122+ }
123+ { ` ${ ( finished ) ? "00:00:00" : timeRemaining . toFormat ( "hh:mm:ss" ) } ` }
124+ { totalTime . minus ( timeRemaining ) . shiftTo ( "milliseconds" ) . milliseconds >= 0 &&
125+ < ReplayIcon
126+ className = "IconButton"
127+ onClick = { ( ) => {
128+ setElapsed ( Duration . fromMillis ( 0 ) )
129+ setFinished ( false )
130+ if ( started ) {
131+ setStarted ( props . currentTime )
132+ }
133+ } }
134+ />
135+ }
136+ { props . onDelete &&
137+ < Delete
138+ className = "IconButton"
139+ onClick = { ( ) => {
140+ clearSelf ( )
141+ props . onDelete && props . onDelete ( )
142+ } }
143+ />
144+ }
145+ </ h2 >
146+ { expanded && < ul className = "TimerList" >
147+ { childrenIDs . map ( cid => (
148+ < Timer
149+ key = { cid }
150+ id = { cid }
151+ currentTime = { props . currentTime }
152+
153+ triggerStart = { ( ) => {
154+ setChildRunning ( cid )
155+ if ( started === undefined ) {
156+ startTimer ( )
157+ }
158+ } }
159+ triggerStop = { ( ) => {
160+ setChildRunning ( undefined )
161+ if ( started !== undefined ) {
162+ stopTimer ( )
163+ }
164+ } }
165+ onDelete = { ( ) => {
166+ setChildrenIDs ( childrenIDs . filter ( id => id !== cid ) )
167+ } }
168+ siblingRunning = { childRunning }
169+ notifyWhenFinished = { props . notifyWhenFinished || false }
170+ /> )
171+ ) }
172+ { addDialogOpen || ( unallocatedTime . shiftTo ( "milliseconds" ) . milliseconds > 0 &&
173+ < div >
174+ < Add
175+ className = "IconButton"
176+ onClick = { ( ) => setAddDialogOpen ( true ) }
177+ />
178+ { ` Add Timer (unallocated: ${ unallocatedTime . toFormat ( "hh:mm:ss" ) } )` }
179+ </ div >
180+ ) }
181+ { addDialogOpen &&
182+ < AddTimerDialog
183+ addTimer = { addTimer }
184+ maxDuration = { unallocatedTime }
185+ parentID = { props . id }
186+ onCancel = { ( ) => setAddDialogOpen ( false ) }
187+ />
188+ }
189+ </ ul >
190+ }
191+ </ li >
192+ )
193+ }
194+
195+ function TimerControl ( props : {
196+ running : boolean , finished : boolean , startable : boolean ,
197+ percentRemaining : number ,
198+ onStart : ( ) => void , onStop : ( ) => void }
199+ ) {
200+ const { running, finished, startable, percentRemaining, onStart, onStop } = props
201+ const [ hovering , setHovering ] = useState < boolean > ( false )
202+
203+ const radius = 8
204+
205+ if ( finished ) {
206+ return (
207+ < span className = "TimerControl"
208+ style = { { cursor : "not-allowed" } }
209+ >
210+ < FinishedBox radius = { radius } fill = "#48A0B8" />
211+ </ span >
212+ )
213+ }
214+
215+ if ( ! startable && ! running ) {
216+ return (
217+ < span className = "TimerControl"
218+ style = { { cursor : "not-allowed" } }
219+ >
220+ < FinishedBox radius = { radius } fill = "#61DAFB" />
221+ </ span >
222+ )
223+ }
224+
225+ return (
226+ < span className = "TimerControl"
227+ style = { { cursor : "pointer" } }
228+ onMouseEnter = { ( ) => setHovering ( true ) }
229+ onMouseLeave = { ( ) => setHovering ( false ) }
230+
231+ onClick = { running ? onStop : onStart }
232+ >
233+ { ( running ) ?
234+ ( hovering ) ?
235+ < PauseCircle radius = { radius } fill = "#C9EFFB" percent = { percentRemaining } /> :
236+ < SemiCircle radius = { radius } fill = "#61dafb" percent = { percentRemaining } />
237+ :
238+ < PlayButton radius = { radius } fill = { ( hovering ) ? "#C9EFFB" : "#61dafb" } />
239+ }
240+ </ span >
241+ )
242+ }
0 commit comments