1- import { Tooltip , TooltipProvider } from '@repo/ui' ;
1+ import { formatDuration , Tooltip , TooltipProvider } from '@repo/ui' ;
22import React from 'react' ;
33import type { specta } from '@/environment' ;
44import { categoryToColor , FocusCategoryTooltip } from '@/features/focus' ;
55
66export const FocusPulse : React . FC < TFocusPulseProps > = ( props ) => {
77 const { activities } = props ;
88
9- const { focusedMs, neutralMs, distractingMs, score } = React . useMemo ( ( ) => {
10- let focusedMs = 0 ;
11- let neutralMs = 0 ;
12- let distractingMs = 0 ;
13- for ( const a of activities ) {
14- const dur = a . endedAt - a . startedAt ;
15- if ( a . category === 'focused' ) focusedMs += dur ;
16- else if ( a . category === 'neutral' ) neutralMs += dur ;
17- else if ( a . category === 'distracting' ) distractingMs += dur ;
18- }
19- const totalMs = focusedMs + neutralMs + distractingMs ;
20- const score =
21- totalMs === 0
22- ? 0
23- : Math . min ( 100 , Math . round ( ( ( focusedMs + neutralMs * 0.5 ) / totalMs ) * 100 ) ) ;
24- return { focusedMs, neutralMs, distractingMs, score } ;
25- } , [ activities ] ) ;
9+ const { focusedMs, neutralMs, distractingMs, uncategorizedMs, categorizedMs, score } =
10+ React . useMemo ( ( ) => {
11+ let focusedMs = 0 ;
12+ let neutralMs = 0 ;
13+ let distractingMs = 0 ;
14+ let uncategorizedMs = 0 ;
15+ for ( const a of activities ) {
16+ const dur = a . endedAt - a . startedAt ;
17+ if ( a . category === 'focused' ) focusedMs += dur ;
18+ else if ( a . category === 'neutral' ) neutralMs += dur ;
19+ else if ( a . category === 'distracting' ) distractingMs += dur ;
20+ else uncategorizedMs += dur ;
21+ }
22+ const categorizedMs = focusedMs + neutralMs + distractingMs ;
23+ const score =
24+ categorizedMs === 0
25+ ? 0
26+ : Math . min ( 100 , Math . round ( ( ( focusedMs + neutralMs * 0.5 ) / categorizedMs ) * 100 ) ) ;
27+ return { focusedMs, neutralMs, distractingMs, uncategorizedMs, categorizedMs, score } ;
28+ } , [ activities ] ) ;
2629
2730 const size = 128 ;
2831 const center = size / 2 ;
2932 const r = 52 ;
3033 const strokeWidth = 14 ;
3134 const c = 2 * Math . PI * r ;
32- const totalMs = focusedMs + neutralMs + distractingMs ;
35+ const totalMs = categorizedMs + uncategorizedMs ;
3336
3437 const focusedLen = totalMs > 0 ? ( focusedMs / totalMs ) * c : 0 ;
3538 const neutralLen = totalMs > 0 ? ( neutralMs / totalMs ) * c : 0 ;
3639 const distractingLen = totalMs > 0 ? ( distractingMs / totalMs ) * c : 0 ;
40+ const uncategorizedLen = totalMs > 0 ? ( uncategorizedMs / totalMs ) * c : 0 ;
41+ const categorizedPct = totalMs > 0 ? Math . round ( ( categorizedMs / totalMs ) * 100 ) : 0 ;
3742
3843 const focusedActivities = React . useMemo (
3944 ( ) => activities . filter ( ( activity ) => activity . category === 'focused' ) ,
@@ -47,6 +52,10 @@ export const FocusPulse: React.FC<TFocusPulseProps> = (props) => {
4752 ( ) => activities . filter ( ( activity ) => activity . category === 'distracting' ) ,
4853 [ activities ]
4954 ) ;
55+ const uncategorizedActivities = React . useMemo (
56+ ( ) => activities . filter ( ( activity ) => isUncategorizedActivity ( activity . category ) ) ,
57+ [ activities ]
58+ ) ;
5059
5160 // MARK: - UI
5261
@@ -147,6 +156,33 @@ export const FocusPulse: React.FC<TFocusPulseProps> = (props) => {
147156 />
148157 </ Tooltip >
149158 ) }
159+ { /* Uncategorized arc */ }
160+ { uncategorizedLen > 0 && (
161+ < Tooltip
162+ content = {
163+ < FocusCategoryTooltip
164+ category = { null }
165+ durationMs = { uncategorizedMs }
166+ activities = { uncategorizedActivities }
167+ />
168+ }
169+ side = "left"
170+ positionerClassName = "z-50"
171+ >
172+ < circle
173+ cx = { center }
174+ cy = { center }
175+ r = { r }
176+ fill = "none"
177+ strokeWidth = { strokeWidth }
178+ stroke = { categoryToColor ( null ) }
179+ strokeDasharray = { `${ uncategorizedLen } ${ c } ` }
180+ strokeDashoffset = { - ( focusedLen + neutralLen + distractingLen ) }
181+ transform = { `rotate(-90 ${ center } ${ center } )` }
182+ className = "cursor-default transition-opacity hover:opacity-80"
183+ />
184+ </ Tooltip >
185+ ) }
150186 </ svg >
151187 { /* Score */ }
152188 < div className = "pointer-events-none absolute inset-0 flex items-center justify-center" >
@@ -156,6 +192,11 @@ export const FocusPulse: React.FC<TFocusPulseProps> = (props) => {
156192
157193 { /* Label */ }
158194 < span className = "text-base-400 text-[11px]" > focus score</ span >
195+ < span className = "text-base-400 text-[11px]" >
196+ { uncategorizedMs > 0
197+ ? `${ categorizedPct } % categorized · ${ formatDuration ( uncategorizedMs / 1000 ) } uncategorized`
198+ : 'fully categorized' }
199+ </ span >
159200 </ div >
160201 </ TooltipProvider >
161202 ) ;
@@ -164,3 +205,7 @@ export const FocusPulse: React.FC<TFocusPulseProps> = (props) => {
164205interface TFocusPulseProps {
165206 activities : specta . WindowActivityDto [ ] ;
166207}
208+
209+ function isUncategorizedActivity ( category : specta . WindowActivityDto [ 'category' ] ) : boolean {
210+ return category !== 'focused' && category !== 'neutral' && category !== 'distracting' ;
211+ }
0 commit comments