Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion SparkyFitnessFrontend/src/constants/healthDataImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ export const HEALTH_IMPORT_CATEGORIES: HealthImportCategoryConfig[] = [
description:
'One sleep session per row. Provide bedtime/wake_time as ISO timestamps. ' +
'The deep/light/rem/awake columns are aggregate seconds per stage. For a ' +
'full stage timeline, optionally put a JSON array in stage_events.',
'full stage timeline, optionally put a JSON array in stage_events. ' +
'Optional record_timezone (IANA name, e.g. America/New_York) and/or ' +
'record_utc_offset_minutes columns pin display to the zone the sleep ' +
'was recorded in; without them times display in your profile timezone.',
requiredHeaders: [
'date',
'bedtime',
Expand Down
43 changes: 39 additions & 4 deletions SparkyFitnessFrontend/src/pages/CheckIn/SleepEntrySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { usePreferences } from '@/contexts/PreferencesContext';
import { debug, info, warn, error } from '@/utils/logging';
import { Trash2, Edit, Save, X } from 'lucide-react';
import { toast } from '@/hooks/use-toast';
import { formatSecondsToHHMM } from '@/utils/timeFormatters';
import {
formatSecondsToHHMM,
formatTimeInZone,
sleepEntryZone,
} from '@/utils/timeFormatters';
import {
Tooltip,
TooltipContent,
Expand All @@ -36,7 +40,7 @@ const SleepEntrySection: React.FC<SleepEntrySectionProps> = ({
}) => {
const { t } = useTranslation();
const { activeUserId } = useActiveUser();
const { formatDateInUserTimezone, formatTime, loggingLevel } =
const { formatDateInUserTimezone, timeFormat, timezone, loggingLevel } =
usePreferences();

const [sleepSessions, setSleepSessions] = useState<
Expand Down Expand Up @@ -115,6 +119,10 @@ const SleepEntrySection: React.FC<SleepEntrySectionProps> = ({
wake_time: parsedWakeTime.toISOString(),
duration_in_seconds: Number(durationInSeconds) || 0,
source: 'manual',
// Typed wall-clock times are interpreted by the browser clock, so
// the browser zone is the recording zone; stamping it makes
// read-back exact regardless of the profile timezone.
record_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
stage_events: session.stageEvents
.filter((event) => event)
.map((event) => ({
Expand Down Expand Up @@ -197,13 +205,32 @@ const SleepEntrySection: React.FC<SleepEntrySectionProps> = ({
const durationInSeconds =
differenceInMinutes(parseISO(newWakeTime), parseISO(newBedtime)) * 60;

// A bed/wake edit is typed against the browser clock, so it relabels the
// recording zone; a stage-only or no-op save must not — omitting the key
// leaves an imported entry's zone metadata untouched server-side.
const originalEntry = sleepEntries.find((e) => e.id === entryId);
const timesChanged =
originalEntry != null &&
(new Date(newBedtime).getTime() !==
new Date(originalEntry.bedtime).getTime() ||
new Date(newWakeTime).getTime() !==
new Date(originalEntry.wake_time).getTime());

await updateSleepEntry({
id: entryId,
data: {
stage_events: eventsForApi,
bedtime: newBedtime,
wake_time: newWakeTime,
duration_in_seconds: durationInSeconds,
...(timesChanged
? {
record_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
// Clear any stale offset so the relabel doesn't leave
// contradictory zone metadata from the original import.
record_utc_offset_minutes: null,
}
: {}),
},
});

Expand Down Expand Up @@ -490,8 +517,16 @@ const SleepEntrySection: React.FC<SleepEntrySectionProps> = ({
}}
// Pass basic sleep entry details to SleepTimelineEditor for display
entryDetails={{
bedtime: formatTime(entry.bedtime),
wakeTime: formatTime(entry.wake_time),
bedtime: formatTimeInZone(
entry.bedtime,
sleepEntryZone(entry, timezone),
timeFormat
),
wakeTime: formatTimeInZone(
entry.wake_time,
sleepEntryZone(entry, timezone),
timeFormat
),
duration: formatSecondsToHHMM(
entry.duration_in_seconds
),
Expand Down
42 changes: 29 additions & 13 deletions SparkyFitnessFrontend/src/pages/Reports/SleepAnalyticsCharts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
type SleepChartData,
type SleepEntry,
} from '@/types';
import { formatSecondsToHHMM } from '@/utils/timeFormatters';
import { formatSecondsToHHMM, sleepEntryZone } from '@/utils/timeFormatters';
import { instantHourMinuteInZone } from '@workspace/shared';
import {
DEBT_ZONE_COLOR,
SURPLUS_ZONE_COLOR,
Expand Down Expand Up @@ -90,7 +91,7 @@ const SleepAnalyticsCharts = ({
heartRateData,
latestSleepEntry,
}: SleepAnalyticsChartsProps) => {
const { formatDateInUserTimezone, dateFormat } = usePreferences();
const { formatDateInUserTimezone, dateFormat, timezone } = usePreferences();
const { resolvedTheme } = useTheme();
const { t } = useTranslation();
const { data: sleepDebtData } = useSleepDebtQuery();
Expand Down Expand Up @@ -121,19 +122,34 @@ const SleepAnalyticsCharts = ({
(data.stagePercentages.light || 0) +
(data.stagePercentages.awake || 0);

const bedtimeDate = new Date(data.earliestBedtime || 0);
let bedtimeHours = bedtimeDate.getHours() + bedtimeDate.getMinutes() / 60;

// CROSS-MIDNIGHT FIX:
// If bedtime is between 00:00 and 12:00 (midday), treat it as 24:00+
// This keeps the trend line continuous (e.g. 23:00 -> 01:00 becomes 23:00 -> 25:00)
if (bedtimeHours >= 0 && bedtimeHours < 12) {
bedtimeHours += 24;
// Consistency hours derive from the day's recording zone (profile
// timezone when absent) so travel weeks chart the wall clock the user
// actually slept by. Missing instants stay null so recharts skips the
// point instead of charting the 1970 epoch.
const zone = sleepEntryZone(data, timezone);
let bedtimeHours: number | null = null;
if (data.earliestBedtime) {
const { hour, minute } = instantHourMinuteInZone(
data.earliestBedtime,
zone
);
bedtimeHours = hour + minute / 60;
// CROSS-MIDNIGHT FIX:
// If bedtime is between 00:00 and 12:00 (midday), treat it as 24:00+
// This keeps the trend line continuous (e.g. 23:00 -> 01:00 becomes 23:00 -> 25:00)
if (bedtimeHours >= 0 && bedtimeHours < 12) {
bedtimeHours += 24;
}
}

const wakeTimeDate = new Date(data.latestWakeTime || 0);
const wakeTimeHours =
wakeTimeDate.getHours() + wakeTimeDate.getMinutes() / 60;
let wakeTimeHours: number | null = null;
if (data.latestWakeTime) {
const { hour, minute } = instantHourMinuteInZone(
data.latestWakeTime,
zone
);
wakeTimeHours = hour + minute / 60;
}

return {
date: data.date,
Expand Down
35 changes: 29 additions & 6 deletions SparkyFitnessFrontend/src/pages/Reports/SleepAnalyticsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { usePreferences } from '@/contexts/PreferencesContext';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { debug } from '@/utils/logging';
import { getUserLoggingLevel } from '@/utils/userPreferences';
import { formatSecondsToHHMM } from '@/utils/timeFormatters';
import {
formatSecondsToHHMM,
formatTimeInZone,
sleepEntryZone,
} from '@/utils/timeFormatters';
import {
HIGH_DEBT_THRESHOLD_HOURS,
GOOD_SLEEP_SCORE_THRESHOLD,
Expand All @@ -39,7 +43,8 @@ const SleepAnalyticsTable = ({
'SleepAnalyticsTable received combinedSleepData:',
combinedSleepData
);
const { formatDateInUserTimezone, dateFormat, formatTime } = usePreferences();
const { formatDateInUserTimezone, dateFormat, timeFormat, timezone } =
usePreferences();
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(
new Set()
);
Expand Down Expand Up @@ -147,6 +152,11 @@ const SleepAnalyticsTable = ({

const insight = t(insightKey, insightDefault);

// Aggregated rows carry the day zone (earliest-bedtime
// session's recording zone), so stage labels agree with the
// hypnogram axis and header dates.
const zone = sleepEntryZone(sleepEntry, timezone);

const aggregatedStages = sleepEntry.stage_events?.reduce(
(acc, event) => {
acc[event.stage_type] =
Expand Down Expand Up @@ -179,8 +189,12 @@ const SleepAnalyticsTable = ({
dateFormat
)}
</TableCell>
<TableCell>{formatTime(sleepEntry.bedtime)}</TableCell>
<TableCell>{formatTime(sleepEntry.wake_time)}</TableCell>
<TableCell>
{formatTimeInZone(sleepEntry.bedtime, zone, timeFormat)}
</TableCell>
<TableCell>
{formatTimeInZone(sleepEntry.wake_time, zone, timeFormat)}
</TableCell>
<TableCell>{totalSleepDuration}</TableCell>
<TableCell>{timeAsleep}</TableCell>
<TableCell>
Expand Down Expand Up @@ -283,8 +297,17 @@ const SleepAnalyticsTable = ({
)}
</div>
<div className="text-xs opacity-80">
{formatTime(event.start_time)} -{' '}
{formatTime(event.end_time)}
{formatTimeInZone(
event.start_time,
zone,
timeFormat
)}{' '}
-{' '}
{formatTimeInZone(
event.end_time,
zone,
timeFormat
)}
</div>
</div>
))}
Expand Down
47 changes: 35 additions & 12 deletions SparkyFitnessFrontend/src/pages/Reports/SleepReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import type {
import { useTranslation } from 'react-i18next';
import { toast as sonnerToast } from 'sonner';
import { formatDateToYYYYMMDD } from '@/lib/utils';
import { formatSecondsToHHMM } from '@/utils/timeFormatters';
import {
formatSecondsToHHMM,
formatTimeInZone,
sleepEntryZone,
} from '@/utils/timeFormatters';
import {
HIGH_DEBT_THRESHOLD_HOURS,
GOOD_SLEEP_SCORE_THRESHOLD,
Expand All @@ -28,7 +32,8 @@ interface SleepReportProps {

const SleepReport = ({ startDate, endDate }: SleepReportProps) => {
const { t } = useTranslation();
const { formatDateInUserTimezone, dateFormat, formatTime } = usePreferences();
const { formatDateInUserTimezone, dateFormat, timeFormat, timezone } =
usePreferences();
const { data: sleepEntries = [], isLoading: loadingEntries } =
useSleepEntriesQuery(startDate, endDate);
const { data: sleepDebtData, isLoading: loadingDebt } = useSleepDebtQuery();
Expand Down Expand Up @@ -69,10 +74,11 @@ const SleepReport = ({ startDate, endDate }: SleepReportProps) => {
insight = t('sleepReport.goodSleep', 'Good Sleep');
}

const zone = sleepEntryZone(sleepEntry, timezone);
return [
formatDateInUserTimezone(sleepEntry.entry_date, dateFormat),
formatTime(sleepEntry.bedtime),
formatTime(sleepEntry.wake_time),
formatTimeInZone(sleepEntry.bedtime, zone, timeFormat),
formatTimeInZone(sleepEntry.wake_time, zone, timeFormat),
formatSecondsToHHMM(sleepEntry.duration_in_seconds),
sleepEntry.time_asleep_in_seconds
? formatSecondsToHHMM(sleepEntry.time_asleep_in_seconds)
Expand Down Expand Up @@ -216,6 +222,12 @@ const SleepReport = ({ startDate, endDate }: SleepReportProps) => {
entries.reduce((acc, e) => acc + (e.awake_count || 0), 0) ||
allStageEvents.filter((e) => e.stage_type === 'awake').length,
totalAwakeDuration: aggregatedStages.awake,
// Day zone rule: the earliest-bedtime session's recording zone
// labels the whole day (all stage labels included); a zone-less
// day falls back to the profile timezone, never a sibling
// session's zone.
record_timezone: mainEntry.record_timezone,
record_utc_offset_minutes: mainEntry.record_utc_offset_minutes,
};

const combinedEntry: SleepEntry & { is_aggregated?: boolean } = {
Expand Down Expand Up @@ -248,20 +260,31 @@ const SleepReport = ({ startDate, endDate }: SleepReportProps) => {
};

const processSleepChartData = (): SleepChartData[] => {
const grouped: Record<string, SleepStageEvent[]> = {};
const grouped: Record<string, typeof sleepEntries> = {};
sleepEntries.forEach((entry) => {
const dateKey = entry.entry_date.split('T')[0] as string;
if (!dateKey) return;
if (!grouped[dateKey]) grouped[dateKey] = [];
if (entry.stage_events) {
grouped[dateKey].push(...entry.stage_events.filter((ev) => ev != null));
}
grouped[dateKey].push(entry);
});
return Object.entries(grouped)
.map(([date, segments]) => ({
date,
segments,
}))
.map(([date, entries]) => {
// Same day-zone rule as processSleepData: the earliest-bedtime
// session's recording zone labels the whole day's hypnogram.
const mainEntry = [...entries].sort(
(a, b) =>
new Date(a.bedtime).getTime() - new Date(b.bedtime).getTime()
)[0];
const segments: SleepStageEvent[] = entries.flatMap((entry) =>
(entry.stage_events ?? []).filter((ev) => ev != null)
);
return {
date,
segments,
record_timezone: mainEntry?.record_timezone,
record_utc_offset_minutes: mainEntry?.record_utc_offset_minutes,
};
})
.sort((a, b) => b.date.localeCompare(a.date));
};

Expand Down
13 changes: 8 additions & 5 deletions SparkyFitnessFrontend/src/pages/Reports/SleepStageChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { type SleepChartData, SLEEP_STAGE_COLORS } from '@/types';
import { usePreferences } from '@/contexts/PreferencesContext';
import { formatTimeWithPreference } from '@/utils/timeFormatters';
import { formatTimeInZone, sleepEntryZone } from '@/utils/timeFormatters';
import ZoomableChart from '@/components/ZoomableChart';
import { useTheme } from '@/contexts/ThemeContext';

Expand Down Expand Up @@ -34,7 +34,8 @@ const stageLabels: { [key: string]: string } = {

const SleepStageChart = ({ sleepChartData }: SleepStageChartProps) => {
const { t } = useTranslation();
const { formatDateInUserTimezone, dateFormat, timeFormat } = usePreferences();
const { formatDateInUserTimezone, dateFormat, timeFormat, timezone } =
usePreferences();
const { resolvedTheme } = useTheme();
const [isMounted, setIsMounted] = React.useState(false);

Expand Down Expand Up @@ -231,13 +232,15 @@ const SleepStageChart = ({ sleepChartData }: SleepStageChartProps) => {
);
});

// Vertical grid lines and time labels
// Vertical grid lines and time labels. Axis times render in the day's
// recording zone (profile timezone when absent) so they agree with the
// header date and the analytics table.
const zone = sleepEntryZone(sleepChartData, timezone);
const numTimeLabels = 5; // Number of time labels to display
for (let i = 0; i <= numTimeLabels; i++) {
const timeMs = minTime + (totalDurationMs / numTimeLabels) * i;
const xPos = getX(timeMs);
const date = new Date(timeMs);
const timeString = formatTimeWithPreference(date, timeFormat);
const timeString = formatTimeInZone(timeMs, zone, timeFormat);

gridLines.push(
<line
Expand Down
Loading
Loading