From 3e8a2092cd26d3779ce4cfa13553eb38701473ee Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 27 May 2026 15:18:29 +0200 Subject: [PATCH] Drying out --- PLAYGROUND_PLAN.md | 23 ---------- backend/app/routers/sessions.py | 4 +- frontend/src/components/stats/SessionList.tsx | 34 ++++----------- frontend/src/components/stats/WakeupPlot.tsx | 13 +----- .../src/components/tracker/TimeAdjuster.tsx | 14 +----- frontend/src/utils/time.ts | 39 +++++++++++++++++ frontend/tests/unit/time.test.ts | 25 ++++++++++- plan.md | 43 ++++++------------- 8 files changed, 89 insertions(+), 106 deletions(-) delete mode 100644 PLAYGROUND_PLAN.md 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`