Improvements todo created and small bugfixes

This commit is contained in:
2026-05-25 12:56:08 +02:00
parent 499f68b9ee
commit 648d376a12
10 changed files with 807 additions and 72 deletions
+56
View File
@@ -0,0 +1,56 @@
# Component Playground Implementation Plan
This document outlines the plan to implement a `/playground` route in the `babymonitor` frontend, allowing for isolated component development and testing with realistic mock data.
## Goal
Create a route `/playground` that provides a dropdown menu to select any UI component from the codebase. When selected, the component will be rendered in isolation with its required props and necessary TanStack Query cache data pre-populated with realistic mock values.
## Target Components
The playground will cover components from both the `tracker` and `stats` directories:
### Tracker Components
- `StatusBanner`
- `TimeAdjuster`
- `SleepButton`
### Stats Components
- `DailySummary`
- `WakeupPlot`
- `BedtimePlot`
- `SleepTimetable`
- `SessionList`
- `IntervalPicker`
- `SettingsPopover`
## Technical Approach
### 1. Component Registry Definition
I will define a registry within `Playground.tsx` that maps component names to:
- The React Component itself.
- A function to generate required `props`.
- An array of TanStack Query `queryKeys` that need to be populated in the cache.
### 2. Mock Data Injection Strategy
To avoid modifying existing hooks (like `useSessions` or `useStats`), I will leverage the TanStack `QueryClient`.
- When a component is selected, a `useEffect` will trigger.
- This effect will use `queryClient.setQueryData(key, mockData)` for every key associated with that component in the registry.
- This ensures that when the component calls its respective hook, it finds the mock data already in the cache.
### 3. Implementation Details
- **File Creation**: `frontend/src/components/Playground.tsx` will house the main logic.
- **Routing**: `frontend/src/App.tsx` will be updated to include the new `<Route path="/playground" element={<Playground />} />`.
- **Dependencies**: The playground will wrap its content in a `QueryClientProvider` and `ThemeProvider` (if applicable) to mimic the production environment.
- **Styling**: Tailwind CSS will be used for the dropdown and layout to maintain consistency with the existing design system.
## Mock Data Schema
I will implement realistic data based on the types defined in `frontend/src/types/index.ts`:
- **Sessions**: A list of `SleepSessionRead` objects including `start_time`, `end_time`, and varied durations.
- **Stats**: 7 days of `DailySummaryDay`, `TimetableSlot`, and `WakeupBedtimeDay` data to populate charts and grids.
- **Settings**: Default values for `dayStart`, `dayEnd`, etc., to satisfy the `useSettings` hook.
## Step-by-Step Execution
1. **Create** `frontend/src/components/Playground.tsx`.
2. **Define** the component registry and mock data generators inside `Playground.tsx`.
3. **Implement** the selection logic and cache injection mechanism in `Playground.tsx`.
4. **Update** `frontend/src/App.tsx` to register the route.
5. **Verify** (via manual instruction) that components render correctly without backend connectivity.
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useStats } from '../../hooks/useStats';
import { formatMinutesAfter, getToday } from '../../utils/time';
import { formatMinutesAfter, getToday, getLocalOffsetFromReference } from '../../utils/time';
import {
LineChart,
Line,
@@ -32,12 +32,13 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
const daysAgo = Math.floor(
(new Date(getToday()).getTime() - new Date(day.date).getTime()) / (1000 * 60 * 60 * 24)
);
const offsetFromRef = day.bedtime_minutes !== null ? getLocalOffsetFromReference(day.bedtime_minutes, bedtime_ref) : 0;
return {
date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`,
minutes: day.bedtime_minutes ?? 0,
minutes: offsetFromRef,
time:
day.bedtime_minutes !== null
? formatMinutesAfter(day.bedtime_minutes, bedtime_ref)
? formatMinutesAfter(offsetFromRef, bedtime_ref)
: 'N/A',
};
}).reverse();
@@ -62,69 +62,70 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
};
const currentMins = new Date().getHours() * 60 + new Date().getMinutes();
const rowHeightPx = 32; // h-8 is 2rem, which is 32px in standard Tailwind config
return (
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Timetable</h3>
<div className="min-w-[600px] flex flex-col gap-px">
{/* Header Row */}
<div
className="grid"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
<div className="h-8" /> {/* Empty corner cell */}
{days.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8">
{getDayName(day)}
</div>
))}
</div>
return (
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Timetable</h3>
<div className="min-w-[600px] flex flex-col gap-px">
{/* Header Row */}
<div
className="grid"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
<div className="h-8" /> {/* Empty corner cell */}
{days.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8">
{getDayName(day)}
</div>
))}
</div>
{/* Time Grid Container */}
<div className="relative">
<div
className="grid"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
{/* Time Rows */}
{timeSteps.map((step) => (
<React.Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8">
{step % 60 === 0 ? formatTimeLabel(step) : ''}
</div>
{/* Grid Cells */}
{days.map((day) => {
const sleeping = isSleepingInInterval(day, step, step + 60);
return (
<div
key={`${day}-${step}`}
className={`h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
/>
);
})}
</React.Fragment>
))}
{/* Time Grid Container */}
<div className="relative">
<div
className="grid"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
{/* Time Rows */}
{timeSteps.map((step) => (
<React.Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8">
{step % 60 === 0 ? formatTimeLabel(step) : ''}
</div>
{/* Grid Cells */}
{days.map((day) => {
const sleeping = isSleepingInInterval(day, step, step + 60);
return (
<div
key={`${day}-${step}`}
className={`h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
/>
);
})}
</React.Fragment>
))}
{/* Red Marker Line */}
<div
className="absolute left-[4rem] right-0 h-0.5 bg-red-500 pointer-events-none z-10"
style={{
top: `${(currentMins / 1440) * 100}%`,
transform: 'translateY(-50%)'
}}
/>
</div>
</div>
</div>
</div>
);
{/* Red Marker Line */}
<div
className="absolute left-[4rem] right-0 h-0.5 bg-red-500 pointer-events-none z-10"
style={{
top: `${(currentMins / 1440) * (24 * rowHeightPx)}px`,
transform: 'translateY(-50%)'
}}
/>
</div>
</div>
</div>
</div>
);
};
export default Timetable;
+9 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useStats } from '../../hooks/useStats';
import { formatMinutesAfter, getToday } from '../../utils/time';
import { formatMinutesAfter, getToday, getLocalOffsetFromReference } from '../../utils/time';
import {
LineChart,
Line,
@@ -32,12 +32,13 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
const daysAgo = Math.floor(
(new Date(getToday()).getTime() - new Date(day.date).getTime()) / (1000 * 60 * 60 * 24)
);
const offsetFromRef = day.wakeup_minutes !== null ? getLocalOffsetFromReference(day.wakeup_minutes, wakeup_ref) : 0;
return {
date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`,
minutes: day.wakeup_minutes ?? 0,
minutes: offsetFromRef,
time:
day.wakeup_minutes !== null
? formatMinutesAfter(day.wakeup_minutes, wakeup_ref)
? formatMinutesAfter(offsetFromRef, wakeup_ref)
: 'N/A',
};
}).reverse();
@@ -70,11 +71,11 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: any) => [
formatMinutesAfter(value ?? 0, wakeup_ref),
'Wakeup',
] as any}
labelFormatter={(label) => label}
formatter={(value: any) => [
formatMinutesAfter(value ?? 0, wakeup_ref),
'Wakeup',
] as any}
labelFormatter={(label) => label}
/>
<Line
type="monotone"
+21
View File
@@ -22,6 +22,16 @@ export function formatMinutesAfter(minutes: number | null, reference: string = "
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
}
/**
* Adjust minutes by the local timezone offset
* @param utcMinutes - minutes from UTC midnight
* @returns adjusted minutes for local time
*/
export function adjustForTimezone(utcMinutes: number): number {
const offsetInMinutes = new Date().getTimezoneOffset();
return utcMinutes - offsetInMinutes;
}
/**
* Convert a Date to datetime-local input value format (local time)
* @param date - Date object
@@ -111,6 +121,17 @@ export function timeToMinutes(timeStr: string): number {
return hours * 60 + minutes;
}
/**
* Get the offset from a reference time in local minutes
* @param utcMinutesFromMidnight - minutes from UTC midnight
* @param referenceHHMM - reference time as HH:MM string
* @returns local offset from reference in minutes
*/
export function getLocalOffsetFromReference(utcMinutesFromMidnight: number, referenceHHMM: string): number {
const localMinsFromMidnight = adjustForTimezone(utcMinutesFromMidnight);
return localMinsFromMidnight - timeToMinutes(referenceHHMM);
}
/**
* Convert total minutes to a human-readable string (e.g., "1h 30m" or "45m")
* @param totalMinutes - Total number of minutes
+10 -1
View File
@@ -8,6 +8,7 @@ import {
formatMinutes,
getDaysAgo,
getToday,
timeToMinutes,
} from '../../src/utils/time';
describe('formatMinutesAfter', () => {
@@ -103,4 +104,12 @@ describe('getToday', () => {
const today = new Date().toISOString().split('T')[0];
expect(result).toBe(today);
});
});
});
describe('timeToMinutes', () => {
it('converts HH:MM to minutes', () => {
expect(timeToMinutes('07:30')).toBe(450);
expect(timeToMinutes('00:00')).toBe(0);
expect(timeToMinutes('23:59')).toBe(1439);
});
});