@@ -10,6 +10,11 @@ import {
1010 type SleepEntry ,
1111} from '@/types' ;
1212import { formatSecondsToHHMM } from '@/utils/timeFormatters' ;
13+ import {
14+ DEBT_ZONE_COLOR ,
15+ SURPLUS_ZONE_COLOR ,
16+ DEFAULT_HYPNOGRAMS_SHOWN ,
17+ } from '@/constants/sleep' ;
1318import {
1419 Activity ,
1520 ChevronDown ,
@@ -26,6 +31,8 @@ import {
2631 Legend ,
2732 Line ,
2833 LineChart ,
34+ ReferenceArea ,
35+ ReferenceLine ,
2936 ResponsiveContainer ,
3037 Tooltip ,
3138 XAxis ,
@@ -74,8 +81,6 @@ interface SleepAnalyticsChartsProps {
7481 latestSleepEntry ?: SleepEntry | null ;
7582}
7683
77- const DEFAULT_HYPNOGRAMS_SHOWN = 2 ;
78-
7984const SleepAnalyticsCharts = ( {
8085 sleepAnalyticsData,
8186 sleepHypnogramData,
@@ -99,32 +104,132 @@ const SleepAnalyticsCharts = ({
99104 const [ showAllHypnograms , setShowAllHypnograms ] = useState ( false ) ;
100105
101106 const formatBedWakeTime = ( value : number ) => {
102- const hours = Math . floor ( value ) ;
107+ let hours = Math . floor ( value ) ;
103108 const minutes = Math . round ( ( value - hours ) * 60 ) ;
109+
110+ // If hours >= 24, it means it's early morning (cross-midnight)
111+ if ( hours >= 24 ) hours -= 24 ;
112+
104113 return `${ hours . toString ( ) . padStart ( 2 , '0' ) } :${ minutes . toString ( ) . padStart ( 2 , '0' ) } ` ;
105114 } ;
106115
107116 const chartData = sleepAnalyticsData
108- . map ( ( data ) => ( {
109- date : data . date ,
110- deep : data . stagePercentages . deep ,
111- rem : data . stagePercentages . rem ,
112- light : data . stagePercentages . light ,
113- awake : data . stagePercentages . awake ,
114- sleepDebt : data . sleepDebt ,
115- sleepEfficiency : data . sleepEfficiency ,
116- bedtime :
117- new Date ( data . earliestBedtime || 0 ) . getHours ( ) +
118- new Date ( data . earliestBedtime || 0 ) . getMinutes ( ) / 60 ,
119- wakeTime :
120- new Date ( data . latestWakeTime || 0 ) . getHours ( ) +
121- new Date ( data . latestWakeTime || 0 ) . getMinutes ( ) / 60 ,
122- } ) )
117+ . map ( ( data ) => {
118+ const totalMinutes =
119+ ( data . stagePercentages . deep || 0 ) +
120+ ( data . stagePercentages . rem || 0 ) +
121+ ( data . stagePercentages . light || 0 ) +
122+ ( data . stagePercentages . awake || 0 ) ;
123+
124+ const bedtimeDate = new Date ( data . earliestBedtime || 0 ) ;
125+ let bedtimeHours = bedtimeDate . getHours ( ) + bedtimeDate . getMinutes ( ) / 60 ;
126+
127+ // CROSS-MIDNIGHT FIX:
128+ // If bedtime is between 00:00 and 12:00 (midday), treat it as 24:00+
129+ // This keeps the trend line continuous (e.g. 23:00 -> 01:00 becomes 23:00 -> 25:00)
130+ if ( bedtimeHours >= 0 && bedtimeHours < 12 ) {
131+ bedtimeHours += 24 ;
132+ }
133+
134+ const wakeTimeDate = new Date ( data . latestWakeTime || 0 ) ;
135+ const wakeTimeHours =
136+ wakeTimeDate . getHours ( ) + wakeTimeDate . getMinutes ( ) / 60 ;
137+
138+ return {
139+ date : data . date ,
140+ deep : data . stagePercentages . deep ,
141+ rem : data . stagePercentages . rem ,
142+ light : data . stagePercentages . light ,
143+ awake : data . stagePercentages . awake ,
144+ totalMinutes,
145+ sleepDebt : data . sleepDebt ,
146+ sleepEfficiency : data . sleepEfficiency ,
147+ bedtime : bedtimeHours ,
148+ wakeTime : wakeTimeHours ,
149+ } ;
150+ } )
123151 . sort ( ( a , b ) => {
124152 // Safe sorting for date strings
125153 return a . date . localeCompare ( b . date ) ;
126154 } ) ;
127155
156+ interface CustomTooltipProps {
157+ active ?: boolean ;
158+ payload ?: {
159+ value : number ;
160+ name : string ;
161+ fill : string ;
162+ dataKey : string ;
163+ payload : {
164+ totalMinutes ?: number ;
165+ } ;
166+ } [ ] ;
167+ label ?: string ;
168+ }
169+
170+ const CustomTooltip = ( { active, payload, label } : CustomTooltipProps ) => {
171+ if ( active && payload && payload . length ) {
172+ return (
173+ < div
174+ className = "p-3 border rounded shadow-md"
175+ style = { {
176+ backgroundColor : tooltipBackgroundColor ,
177+ borderColor : tooltipBorderColor ,
178+ color : tickColor ,
179+ } }
180+ >
181+ < p className = "font-semibold mb-2" >
182+ { formatDateInUserTimezone ( label || '' , dateFormat ) }
183+ </ p >
184+ < div className = "space-y-1" >
185+ { payload . map ( ( entry , index : number ) => {
186+ if (
187+ entry . dataKey === 'sleepDebt' ||
188+ entry . dataKey === 'sleepEfficiency'
189+ )
190+ return null ;
191+
192+ const value = entry . value ;
193+ const total = entry . payload ?. totalMinutes || 0 ;
194+ const percent = total > 0 ? ( value / total ) * 100 : 0 ;
195+
196+ return (
197+ < div
198+ key = { index }
199+ className = "flex items-center justify-between gap-4 text-sm"
200+ >
201+ < div className = "flex items-center gap-2" >
202+ < div
203+ className = "w-3 h-3 rounded-full"
204+ style = { { backgroundColor : entry . fill } }
205+ />
206+ < span > { entry . name } :</ span >
207+ </ div >
208+ < span className = "font-mono" >
209+ { formatSecondsToHHMM ( value * 60 ) } ({ percent . toFixed ( 1 ) } %)
210+ </ span >
211+ </ div >
212+ ) ;
213+ } ) }
214+ </ div >
215+ { ( ( ) => {
216+ const totalMin = payload [ 0 ] ?. payload ?. totalMinutes ;
217+ if ( totalMin && totalMin > 0 ) {
218+ return (
219+ < div className = "mt-2 pt-2 border-t border-border/50 text-sm font-semibold flex justify-between" >
220+ < span > { t ( 'sleepAnalyticsCharts.total' , 'Total' ) } :</ span >
221+ < span > { formatSecondsToHHMM ( totalMin * 60 ) } </ span >
222+ </ div >
223+ ) ;
224+ }
225+ return null ;
226+ } ) ( ) }
227+ </ div >
228+ ) ;
229+ }
230+ return null ;
231+ } ;
232+
128233 // Sort hypnograms by date descending (most recent first)
129234 const sortedHypnograms = useMemo (
130235 ( ) => [ ...sleepHypnogramData ] . sort ( ( a , b ) => b . date . localeCompare ( a . date ) ) ,
@@ -266,17 +371,7 @@ const SleepAnalyticsCharts = ({
266371 stroke = { tickColor }
267372 tick = { { fill : tickColor } }
268373 />
269- < Tooltip
270- labelFormatter = { ( label ) =>
271- formatDateInUserTimezone ( label , dateFormat )
272- }
273- contentStyle = { {
274- backgroundColor : tooltipBackgroundColor ,
275- borderColor : tooltipBorderColor ,
276- color : tickColor ,
277- } }
278- itemStyle = { { color : tickColor } }
279- />
374+ < Tooltip content = { < CustomTooltip /> } />
280375 < Legend wrapperStyle = { { color : tickColor } } />
281376 < Bar
282377 dataKey = "deep"
@@ -440,10 +535,29 @@ const SleepAnalyticsCharts = ({
440535 < YAxis
441536 stroke = { tickColor }
442537 tick = { { fill : tickColor } }
538+ domain = { [ 'auto' , 'auto' ] }
443539 tickFormatter = { ( value ) =>
444540 formatSecondsToHHMM ( value * 3600 )
445541 }
446542 />
543+ { /* BACKGROUND COLOR ZONES */ }
544+ < ReferenceArea
545+ y1 = { 0 }
546+ y2 = { 100 }
547+ fill = { DEBT_ZONE_COLOR }
548+ fillOpacity = { 0.05 }
549+ />
550+ < ReferenceArea
551+ y1 = { - 100 }
552+ y2 = { 0 }
553+ fill = { SURPLUS_ZONE_COLOR }
554+ fillOpacity = { 0.05 }
555+ />
556+ < ReferenceLine
557+ y = { 0 }
558+ stroke = "#666"
559+ strokeDasharray = "3 3"
560+ />
447561 < Tooltip
448562 labelFormatter = { ( label ) =>
449563 formatDateInUserTimezone ( label , dateFormat )
@@ -479,8 +593,8 @@ const SleepAnalyticsCharts = ({
479593 < div className = "text-sm text-muted-foreground p-4" >
480594 { t (
481595 'sleepAnalyticsCharts.sleepDebtDisclaimerPersonalized' ,
482- `*Sleep Debt is calculated based on your personalized sleep need of {{hours}}h .` ,
483- { hours : personalizedSleepNeed }
596+ `*Sleep Debt is calculated based on your personalized sleep need of {{time}} .` ,
597+ { time : formatSecondsToHHMM ( personalizedSleepNeed * 3600 ) }
484598 ) }
485599 </ div >
486600 </ Card >
@@ -528,7 +642,7 @@ const SleepAnalyticsCharts = ({
528642 tick = { { fill : tickColor } }
529643 />
530644 < YAxis
531- domain = { [ 0 , 100 ] }
645+ domain = { [ 'auto' , 100 ] }
532646 tickFormatter = { ( value ) => `${ value . toFixed ( 0 ) } %` }
533647 stroke = { tickColor }
534648 tick = { { fill : tickColor } }
@@ -537,6 +651,13 @@ const SleepAnalyticsCharts = ({
537651 labelFormatter = { ( label ) =>
538652 formatDateInUserTimezone ( label , dateFormat )
539653 }
654+ formatter = { ( value : number | undefined ) => [
655+ `${ ( value || 0 ) . toFixed ( 1 ) } %` ,
656+ t (
657+ 'sleepAnalyticsCharts.sleepEfficiency' ,
658+ 'Sleep Efficiency'
659+ ) ,
660+ ] }
540661 contentStyle = { {
541662 backgroundColor : tooltipBackgroundColor ,
542663 borderColor : tooltipBorderColor ,
0 commit comments