@@ -25,6 +25,7 @@ type WidgetPosition = {
2525const POSITION_KEY = 'recall_pomodoro_widget_position' ;
2626const COLLAPSED_KEY = 'recall_pomodoro_widget_collapsed' ;
2727const DEFAULT_OFFSET = 16 ;
28+ const EDGE_PEEK = 18 ;
2829
2930const readStoredPosition = ( ) : WidgetPosition | null => {
3031 if ( typeof window === 'undefined' ) return null ;
@@ -49,14 +50,24 @@ const clampPosition = (position: WidgetPosition, width: number, height: number)
4950 } ;
5051} ;
5152
53+ const getDockedX = ( x : number , width : number ) => {
54+ if ( typeof window === 'undefined' ) return x ;
55+ const leftDistance = x ;
56+ const rightDistance = window . innerWidth - ( x + width ) ;
57+ return leftDistance <= rightDistance ? DEFAULT_OFFSET : Math . max ( DEFAULT_OFFSET , window . innerWidth - width - DEFAULT_OFFSET ) ;
58+ } ;
59+
5260export default function PomodoroFloatingWidget ( { onOpenPomodoro } : PomodoroFloatingWidgetProps ) {
5361 const [ tick , setTick ] = useState ( Date . now ( ) ) ;
5462 const [ dismissed , setDismissed ] = useState ( false ) ;
5563 const [ collapsed , setCollapsed ] = useState ( false ) ;
5664 const [ position , setPosition ] = useState < WidgetPosition | null > ( null ) ;
5765 const [ isDragging , setIsDragging ] = useState ( false ) ;
66+ const [ isEdgeDocked , setIsEdgeDocked ] = useState ( false ) ;
67+ const [ flashActive , setFlashActive ] = useState ( false ) ;
5868 const widgetRef = useRef < HTMLDivElement | null > ( null ) ;
5969 const dragRef = useRef ( { startX : 0 , startY : 0 , originX : 0 , originY : 0 } ) ;
70+ const previousRemainingRef = useRef < number | null > ( null ) ;
6071
6172 useEffect ( ( ) => {
6273 if ( typeof window === 'undefined' ) return ;
@@ -125,13 +136,16 @@ export default function PomodoroFloatingWidget({ onOpenPomodoro }: PomodoroFloat
125136 x : window . innerWidth - currentRect . width - DEFAULT_OFFSET ,
126137 y : window . innerHeight - currentRect . height - DEFAULT_OFFSET ,
127138 } ;
128- return clampPosition ( fallback , currentRect . width , currentRect . height ) ;
139+ const clamped = clampPosition ( fallback , currentRect . width , currentRect . height ) ;
140+ return isEdgeDocked
141+ ? { ...clamped , x : getDockedX ( clamped . x , currentRect . width ) }
142+ : clamped ;
129143 } ) ;
130144 } ;
131145
132146 window . addEventListener ( 'resize' , handleResize ) ;
133147 return ( ) => window . removeEventListener ( 'resize' , handleResize ) ;
134- } , [ position , collapsed , tick ] ) ;
148+ } , [ position , collapsed , tick , isEdgeDocked ] ) ;
135149
136150 useEffect ( ( ) => {
137151 if ( typeof window === 'undefined' || ! position ) return ;
@@ -148,6 +162,16 @@ export default function PomodoroFloatingWidget({ onOpenPomodoro }: PomodoroFloat
148162 return getResolvedPomodoroState ( base , tick ) ;
149163 } , [ tick ] ) ;
150164
165+ useEffect ( ( ) => {
166+ const previousRemaining = previousRemainingRef . current ;
167+ if ( previousRemaining !== null && previousRemaining > 0 && state . remaining === 0 ) {
168+ setFlashActive ( true ) ;
169+ const timer = window . setTimeout ( ( ) => setFlashActive ( false ) , 2200 ) ;
170+ return ( ) => window . clearTimeout ( timer ) ;
171+ }
172+ previousRemainingRef . current = state . remaining ;
173+ } , [ state . remaining ] ) ;
174+
151175 useEffect ( ( ) => {
152176 if ( ! isDragging ) return ;
153177
@@ -163,13 +187,23 @@ export default function PomodoroFloatingWidget({ onOpenPomodoro }: PomodoroFloat
163187 rect . height ,
164188 ) ;
165189 setPosition ( next ) ;
190+ setIsEdgeDocked ( false ) ;
191+ } ;
192+
193+ const stopDragging = ( ) => {
194+ if ( widgetRef . current && position ) {
195+ const rect = widgetRef . current . getBoundingClientRect ( ) ;
196+ const dockedX = getDockedX ( position . x , rect . width ) ;
197+ setPosition ( ( prev ) => ( prev ? { ...prev , x : dockedX } : prev ) ) ;
198+ setIsEdgeDocked ( true ) ;
199+ }
200+ setIsDragging ( false ) ;
166201 } ;
167202
168203 const onMouseMove = ( event : MouseEvent ) => handleMove ( event . clientX , event . clientY ) ;
169204 const onTouchMove = ( event : TouchEvent ) => {
170205 if ( event . touches [ 0 ] ) handleMove ( event . touches [ 0 ] . clientX , event . touches [ 0 ] . clientY ) ;
171206 } ;
172- const stopDragging = ( ) => setIsDragging ( false ) ;
173207
174208 window . addEventListener ( 'mousemove' , onMouseMove ) ;
175209 window . addEventListener ( 'mouseup' , stopDragging ) ;
@@ -181,7 +215,7 @@ export default function PomodoroFloatingWidget({ onOpenPomodoro }: PomodoroFloat
181215 window . removeEventListener ( 'touchmove' , onTouchMove ) ;
182216 window . removeEventListener ( 'touchend' , stopDragging ) ;
183217 } ;
184- } , [ isDragging ] ) ;
218+ } , [ isDragging , position ] ) ;
185219
186220 if ( ! state . hasActiveSession || dismissed || ! position ) return null ;
187221
@@ -224,11 +258,29 @@ export default function PomodoroFloatingWidget({ onOpenPomodoro }: PomodoroFloat
224258 setIsDragging ( true ) ;
225259 } ;
226260
261+ const collapsedEdgeStyle = collapsed && isEdgeDocked && widgetRef . current
262+ ? position . x <= window . innerWidth / 2
263+ ? { transform : `translateX(-${ Math . max ( 0 , widgetRef . current . getBoundingClientRect ( ) . width - EDGE_PEEK ) } px)` }
264+ : { transform : `translateX(${ Math . max ( 0 , widgetRef . current . getBoundingClientRect ( ) . width - EDGE_PEEK ) } px)` }
265+ : undefined ;
266+
227267 return (
228268 < div
229269 ref = { widgetRef }
230- className = { `fixed z-[65] select-none rounded-2xl border border-[rgba(var(--theme-accent),0.26)] bg-[rgba(19,22,28,0.94)] shadow-[0_18px_40px_rgba(0,0,0,0.32)] backdrop-blur-xl ${ collapsed ? 'w-auto p-2.5' : 'w-[min(88vw,320px)] p-3' } ` }
231- style = { { left : position . x , top : position . y } }
270+ className = { `fixed z-[65] select-none rounded-2xl border backdrop-blur-xl transition-[transform,opacity,box-shadow,border-color,background-color] duration-300 ${
271+ flashActive
272+ ? 'border-amber-300/80 bg-[rgba(120,80,10,0.92)] shadow-[0_0_0_1px_rgba(251,191,36,0.35),0_0_32px_rgba(251,191,36,0.35)]'
273+ : collapsed && isEdgeDocked
274+ ? 'border-[rgba(var(--theme-accent),0.18)] bg-[rgba(19,22,28,0.52)] shadow-[0_12px_28px_rgba(0,0,0,0.22)] hover:bg-[rgba(19,22,28,0.82)] hover:border-[rgba(var(--theme-accent),0.28)] hover:translate-x-0'
275+ : 'border-[rgba(var(--theme-accent),0.26)] bg-[rgba(19,22,28,0.94)] shadow-[0_18px_40px_rgba(0,0,0,0.32)]'
276+ } ${ collapsed ? 'w-auto p-2.5' : 'w-[min(88vw,320px)] p-3' } `}
277+ style = { { left : position . x , top : position . y , ...( collapsedEdgeStyle ?? { } ) } }
278+ onMouseEnter = { ( ) => {
279+ if ( collapsed && isEdgeDocked ) setIsEdgeDocked ( false ) ;
280+ } }
281+ onMouseLeave = { ( ) => {
282+ if ( collapsed ) setIsEdgeDocked ( true ) ;
283+ } }
232284 >
233285 < div className = "flex items-start justify-between gap-3" >
234286 < button
0 commit comments