Drying out
This commit is contained in:
@@ -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.
|
|
||||||
@@ -36,7 +36,7 @@ async def read_active_session(session: AsyncSession = Depends(get_session)):
|
|||||||
from app.crud import get_active_session
|
from app.crud import get_active_session
|
||||||
|
|
||||||
active = await get_active_session(session)
|
active = await get_active_session(session)
|
||||||
return {"session": active}
|
return await get_active_session(session)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=SessionListResponse)
|
@router.get("", response_model=SessionListResponse)
|
||||||
@@ -87,7 +87,7 @@ async def delete_session_endpoint(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{session_id}", response_model=None)
|
@router.patch("/{session_id}", response_model=SleepSessionRead)
|
||||||
async def update_session_endpoint(
|
async def update_session_endpoint(
|
||||||
session_id: int,
|
session_id: int,
|
||||||
payload: SleepSessionUpdate,
|
payload: SleepSessionUpdate,
|
||||||
|
|||||||
@@ -2,26 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { useSessions } from '../../hooks/useSleepSession';
|
import { useSessions } from '../../hooks/useSleepSession';
|
||||||
import { Edit2, Check, X } from 'lucide-react';
|
import { Edit2, Check, X } from 'lucide-react';
|
||||||
import type { SleepSessionRead } from '../../types';
|
import type { SleepSessionRead } from '../../types';
|
||||||
|
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes } from '../../utils/time';
|
||||||
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())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { sessions, isLoading, updateSession } = useSessions();
|
const { sessions, isLoading, updateSession } = useSessions();
|
||||||
@@ -50,8 +31,8 @@ export function SessionList() {
|
|||||||
|
|
||||||
const startEditing = (s: SleepSessionRead) => {
|
const startEditing = (s: SleepSessionRead) => {
|
||||||
setEditingId(s.id);
|
setEditingId(s.id);
|
||||||
setEditStart(toDatetimeLocal(s.start_time));
|
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
|
||||||
setEditEnd(s.end_time ? toDatetimeLocal(s.end_time) : '');
|
setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -121,9 +102,10 @@ export function SessionList() {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-blue-600 font-mono text-sm">
|
<span className="text-blue-600 font-mono text-sm">
|
||||||
{s.end_time && s.start_time
|
{s.end_time && s.start_time
|
||||||
? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min`
|
? `${getDurationMinutes(s.start_time, s.end_time)} min`
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Edit2 size={14} className="text-gray-400" />
|
<Edit2 size={14} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useStats } from '../../hooks/useStats';
|
import { useStats } from '../../hooks/useStats';
|
||||||
import { formatMinutesAfter, getToday, getLocalOffsetFromReference, timeToMinutes } from '../../utils/time';
|
import { formatMinutesAfter, getToday, getLocalOffsetFromReference } from '../../utils/time';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -35,16 +35,7 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
|||||||
|
|
||||||
let offsetFromRef = 0;
|
let offsetFromRef = 0;
|
||||||
if (day.wakeup_minutes !== null) {
|
if (day.wakeup_minutes !== null) {
|
||||||
const refMins = timeToMinutes(wakeup_ref);
|
offsetFromRef = getLocalOffsetFromReference(day.wakeup_minutes, 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { toLocalDatetimeInputValue, toUTCFromLocalInput } from '../../utils/time';
|
||||||
|
|
||||||
interface TimeAdjusterProps {
|
interface TimeAdjusterProps {
|
||||||
onTimeChange: (time: string) => void;
|
onTimeChange: (time: string) => void;
|
||||||
defaultValue?: string;
|
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 TimeAdjuster: React.FC<TimeAdjusterProps> = ({ onTimeChange, defaultValue }) => {
|
||||||
const [value, setValue] = useState(() => {
|
const [value, setValue] = useState(() => {
|
||||||
if (defaultValue) {
|
if (defaultValue) {
|
||||||
|
|||||||
@@ -62,6 +62,45 @@ export function formatElapsedTime(utcTimestamp: string): string {
|
|||||||
return `${mins}m`;
|
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)
|
* Clamp a date to day boundaries (start or end of day)
|
||||||
* @param date - Date to clamp
|
* @param date - Date to clamp
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
clampToDay,
|
clampToDay,
|
||||||
parseTime,
|
parseTime,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
getDaysAgo,
|
|
||||||
getToday,
|
getToday,
|
||||||
|
getDaysAgo,
|
||||||
timeToMinutes,
|
timeToMinutes,
|
||||||
|
adjustForTimezone,
|
||||||
|
getLocalOffsetFromReference,
|
||||||
} from '../../src/utils/time';
|
} from '../../src/utils/time';
|
||||||
|
|
||||||
describe('formatMinutesAfter', () => {
|
describe('formatMinutesAfter', () => {
|
||||||
@@ -113,3 +115,24 @@ describe('timeToMinutes', () => {
|
|||||||
expect(timeToMinutes('23:59')).toBe(1439);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,34 +1,17 @@
|
|||||||
# Investigation Plan: Bedtime Graph Issue
|
# Plan: Fix Timezone issues in WakeupPlot
|
||||||
|
|
||||||
## Objective
|
## Problem
|
||||||
Investigate why the "Bedtime" graph shows unexpected times when a specific `bedtime_ref` is set.
|
`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
|
## Goal
|
||||||
- The `BedtimePlot.tsx` component displays a graph where the Y-axis (or values) are formatted using `formatMinutesAfter(value, bedtime_ref)`.
|
Align `WakeupPlot.tsx` with `BedtimePlot.tsx` by using the existing `getLocalOffsetFromReference` utility to correctly apply client-side timezone adjustments.
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## 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
|
## Files to change
|
||||||
- [ ] Examine `get_wake_sleep_times` in `backend/app/routers/stats.py` to understand how `bedtime_minutes` is calculated.
|
- `frontend/src/components/stats/WakeupPlot.tsx`
|
||||||
- [ ] 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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user