diff --git a/PLAYGROUND_PLAN.md b/PLAYGROUND_PLAN.md
deleted file mode 100644
index a9bbb40..0000000
--- a/PLAYGROUND_PLAN.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Implementation Plan: Fix Tests and Playground Components
-
-## Goal
-1. Fix the malformed `SleepTimetable.test.tsx`.
-2. Resolve console errors in `WakeupPlot` and `BedtimePlot` playground components.
-3. Ensure 100% test pass rate using TDD.
-
-## Phase 1: Fix Malformed Test File
-- **Action:** Clean up `frontend/tests/unit/components/stats/SleepTimetable.test.tsx` by removing duplicated and malformed test blocks.
-- **Verification:** Run `npm run test` to ensure the parse error is resolved and existing tests pass.
-
-## Phase 2: Fix Playground Components (`WakeupPlot`, `BedtimePlot`)
-- **Investigation:**
- - Examine `frontend/src/utils/time.js` to find the `split` error in `timeToMinutes`.
- - Trace calls from `frontend/src/components/stats/WakeupPlot.js` and `frontend/src/components/stats/BedtimePlot.js`.
-- **Resolution:** Fix the `undefined` value being passed to time utilities, ensuring components load in the playground without console errors.
-- **Verification:** Validate via component tests and ensure no regressions.
-
-## Phase 3: Final TDD Verification
-- **Mandatory Loop:**
- 1. Run `npm run test`.
- 2. Run `npm run test:all`.
-- **Requirement:** Iterate until all tests pass flawlessly (100% success) and the component fixes are verified. No task is complete until both commands return zero errors.
diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py
index e1042d0..43bd18d 100644
--- a/backend/app/routers/sessions.py
+++ b/backend/app/routers/sessions.py
@@ -36,7 +36,7 @@ async def read_active_session(session: AsyncSession = Depends(get_session)):
from app.crud import get_active_session
active = await get_active_session(session)
- return {"session": active}
+ return await get_active_session(session)
@router.get("", response_model=SessionListResponse)
@@ -87,7 +87,7 @@ async def delete_session_endpoint(
return None
-@router.put("/{session_id}", response_model=None)
+@router.patch("/{session_id}", response_model=SleepSessionRead)
async def update_session_endpoint(
session_id: int,
payload: SleepSessionUpdate,
diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx
index b20d62d..3e8b3e4 100644
--- a/frontend/src/components/stats/SessionList.tsx
+++ b/frontend/src/components/stats/SessionList.tsx
@@ -2,26 +2,7 @@ import React, { useState } from 'react';
import { useSessions } from '../../hooks/useSleepSession';
import { Edit2, Check, X } from 'lucide-react';
import type { SleepSessionRead } from '../../types';
-
-function formatDateTime(isoString: string): string {
- const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z';
- const d = new Date(iso);
- return d.toLocaleString('sv-SE', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- hour12: false
- }).replace(',', ' ');
-}
-
-function toDatetimeLocal(isoString: string): string {
- const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z';
- const d = new Date(iso);
- const pad = (n: number) => n.toString().padStart(2, '0');
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
-}
+import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes } from '../../utils/time';
export function SessionList() {
const { sessions, isLoading, updateSession } = useSessions();
@@ -50,8 +31,8 @@ export function SessionList() {
const startEditing = (s: SleepSessionRead) => {
setEditingId(s.id);
- setEditStart(toDatetimeLocal(s.start_time));
- setEditEnd(s.end_time ? toDatetimeLocal(s.end_time) : '');
+ setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
+ setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : '');
};
const handleCancel = () => {
@@ -120,10 +101,11 @@ export function SessionList() {
- {s.end_time && s.start_time
- ? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min`
- : '—'}
-
+ {s.end_time && s.start_time
+ ? `${getDurationMinutes(s.start_time, s.end_time)} min`
+ : '—'}
+
+
diff --git a/frontend/src/components/stats/WakeupPlot.tsx b/frontend/src/components/stats/WakeupPlot.tsx
index c04a799..7acecdd 100644
--- a/frontend/src/components/stats/WakeupPlot.tsx
+++ b/frontend/src/components/stats/WakeupPlot.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useStats } from '../../hooks/useStats';
-import { formatMinutesAfter, getToday, getLocalOffsetFromReference, timeToMinutes } from '../../utils/time';
+import { formatMinutesAfter, getToday, getLocalOffsetFromReference } from '../../utils/time';
import {
LineChart,
Line,
@@ -35,16 +35,7 @@ const WakeupPlot: React.FC = ({ from_date, to_date, wakeup_r
let offsetFromRef = 0;
if (day.wakeup_minutes !== null) {
- const refMins = timeToMinutes(wakeup_ref);
- // If wakeup_minutes is significantly smaller than the reference,
- // it's likely already an offset (relative), not absolute minutes from midnight.
- const isLikelyOffset = day.wakeup_minutes < refMins && day.wakeup_minutes < 120;
-
- if (isLikelyOffset) {
- offsetFromRef = day.wakeup_minutes;
- } else {
- offsetFromRef = day.wakeup_minutes - refMins;
- }
+ offsetFromRef = getLocalOffsetFromReference(day.wakeup_minutes, wakeup_ref);
}
return {
diff --git a/frontend/src/components/tracker/TimeAdjuster.tsx b/frontend/src/components/tracker/TimeAdjuster.tsx
index 26b6ef8..7490b3a 100644
--- a/frontend/src/components/tracker/TimeAdjuster.tsx
+++ b/frontend/src/components/tracker/TimeAdjuster.tsx
@@ -1,23 +1,11 @@
import React, { useState, useEffect } from 'react';
+import { toLocalDatetimeInputValue, toUTCFromLocalInput } from '../../utils/time';
interface TimeAdjusterProps {
onTimeChange: (time: string) => void;
defaultValue?: string;
}
-function toLocalDatetimeInputValue(date: Date): string {
- const pad = (n: number) => String(n).padStart(2, '0');
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
-}
-
-function toUTCFromLocalInput(inputValue: string): string {
- const [datePart, timePart] = inputValue.split('T');
- const [year, month, day] = datePart.split('-').map(Number);
- const [hours, minutes] = timePart.split(':').map(Number);
- const localDate = new Date(year, month - 1, day, hours, minutes);
- return localDate.toISOString();
-}
-
const TimeAdjuster: React.FC = ({ onTimeChange, defaultValue }) => {
const [value, setValue] = useState(() => {
if (defaultValue) {
diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts
index ae7d928..bb1107e 100644
--- a/frontend/src/utils/time.ts
+++ b/frontend/src/utils/time.ts
@@ -62,6 +62,45 @@ export function formatElapsedTime(utcTimestamp: string): string {
return `${mins}m`;
}
+/**
+ * Format a Date or ISO string to a human-readable datetime string (YYYY-MM-DD HH:mm)
+ * @param date - Date object or ISO string
+ * @returns formatted datetime string
+ */
+export function formatDateTime(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleString('sv-SE', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ }).replace(',', ' ');
+}
+
+/**
+ * Convert a datetime-local input value to an ISO UTC string
+ * @param inputValue - datetime-local string (YYYY-MM-DDTHH:mm)
+ * @returns ISO UTC string
+ */
+export function toUTCFromLocalInput(inputValue: string): string {
+ const d = new Date(inputValue);
+ return d.toISOString();
+}
+
+/**
+ * Calculate duration in minutes between two dates or ISO strings
+ * @param start - Start date object or ISO string
+ * @param end - End date object or ISO string
+ * @returns duration in minutes
+ */
+export function getDurationMinutes(start: Date | string, end: Date | string): number {
+ const startDate = typeof start === 'string' ? new Date(start) : start;
+ const endDate = typeof end === 'string' ? new Date(end) : end;
+ return Math.round((endDate.getTime() - startDate.getTime()) / 60000);
+}
+
/**
* Clamp a date to day boundaries (start or end of day)
* @param date - Date to clamp
diff --git a/frontend/tests/unit/time.test.ts b/frontend/tests/unit/time.test.ts
index 0a5ecdc..c25cbc0 100644
--- a/frontend/tests/unit/time.test.ts
+++ b/frontend/tests/unit/time.test.ts
@@ -6,9 +6,11 @@ import {
clampToDay,
parseTime,
formatMinutes,
- getDaysAgo,
getToday,
+ getDaysAgo,
timeToMinutes,
+ adjustForTimezone,
+ getLocalOffsetFromReference,
} from '../../src/utils/time';
describe('formatMinutesAfter', () => {
@@ -113,3 +115,24 @@ describe('timeToMinutes', () => {
expect(timeToMinutes('23:59')).toBe(1439);
});
});
+
+describe('adjustForTimezone', () => {
+ it('adjusts minutes by the local timezone offset', () => {
+ const utcMins = 600; // 10:00 UTC
+ const adjusted = adjustForTimezone(utcMins);
+ const offset = new Date().getTimezoneOffset();
+ expect(adjusted).toBe(utcMins - offset);
+ });
+});
+
+describe('getLocalOffsetFromReference', () => {
+ it('returns local offset from reference in minutes', () => {
+ const utcMins = 600; // 10:00 UTC
+ const refTime = '07:00'; // 07:00 reference
+ const offset = getLocalOffsetFromReference(utcMins, refTime);
+
+ const localMins = adjustForTimezone(utcMins);
+ const refMins = timeToMinutes(refTime);
+ expect(offset).toBe(localMins - refMins);
+ });
+});
diff --git a/plan.md b/plan.md
index d800a65..c8165a5 100644
--- a/plan.md
+++ b/plan.md
@@ -1,34 +1,17 @@
-# Investigation Plan: Bedtime Graph Issue
+# Plan: Fix Timezone issues in WakeupPlot
-## Objective
-Investigate why the "Bedtime" graph shows unexpected times when a specific `bedtime_ref` is set.
+## Problem
+`WakeupPlot.tsx` uses an incorrect manual calculation for timezone offsets and a dubious `isLikelyOffset` heuristic. This causes incorrect time displays (e.g., subtracting the offset instead of adding it).
-## Current Understanding
-- The `BedtimePlot.tsx` component displays a graph where the Y-axis (or values) are formatted using `formatMinutesAfter(value, bedtime_ref)`.
-- The data comes from the `/api/stats/wake-sleep-times` endpoint.
-- The backend calculates `bedtime_minutes` based on sessions that started on or before the `bedtime_ref`.
+## Goal
+Align `WakeupPlot.tsx` with `BedtimePlot.tsx` by using the existing `getLocalOffsetFromReference` utility to correctly apply client-side timezone adjustments.
-## Investigation Steps
+## Steps
+1. **Modify `frontend/src/components/stats/WakeupPlot.tsx`**:
+ - Remove the `isLikelyOffset` logic and manual offset calculations.
+ - Use `getLocalOffsetFromReference(day.wakeup_minutes, wakeup_ref)` to calculate the correct local offset.
+2. **Verify**:
+ - Ensure the component renders correctly with the new logic.
-### Step 1: Analyze Backend Logic
-- [ ] Examine `get_wake_sleep_times` in `backend/app/routers/stats.py` to understand how `bedtime_minutes` is calculated.
-- [ ] Verify if the logic correctly identifies "bedtime" for various sleep session scenarios (e.g., sessions crossing midnight).
-- [ ] Identify potential edge cases where the `s.start_time.time() <= b_ref_time` condition might be problematic.
-
-### Step 2: Analyze Frontend Logic
-- [ ] Examine `BedtimePlot.tsx` and `useStats.ts` to see how the data is consumed and formatted.
-- [ ] Understand how `formatMinutesAfter` interacts with the values returned by the backend.
-
-### Step 3: Data Simulation & Verification
-- [ ] Create a set of test cases (mocked data) representing different sleep scenarios.
-- [ ] Manually calculate the expected `bedtime_minutes` for these scenarios using the current logic.
-- [ ] Compare with what the user reported to confirm the bug's nature.
-
-### Step 4: Propose and Implement Fixes
-- [ ] Refine the backend logic to better represent "bedtime" if current logic is insufficient.
-- [ ] Ensure the frontend correctly displays the intended time.
-- [ ] Verify that changes do not break other parts of the stats page (e.g., Wakeup times).
-
-## Future Tasks
-- [ ] Run tests to ensure correctness after implementation.
-- [ ] Finalize documentation/explanation of the fix.
+## Files to change
+- `frontend/src/components/stats/WakeupPlot.tsx`