Drying out

This commit is contained in:
2026-05-27 15:18:29 +02:00
parent 7cef6da5f2
commit 3e8a2092cd
8 changed files with 89 additions and 106 deletions
-23
View File
@@ -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.
+2 -2
View File
@@ -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,
+8 -26
View File
@@ -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() {
</div>
<div className="flex items-center space-x-2">
<span className="text-blue-600 font-mono text-sm">
{s.end_time && s.start_time
? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min`
: '—'}
</span>
{s.end_time && s.start_time
? `${getDurationMinutes(s.start_time, s.end_time)} min`
: '—'}
</span>
<Edit2 size={14} className="text-gray-400" />
</div>
</div>
+2 -11
View File
@@ -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<WakeupBedtimeProps> = ({ 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 {
@@ -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<TimeAdjusterProps> = ({ onTimeChange, defaultValue }) => {
const [value, setValue] = useState(() => {
if (defaultValue) {
+39
View File
@@ -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
+24 -1
View File
@@ -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);
});
});
+13 -30
View File
@@ -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`