Skip to content

Commit 94f12a1

Browse files
authored
Merge pull request #854 from CodeWithCJ/dev
feat(sleep-reports): align sleep calculations, fix charts, and improve data accuracy
2 parents 94dbf12 + 306d2c6 commit 94f12a1

12 files changed

Lines changed: 534 additions & 261 deletions

File tree

SparkyFitnessFrontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sparkyfitnessfrontend",
33
"private": true,
4-
"version": "0.16.4.9",
4+
"version": "0.16.5.0",
55
"homepage": "https://github.com/CodeWithCJ/SparkyFitness",
66
"type": "module",
77
"scripts": {

SparkyFitnessFrontend/public/locales/en/translation.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1819,10 +1819,12 @@
18191819
"score": "Score",
18201820
"efficiency": "Efficiency",
18211821
"debt": "Debt",
1822+
"impact": "Impact",
18221823
"awakePeriods": "Awake Periods",
18231824
"insight": "Insight",
18241825
"source": "Source",
18251826
"goodSleep": "Good Sleep",
1827+
"highDebt": "High Debt",
18261828
"needsImprovement": "Needs Improvement",
18271829
"sleepStagesSummary": "Sleep Stages Summary:",
18281830
"sleepStageTimeline": "Sleep Stage Timeline:",
@@ -1840,11 +1842,13 @@
18401842
"csvHeadersTimeAsleepHours": "Time Asleep (h)",
18411843
"csvHeadersScore": "Score",
18421844
"csvHeadersEfficiencyPercentage": "Efficiency (%)",
1843-
"csvHeadersDebtHours": "Debt (h)",
1845+
"csvHeadersDebt": "Debt",
1846+
"csvHeadersWeight": "Weight (%)",
18441847
"csvHeadersAwakePeriods": "Awake Periods",
18451848
"csvHeadersSource": "Source",
18461849
"csvHeadersInsight": "Insight",
18471850
"goodSleep": "Good Sleep",
1851+
"highDebt": "High Debt",
18481852
"needsImprovement": "Needs Improvement",
18491853
"sleepDataExportedSuccessfully": "Sleep data exported successfully.",
18501854
"failedToLoadSleepEntries": "Failed to load sleep entries",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const HIGH_DEBT_THRESHOLD_HOURS = 1.5;
2+
export const GOOD_SLEEP_SCORE_THRESHOLD = 70;
3+
4+
export const DEBT_ZONE_COLOR = '#ff0000';
5+
export const SURPLUS_ZONE_COLOR = '#00ff00';
6+
export const DEFAULT_HYPNOGRAMS_SHOWN = 3;

SparkyFitnessFrontend/src/pages/Reports/SleepAnalyticsCharts.tsx

Lines changed: 153 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
type SleepEntry,
1111
} from '@/types';
1212
import { formatSecondsToHHMM } from '@/utils/timeFormatters';
13+
import {
14+
DEBT_ZONE_COLOR,
15+
SURPLUS_ZONE_COLOR,
16+
DEFAULT_HYPNOGRAMS_SHOWN,
17+
} from '@/constants/sleep';
1318
import {
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-
7984
const 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

Comments
 (0)