Not sure what happened here...

This commit is contained in:
2026-05-27 09:53:44 +02:00
parent 648d376a12
commit 7cef6da5f2
33 changed files with 664 additions and 191 deletions
+1
View File
@@ -316,3 +316,4 @@ pyrightconfig.json
frontend/test-results
frontend/playwright-report
backend/data
.aider*
+23
View File
@@ -0,0 +1,23 @@
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+2 -1
View File
@@ -3,7 +3,8 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import TrackerPage from './components/tracker/TrackerPage';
import StatsPage from './components/stats/StatsPage';
import Navbar from './components/Navbar';
import Playground from './components/Playground';
function App() {
return (_jsx(Router, { children: _jsxs("div", { className: "min-h-screen bg-[#f8fafc] text-slate-900 font-sans antialiased", children: [_jsx(Navbar, {}), _jsx("main", { className: "container mx-auto px-4 sm:px-6 lg:px-8 mt-8", children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(TrackerPage, {}) }), _jsx(Route, { path: "/stats", element: _jsx(StatsPage, {}) })] }) })] }) }));
return (_jsx(Router, { children: _jsxs("div", { className: "min-h-screen bg-[#f8fafc] text-slate-900 font-sans antialiased", children: [_jsx(Navbar, {}), _jsx("main", { className: "container mx-auto px-4 sm:px-6 lg:px-8 mt-8", children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(TrackerPage, {}) }), _jsx(Route, { path: "/stats", element: _jsx(StatsPage, {}) }), _jsx(Route, { path: "/playground", element: _jsx(Playground, {}) })] }) })] }) }));
}
export default App;
+2
View File
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import TrackerPage from './components/tracker/TrackerPage';
import StatsPage from './components/stats/StatsPage';
import Navbar from './components/Navbar';
import Playground from './components/Playground';
function App() {
return (
@@ -13,6 +14,7 @@ function App() {
<Routes>
<Route path="/" element={<TrackerPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/playground" element={<Playground />} />
</Routes>
</main>
</div>
+36 -60
View File
@@ -1,63 +1,56 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
export function localToUTC(localValue) {
return localValue;
return new Date(localValue).toISOString();
}
async function fetchStats(endpoint, from_date, to_date, extraParams = {}) {
const fromDT = new Date(`${from_date}T00:00:00`).toISOString();
const toDT = new Date(`${to_date}T23:59:59`).toISOString();
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, ...extraParams });
const response = await fetch(`${BASE_URL}/api/stats/${endpoint}?${params.toString()}`);
if (!response.ok)
throw new Error(`Failed to fetch ${endpoint}`);
return response.json();
}
export async function fetchDailySummary(from_date, to_date, day_start, day_end) {
return fetchStats('daily-summary', from_date, to_date, { day_start, day_end });
}
export async function fetchTimetable(from_date, to_date, day_start, day_end) {
return fetchStats('timetable', from_date, to_date, { day_start, day_end });
}
export async function fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref) {
return fetchStats('wake-sleep-times', from_date, to_date, { wakeup_ref, bedtime_ref });
}
// Session API functions
export async function fetchActiveSession() {
const response = await fetch(`${BASE_URL}/api/sessions/active`);
if (!response.ok)
throw new Error('Failed to fetch active session');
const data = await response.json();
return data.session;
return null;
return response.json();
}
export async function fetchSessions(from_date, to_date, limit = 200) {
const params = new URLSearchParams();
if (from_date)
params.append('from_date', from_date);
if (to_date)
params.append('to_date', to_date);
if (limit)
params.append('limit', limit.toString());
const response = await fetch(`${BASE_URL}/api/sessions?${params.toString()}`);
export async function fetchSessions() {
const response = await fetch(`${BASE_URL}/api/sessions`);
if (!response.ok)
throw new Error('Failed to fetch sessions');
return response.json();
}
export async function createSession(payload) {
const utcTime = localToUTC(payload.start_time);
const response = await fetch(`${BASE_URL}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_time: utcTime }),
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to create session');
}
if (!response.ok)
throw new Error('Failed to create session');
return response.json();
}
export async function endSession(sessionId, payload) {
const utcTime = payload.end_time ? localToUTC(payload.end_time) : undefined;
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}/end`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ end_time: utcTime }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to end session');
}
return response.json();
}
export async function updateSession(sessionId, payload) {
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to update session');
}
if (!response.ok)
throw new Error('Failed to end session');
return response.json();
}
export async function deleteSession(sessionId) {
@@ -67,30 +60,13 @@ export async function deleteSession(sessionId) {
if (!response.ok)
throw new Error('Failed to delete session');
}
export async function fetchDailySummary(from_date, to_date, day_start, day_end) {
const fromDT = from_date + 'T00:00:00';
const toDT = to_date + 'T23:59:59';
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end });
const response = await fetch(`${BASE_URL}/api/stats/daily-summary?${params.toString()}`);
export async function updateSession(sessionId, payload) {
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok)
throw new Error('Failed to fetch daily summary');
return response.json();
}
export async function fetchTimetable(from_date, to_date, day_start, day_end) {
const fromDT = from_date + 'T00:00:00';
const toDT = to_date + 'T23:59:59';
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end });
const response = await fetch(`${BASE_URL}/api/stats/timetable?${params.toString()}`);
if (!response.ok)
throw new Error('Failed to fetch timetable');
return response.json();
}
export async function fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref) {
const fromDT = from_date + 'T00:00:00';
const toDT = to_date + 'T23:59:59';
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, wakeup_ref, bedtime_ref });
const response = await fetch(`${BASE_URL}/api/stats/wake-sleep-times?${params.toString()}`);
if (!response.ok)
throw new Error('Failed to fetch wake/sleep times');
throw new Error('Failed to update session');
return response.json();
}
+50
View File
@@ -26,3 +26,53 @@ export async function fetchTimetable(from_date: string, to_date: string, day_sta
export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise<any> {
return fetchStats('wake-sleep-times', from_date, to_date, { wakeup_ref, bedtime_ref });
}
// Session API functions
export async function fetchActiveSession(): Promise<SleepSessionRead | null> {
const response = await fetch(`${BASE_URL}/api/sessions/active`);
if (!response.ok) return null;
return response.json();
}
export async function fetchSessions(): Promise<SessionListResponse> {
const response = await fetch(`${BASE_URL}/api/sessions`);
if (!response.ok) throw new Error('Failed to fetch sessions');
return response.json();
}
export async function createSession(payload: SleepSessionCreate): Promise<SleepSessionRead> {
const response = await fetch(`${BASE_URL}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error('Failed to create session');
return response.json();
}
export async function endSession(sessionId: number, payload: SleepSessionUpdate): Promise<SleepSessionRead> {
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}/end`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error('Failed to end session');
return response.json();
}
export async function deleteSession(sessionId: number): Promise<void> {
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete session');
}
export async function updateSession(sessionId: number, payload: SleepSessionUpdate): Promise<SleepSessionRead> {
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error('Failed to update session');
return response.json();
}
+105
View File
@@ -0,0 +1,105 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
// Component Imports
import StatusBanner from '../components/tracker/StatusBanner';
import TimeAdjuster from '../components/tracker/TimeAdjuster';
import SleepButton from '../components/tracker/SleepButton';
import DailySummary from '../components/stats/DailySummary';
import WakeupPlot from '../components/stats/WakeupPlot';
import BedtimePlot from '../components/stats/BedtimePlot';
import SleepTimetable from '../components/stats/SleepTimetable';
import { SessionList } from '../components/stats/SessionList';
import { IntervalPicker } from '../components/stats/IntervalPicker';
import { SettingsPopover } from '../components/stats/SettingsPopover';
// Mock Data Generators
const generateMockSessions = () => [
{ id: 1, start_time: '2024-05-20T22:00:00Z', end_time: '2024-05-21T06:00:00Z', notes: 'Good sleep', created_at: '2024-05-21T07:00:00Z', updated_at: '2024-05-21T07:00:00Z' },
{ id: 2, start_time: '2024-05-21T21:30:00Z', end_time: null, notes: null, created_at: '2024-05-21T21:30:00Z', updated_at: '2024-05-21T21:30:00Z' },
];
const generateMockStats = (days = 7) => {
const dailySummaryDays = Array.from({ length: days }).map((_, i) => ({
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
total_minutes: 480,
day_minutes: 240,
night_minutes: 240,
}));
const timetableSlots = Array.from({ length: 24 }).map((_, i) => ({
date: new Date().toISOString().split('T')[0],
slot_start: `${i.toString().padStart(2, '0')}:00`,
slot_end: `${((i + 1) % 24).toString().padStart(2, '0')}:00`,
sleeping: i >= 22 || i <= 6,
}));
const wakeupBedtimeDays = Array.from({ length: days }).map((_, i) => ({
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
wakeup_minutes: 360,
bedtime_minutes: 1260,
}));
return {
dailySummary: { days: dailySummaryDays },
timetable: { slots: timetableSlots },
wakeupBedtime: { days: wakeupBedtimeDays },
};
};
// Helper to inject data by prefixing keys since we don't know exact dynamic parameters (dates)
function setQueryDataByPrefix(queryClient, prefix, data) {
const queries = queryClient.getQueryCache().getAll();
let found = false;
for (const query of queries) {
const match = prefix.every((p, i) => query.queryKey[i] === p);
if (match) {
queryClient.setQueryData(query.queryKey, data);
found = true;
}
}
return found;
}
// We will attempt to populate common keys used by the hooks.
function injectAllMocks(queryClient) {
const { dailySummary, timetable, wakeupBedtime } = generateMockStats();
// Session mocks
queryClient.setQueryData(['activeSession'], null);
queryClient.setQueryData(['sessions'], { sessions: generateMockSessions(), total: 2 });
// Stats mocks - iterate and try to find matches for common prefixes
const queries = queryClient.getQueryCache().getAll();
for (const query of queries) {
const key = query.queryKey;
if (key[0] === 'dailySummary') {
queryClient.setQueryData(key, dailySummary);
}
else if (key[0] === 'timetable') {
queryClient.setQueryData(key, timetable);
}
else if (key[0] === 'wakeSleepTimes') {
queryClient.setQueryData(key, wakeupBedtime);
}
}
// If no queries exist yet (because hooks haven't run), we can't match.
// But once a component renders, the query is created and our next render/effect
// can catch it. For this playground, we'll just try to set common ones.
}
const COMPONENT_REGISTRY = [
{ name: 'StatusBanner', component: StatusBanner },
{ name: 'TimeAdjuster', component: TimeAdjuster },
{ name: 'SleepButton', component: SleepButton },
{ name: 'DailySummary', component: DailySummary },
{ name: 'WakeupPlot', component: WakeupPlot },
{ name: 'BedtimePlot', component: BedtimePlot },
{ name: 'SleepTimetable', component: SleepTimetable },
{ name: 'SessionList', component: SessionList },
{ name: 'IntervalPicker', component: IntervalPicker },
{ name: 'SettingsPopover', component: SettingsPopover },
];
export default function Playground() {
const [selectedIndex, setSelectedIndex] = useState(-1);
const queryClient = useQueryClient();
const selectedComponent = selectedIndex >= 0 ? COMPONENT_REGISTRY[selectedIndex] : null;
useEffect(() => {
if (selectedComponent) {
// In a real playground, we might want to clear old data first,
// but for mocks we just want to ensure the right ones exist.
injectAllMocks(queryClient);
}
}, [selectedIndex, queryClient]);
return (_jsxs("div", { className: "p-6 space-y-8", children: [_jsxs("header", { className: "flex flex-col gap-4 border-b pb-6", children: [_jsx("h2", { className: "text-3xl font-bold text-slate-900", children: "Component Playground" }), _jsx("p", { className: "text-slate-500", children: "Select a component to render it in isolation with mock data." }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("label", { htmlFor: "component-select", className: "font-medium text-slate-700", children: "Component:" }), _jsxs("select", { id: "component-select", name: "component-select", className: "block w-full max-w-xs rounded-md border-slate-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm", value: selectedIndex, onChange: (e) => setSelectedIndex(parseInt(e.target.value)), children: [_jsx("option", { value: "-1", children: "-- Select a Component --" }), COMPONENT_REGISTRY.map((comp, idx) => (_jsx("option", { value: idx, children: comp.name }, comp.name)))] })] })] }), _jsx("main", { className: "min-h-[400px] flex items-center justify-center border-2 border-dashed border-slate-200 rounded-xl bg-white p-8", children: selectedComponent ? (_jsx("div", { className: "w-full h-full flex justify-center", children: React.createElement(selectedComponent.component, selectedComponent.props || {}) })) : (_jsx("div", { className: "text-center text-slate-400", children: _jsx("p", { children: "No component selected" }) })) }), selectedComponent && (_jsxs("footer", { className: "text-sm text-slate-400 italic", children: ["Rendering: ", selectedComponent.name] }))] }));
}
+199
View File
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { ComponentType } from 'react';
// Types
import type {
SleepSessionRead,
DailySummaryDay,
TimetableSlot,
WakeupBedtimeDay,
DailySummaryResponse,
TimetableResponse,
WakeupBedtimeResponse
} from '../types';
// Component Imports
import StatusBanner from '../components/tracker/StatusBanner';
import TimeAdjuster from '../components/tracker/TimeAdjuster';
import SleepButton from '../components/tracker/SleepButton';
import DailySummary from '../components/stats/DailySummary';
import WakeupPlot from '../components/stats/WakeupPlot';
import BedtimePlot from '../components/stats/BedtimePlot';
import SleepTimetable from '../components/stats/SleepTimetable';
import { SessionList } from '../components/stats/SessionList';
import { IntervalPicker } from '../components/stats/IntervalPicker';
import { SettingsPopover } from '../components/stats/SettingsPopover';
// Mock Data Generators
const generateMockSessions = (): SleepSessionRead[] => [
{ id: 1, start_time: '2024-05-20T22:00:00Z', end_time: '2024-05-21T06:00:00Z', notes: 'Good sleep', created_at: '2024-05-21T07:00:00Z', updated_at: '2024-05-21T07:00:00Z' },
{ id: 2, start_time: '2024-05-21T21:30:00Z', end_time: null, notes: null, created_at: '2024-05-21T21:30:00Z', updated_at: '2024-05-21T21:30:00Z' },
];
const generateMockStats = (days = 7): {
dailySummary: DailySummaryResponse;
timetable: TimetableResponse;
wakeupBedtime: WakeupBedtimeResponse;
} => {
const dailySummaryDays: DailySummaryDay[] = Array.from({ length: days }).map((_, i) => ({
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
total_minutes: 480,
day_minutes: 240,
night_minutes: 240,
}));
const timetableSlots: TimetableSlot[] = Array.from({ length: 24 }).map((_, i) => ({
date: new Date().toISOString().split('T')[0],
slot_start: `${i.toString().padStart(2, '0')}:00`,
slot_end: `${((i + 1) % 24).toString().padStart(2, '0')}:00`,
sleeping: i >= 22 || i <= 6,
}));
const wakeupBedtimeDays: WakeupBedtimeDay[] = Array.from({ length: days }).map((_, i) => {
const dateStr = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const times = ['04:55', '05:05', '05:00', '05:30', '05:45', '06:00'];
const timeStr = times[i % times.length];
const [h, m] = timeStr.split(':').map(Number);
return {
date: dateStr,
wakeup_minutes: h * 60 + m,
bedtime_minutes: 1260,
};
});
return {
dailySummary: { days: dailySummaryDays },
timetable: { slots: timetableSlots },
wakeupBedtime: { days: wakeupBedtimeDays },
};
};
// Helper to inject data by prefixing keys since we don't know exact dynamic parameters (dates)
function setQueryDataByPrefix(queryClient: any, prefix: string[], data: any) {
const queries = queryClient.getQueryCache().getAll();
let found = false;
for (const query of queries) {
const match = prefix.every((p, i) => query.queryKey[i] === p);
if (match) {
queryClient.setQueryData(query.queryKey, data);
found = true;
}
}
return found;
}
// We will attempt to populate common keys used by the hooks.
function injectAllMocks(queryClient: any) {
const { dailySummary, timetable, wakeupBedtime } = generateMockStats();
// Session mocks
queryClient.setQueryData(['activeSession'], null);
queryClient.setQueryData(['sessions'], { sessions: generateMockSessions(), total: 2 });
// Stats mocks - iterate and try to find matches for common prefixes
const queries = queryClient.getQueryCache().getAll();
for (const query of queries) {
const key = query.queryKey;
if (Array.isArray(key)) {
const firstKey = key[0];
if (firstKey === 'dailySummary') {
queryClient.setQueryData(key, dailySummary);
} else if (firstKey === 'timetable') {
queryClient.setQueryData(key, timetable);
} else if (firstKey === 'wakeSleepTimes') {
queryClient.setQueryData(key, wakeupBedtime);
}
}
}
// Also proactively set some common keys to ensure they are available for initial renders
queryClient.setQueryData(['dailySummary', '2024-01-01', '2024-01-07'], dailySummary);
queryClient.setQueryData(['timetable', '2024-01-01', '2024-01-07'], timetable);
queryClient.setQueryData(['wakeSleepTimes', '2024-01-01', '2024-01-07'], wakeupBedtime);
}
// Registry Definition
interface PlaygroundComponent {
name: string;
component: ComponentType<any>;
props?: Record<string, any>;
}
const COMPONENT_REGISTRY: PlaygroundComponent[] = [
{ name: 'StatusBanner', component: StatusBanner },
{ name: 'TimeAdjuster', component: TimeAdjuster, props: { onTimeChange: (t: any) => {}, defaultValue: new Date().toISOString() } },
{ name: 'SleepButton', component: SleepButton, props: { time: new Date().toISOString() } },
{ name: 'TrackerPage', component: TrackerPage, props: {} },
{ name: 'DailySummary', component: DailySummary, props: { from_date: '2024-01-01', to_date: '2024-01-07', day_start: '06:00', day_end: '21:00' } },
{ name: 'WakeupPlot', component: WakeupPlot, props: { from_date: '2024-01-01', to_date: '2024-01-07', wakeup_ref: '07:00', bedtime_ref: '20:00' } },
{ name: 'BedtimePlot', component: BedtimePlot, props: { from_date: '2024-01-01', to_date: '2024-01-07', wakeup_ref: '07:00', bedtime_ref: '20:00' } },
{ name: 'SleepTimetable', component: SleepTimetable, props: { from_date: '2024-01-01', to_date: '2024-01-07', day_start: '06:00', day_end: '21:00' } },
{ name: 'SessionList', component: SessionList, props: {} },
{ name: 'IntervalPicker', component: IntervalPicker, props: { value: 7, onChange: () => {} } },
{ name: 'SettingsPopover', component: SettingsPopover, props: { dayStart: '06:00', dayEnd: '21:00', wakeupRef: '07:00', bedtimeRef: '20:00', onSave: () => {} } },
{ name: 'Navbar', component: Navbar, props: {} },
{ name: 'ErrorBoundary', component: ErrorBoundary, props: {} },
];
export default function Playground() {
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const queryClient = useQueryClient();
const selectedComponent = selectedIndex >= 0 ? COMPONENT_REGISTRY[selectedIndex] : null;
useEffect(() => {
if (selectedComponent) {
// In a real playground, we might want to clear old data first,
// but for mocks we just want to ensure the right ones exist.
injectAllMocks(queryClient);
}
}, [selectedIndex, queryClient]);
return (
<div className="p-6 space-y-8">
<header className="flex flex-col gap-4 border-b pb-6">
<h2 className="text-3xl font-bold text-slate-900">Component Playground</h2>
<p className="text-slate-500">Select a component to render it in isolation with mock data.</p>
<div className="flex items-center gap-4">
<label htmlFor="component-select" className="font-medium text-slate-700">
Component:
</label>
<select
id="component-select"
name="component-select"
className="block w-full max-w-xs rounded-md border-slate-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
value={selectedIndex}
onChange={(e) => setSelectedIndex(parseInt(e.target.value))}
>
<option value="-1">-- Select a Component --</option>
{COMPONENT_REGISTRY.map((comp, idx) => (
<option key={comp.name} value={idx}>
{comp.name}
</option>
))}
</select>
</div>
</header>
<main className="min-h-[400px] w-full flex items-center justify-center border-2 border-dashed border-slate-200 rounded-xl bg-white p-4">
{selectedComponent ? (
<div className="w-full h-full flex justify-center">
{React.createElement(selectedComponent.component, { ...selectedComponent.props })}
</div >
) : (
<div className="text-center text-slate-400">
<p>No component selected</p>
</div >
)}
</main>
{selectedComponent && (
<footer className="text-sm text-slate-400 italic" id="playground-footer">
Rendering: {selectedComponent.name}
</footer>
)}
</div>
);
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts';
const BedtimePlot = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => {
const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00', wakeup_ref, bedtime_ref);
@@ -13,11 +13,12 @@ const BedtimePlot = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => {
}
const data = wakeupBedtime.days.map((day) => {
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();
@@ -18,7 +18,7 @@ interface WakeupBedtimeProps {
bedtime_ref: string;
}
const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => {
const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_ref = '07:00', bedtime_ref = '20:00' }) => {
const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00', wakeup_ref, bedtime_ref);
const [chartData, setChartData] = useState<{ date: string; minutes: number; time: string }[]>([]);
+3 -3
View File
@@ -45,14 +45,14 @@ export function SessionList() {
alert('Failed to update session');
}
};
const handleCancel = () => {
setEditingId(null);
};
const startEditing = (s) => {
setEditingId(s.id);
setEditStart(toDatetimeLocal(s.start_time));
setEditEnd(s.end_time ? toDatetimeLocal(s.end_time) : '');
};
const handleCancel = () => {
setEditingId(null);
};
return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Sleep Sessions" }), _jsx("div", { className: "space-y-2", children: sessions.map((s) => (_jsx("div", { className: "border-b border-gray-100 last:border-0 pb-2", children: editingId === s.id ? (_jsxs("div", { className: "flex flex-col space-y-2 py-1", children: [_jsxs("div", { className: "grid grid-cols-1 gap-2 text-xs text-gray-500", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: "Start Time" }), _jsx("input", { type: "datetime-local", className: "border rounded px-1 py-0.5 text-sm text-black w-full", value: editStart, onChange: (e) => setEditStart(e.target.value) })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: "End Time" }), _jsx("input", { type: "datetime-local", className: "border rounded px-1 py-0.5 text-sm text-black w-full", value: editEnd, onChange: (e) => setEditEnd(e.target.value) })] })] }), _jsxs("div", { className: "flex justify-end space-x-2", children: [_jsx("button", { onClick: handleCancel, className: "p-1 text-gray-500 hover:text-gray-700 transition-colors", title: "Cancel", children: _jsx(X, { size: 18 }) }), _jsx("button", { onClick: () => handleSave(s.id), className: "p-1 text-green-600 hover:text-green-700 transition-colors", title: "Save", children: _jsx(Check, { size: 18 }) })] })] })) : (_jsxs("div", { className: "flex justify-between items-center text-sm cursor-pointer hover:bg-gray-50 p-1 rounded transition-colors", onClick: () => startEditing(s), children: [_jsxs("div", { className: "flex flex-col", children: [_jsx("span", { className: "text-gray-900 font-medium", children: formatDateTime(s.start_time) }), _jsx("span", { className: "text-gray-500 text-xs", children: s.end_time
? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}`
: 'In progress' })] }), _jsxs("div", { className: "flex items-center space-x-2", children: [_jsx("span", { className: "text-blue-600 font-mono text-sm", children: s.end_time && s.start_time
@@ -40,7 +40,7 @@ export function SessionList() {
start_time: new Date(editStart).toISOString(),
end_time: editEnd ? new Date(editEnd).toISOString() : null,
}
});
} as any);
setEditingId(null);
} catch (err) {
console.error(err);
@@ -48,16 +48,16 @@ export function SessionList() {
}
};
const handleCancel = () => {
setEditingId(null);
};
const startEditing = (s: SleepSessionRead) => {
setEditingId(s.id);
setEditStart(toDatetimeLocal(s.start_time));
setEditEnd(s.end_time ? toDatetimeLocal(s.end_time) : '');
};
const handleCancel = () => {
setEditingId(null);
};
return (
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
@@ -0,0 +1,63 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useMemo } from 'react';
import { useStats } from '../../hooks/useStats';
import { timeToMinutes } from '../../utils/time';
const Timetable = ({ from_date, to_date, day_start, day_end }) => {
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end);
const days = useMemo(() => {
const start = new Date(from_date + 'T00:00:00');
const end = new Date(to_date + 'T23:59:59');
const result = [];
let current = new Date(start);
while (current <= end) {
result.push(current.toLocaleDateString('en-CA'));
current.setDate(current.getDate() + 1);
}
return result;
}, [from_date, to_date]);
const timeSteps = useMemo(() => {
return Array.from({ length: 24 }, (_, i) => i * 60);
}, []);
if (isLoading)
return _jsx("div", { className: "text-center p-4", children: "Loading timetable..." });
if (!timetable?.slots || timetable.slots.length === 0) {
return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." });
}
const getDayName = (dateStr) => {
return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' });
};
const formatTimeLabel = (mins) => {
const hours = Math.floor(mins / 60);
const minutes = mins % 60;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
};
const isSleepingInInterval = (date, intervalStart, intervalEnd) => {
return timetable.slots.some((slot) => {
if (slot.date !== date || !slot.sleeping)
return false;
let sStart = timeToMinutes(slot.slot_start);
let sEnd = timeToMinutes(slot.slot_end);
if (sEnd <= sStart) {
// Handle wrap-around within a single slot if it happens (though unlikely for 15m segments)
const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
const overlap2 = Math.max(0, intervalStart) < Math.min(sEnd, intervalEnd);
return overlap1 || overlap2;
}
return Math.max(sStart, intervalStart) < Math.min(sEnd, intervalEnd);
});
};
const currentMins = new Date().getHours() * 60 + new Date().getMinutes();
const rowHeightPx = 32; // h-8 is 2rem, which is 32px in standard Tailwind config
return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Sleep Timetable" }), _jsxs("div", { className: "min-w-[600px] flex flex-col gap-px", children: [_jsxs("div", { className: "grid", style: {
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}, children: [_jsx("div", { className: "h-8" }), " ", days.map((day) => (_jsx("div", { className: "text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8", children: getDayName(day) }, day)))] }), _jsx("div", { className: "relative", children: _jsxs("div", { className: "grid", style: {
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}, children: [timeSteps.map((step) => (_jsxs(React.Fragment, { children: [_jsx("div", { className: "text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8", children: step % 60 === 0 ? formatTimeLabel(step) : '' }), days.map((day) => {
const sleeping = isSleepingInInterval(day, step, step + 60);
return (_jsx("div", { className: `h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}` }, `${day}-${step}`));
})] }, step))), _jsx("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%)'
} })] }) })] })] }));
};
export default Timetable;
@@ -1,89 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { vi, describe, it, expect } from 'vitest';
import Timetable from './SleepTimetable';
import { useStats } from '../../hooks/useStats';
vi.mock('../../hooks/useStats', () => ({
useStats: vi.fn(),
}));
describe('SleepTimetable', () => {
const defaultProps = {
from_date: '2025-04-20',
to_date: '2025-04-21',
day_start: '22:00',
day_end: '08:00',
};
it('renders loading state', () => {
useStats.mockReturnValue({ timetable: null, isLoading: true });
render(_jsx(Timetable, { ...defaultProps }));
expect(screen.getByText(/Loading timetable.../i)).toBeInTheDocument();
});
it('renders empty state when no data is available', () => {
useStats.mockReturnValue({ timetable: { slots: [] }, isLoading: false });
render(_jsx(Timetable, { ...defaultProps }));
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
});
it('renders the grid correctly with hourly rows and biennial labels', () => {
useStats.mockReturnValue({
timetable: {
slots: [
{
date: '2025-04-20',
sleeping: true,
slot_start: '01:00',
slot_end: '05:00',
},
],
},
isLoading: false,
});
render(_jsx(Timetable, { ...defaultProps }));
// Check for day headers
expect(screen.getByText(/SUN/i)).toBeInTheDocument(); // 2025-04-20 is Sunday
expect(screen.getByText(/MON/i)).toBeInTheDocument(); // 2025-04-21 is Monday
// Check for time labels (every 2 hours: 00, 02, 04...)
expect(screen.getByText('00:00')).toBeInTheDocument();
expect(screen.getByText('02:00')).toBeInTheDocument();
expect(screen.getByText('04:00')).toBeInTheDocument();
expect(screen.getByText('22:00')).toBeInTheDocument();
// Verify non-labeled hours (every hour that isn't %120 == 0)
// We can't easily check for empty text nodes in Vitest/RTL without more precision
// but we can check that 01:00 is NOT present as a text label
expect(screen.queryByText('01:00')).not.toBeInTheDocument();
});
it('colors sleeping intervals correctly', () => {
useStats.mockReturnValue({
timetable: {
slots: [
{
date: '2025-04-20',
sleeping: true,
slot_start: '01:00',
slot_end: '03:00', // Covers 01:00 and 02:00
},
],
},
isLoading: false,
});
const { container } = render(_jsx(Timetable, { ...defaultProps }));
// We are looking for the grid cells.
// Since they don't have text, we look at their classes/backgrounds.
const cells = container.querySelectorAll('.h-8');
// Total cells = 24 rows * (1 label col + 2 days) = 72? No, the structure is:
// Header row has cells. Time grid rows have (1 label + 2 days) = 3 cells each.
// Total elements with .h-8: 1 (header corner) + days.length * 1 (day headers) + 24 * 3 = 75?
// Actually, the code has:
// Header row: header corner + days.map -> 1 + 2 = 3 cells
// Time Rows: (label cell) + days.map -> 24 * 3 = 72 cells
// Total = 75.
// The sleeping cell for 01:00 on 2025-04-20 should be bg-green-500
// The cell for 02:00 on 2025-04-20 should be bg-green-500
// The cell for 03:00 on 2025-04-20 should be bg-gray-50
// Let's just check if ANY cell has bg-green-500 and ALL non-sleeping have bg-gray-50
const greenCells = container.querySelectorAll('.bg-green-500');
expect(greenCells.length).toBeGreaterThan(0);
const grayCells = container.querySelectorAll('.bg-gray-50');
expect(grayCells.length).toBeGreaterThan(0);
});
});
+3 -2
View File
@@ -10,8 +10,9 @@ import { ErrorBoundary } from '../../components/ErrorBoundary';
import { useSettings } from '../../hooks/useSettings';
const StatsPage = () => {
const { settings, update } = useSettings();
const from_date = new Date(new Date().setDate(new Date().getDate() - settings.statsInterval)).toISOString().split('T')[0];
const to_date = new Date().toISOString().split('T')[0];
const now = new Date();
const to_date = now.toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
const from_date = new Date(now.setDate(now.getDate() - settings.statsInterval)).toLocaleDateString('en-CA');
return (_jsxs("div", { className: "max-w-4xl mx-auto space-y-8 pb-12", children: [_jsxs("div", { className: "flex justify-between items-center pt-4", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-3xl font-extrabold text-slate-900 tracking-tight", children: "Statistics" }), _jsx("p", { className: "text-slate-500 text-sm mt-1", children: "Overview of sleep patterns and trends" })] }), _jsx(SettingsPopover, { dayStart: settings.dayStart, dayEnd: settings.dayEnd, wakeupRef: settings.wakeupRef, bedtimeRef: settings.bedtimeRef, onSave: (s) => {
update('dayStart', s.dayStart);
update('dayEnd', s.dayEnd);
+4 -3
View File
@@ -1,7 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts';
const WakeupPlot = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => {
const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00', wakeup_ref, bedtime_ref);
@@ -13,11 +13,12 @@ const WakeupPlot = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => {
}
const data = wakeupBedtime.days.map((day) => {
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();
+19 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useStats } from '../../hooks/useStats';
import { formatMinutesAfter, getToday, getLocalOffsetFromReference } from '../../utils/time';
import { formatMinutesAfter, getToday, getLocalOffsetFromReference, timeToMinutes } from '../../utils/time';
import {
LineChart,
Line,
@@ -18,7 +18,7 @@ interface WakeupBedtimeProps {
bedtime_ref: string;
}
const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => {
const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_ref = '07:00', bedtime_ref = '20:00' }) => {
const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00', wakeup_ref, bedtime_ref);
const [chartData, setChartData] = useState<{ date: string; minutes: number; time: string }[]>([]);
@@ -32,7 +32,21 @@ 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;
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;
}
}
return {
date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`,
minutes: offsetFromRef,
@@ -72,7 +86,7 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
/>
<Tooltip
formatter={(value: any) => [
formatMinutesAfter(value ?? 0, wakeup_ref),
formatMinutesAfter(value, wakeup_ref),
'Wakeup',
] as any}
labelFormatter={(label) => label}
@@ -91,4 +105,4 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
);
};
export default WakeupPlot;
export default WakeupPlot;
@@ -28,7 +28,9 @@ const TimeAdjuster: React.FC<TimeAdjusterProps> = ({ onTimeChange, defaultValue
});
useEffect(() => {
onTimeChange(toUTCFromLocalInput(value));
if (onTimeChange) {
onTimeChange(toUTCFromLocalInput(value));
}
}, [value, onTimeChange]);
const resetToNow = () => {
View File
+20
View File
@@ -19,6 +19,15 @@ export function formatMinutesAfter(minutes, reference = "04:00") {
const mins = normalizedMinutes % 60;
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) {
const offsetInMinutes = new Date().getTimezoneOffset();
return utcMinutes - offsetInMinutes;
}
/**
* Convert a Date to datetime-local input value format (local time)
* @param date - Date object
@@ -99,9 +108,20 @@ export function getToday() {
* @returns total minutes
*/
export function timeToMinutes(timeStr) {
if (!timeStr || typeof timeStr !== 'string') return 0;
const [hours, minutes] = timeStr.split(":").map(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, referenceHHMM) {
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
+59
View File
@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
const BASE_URL = 'http://localhost:5173';
test.describe('Component Playground', () => {
test('playground route loads and shows component selector', async ({ page }) => {
// Listen for console errors
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto(`${BASE_URL}/playground`);
await page.waitForLoadState('networkidle');
// Take screenshot immediately after load to see what is actually there
await page.screenshot({ path: 'playground_debug_load.png' });
if (errors.length > 0) {
console.log('Console errors detected:', errors);
}
// Check heading using a more flexible approach
const heading = page.getByRole('heading', { name: /Component Playground/i });
await expect(heading).toBeVisible();
// Check if dropdown exists and is visible
const select = page.locator('select#component-select');
await expect(select).toBeVisible();
// Check if it has options (besides the placeholder)
const optionCount = await select.locator('option').count();
expect(optionCount).toBeGreaterThan(1);
// Take screenshot for visual confirmation
await page.screenshot({ path: 'playground_loaded.png' });
});
test('selecting a component renders it', async ({ page }) => {
await page.goto(`${BASE_URL}/playground`);
await page.waitForLoadState('networkidle');
const select = page.locator('select#component-select');
// Select 'StatusBanner' (assuming it is the first one in registry after placeholder)
await select.selectOption({ label: 'StatusBanner' });
// Check if the main area is no longer showing "No component selected"
const emptyState = page.locator('text=No component selected');
await expect(emptyState).not.toBeVisible();
// Verify footer shows correct component name
const footer = page.locator('footer');
await expect(footer).toContainText('StatusBanner');
// Take screenshot of rendered component
await page.screenshot({ path: 'playground_component_rendered.png' });
});
});
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
const BASE_URL = 'http://localhost:5173';
test.describe('Exhaustive Component Playground Tests', () => {
test('every component in playground should render without console errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto(`${BASE_URL}/playground`);
await page.waitForLoadState('networkidle');
const select = page.locator('select#component-select');
await expect(select).toBeVisible();
const optionCount = await select.locator('option').count();
// The first option is '-- Select a Component --', so we iterate from index 1
for (let i = 1; i < optionCount; i++) {
const optionName = await select.locator('option').nth(i).textContent();
if (!optionName) continue;
console.log(`Testing component: ${optionName}`);
// Select the component
await select.selectOption({ index: i });
// Wait for potential re-renders and data fetching (even if mocked)
await page.waitForTimeout(500);
// Allow some time for useEffects and async mock injections to settle
await page.waitForLoadState('networkidle').catch(() => {});
// Log the current HTML to debug why footer is missing
const html = await page.content();
console.log(`Current HTML length: ${html.length}`);
if (!html.includes('id="playground-footer"')) {
console.log('CRITICAL: id="playground-footer" not found in HTML');
}
// Check if any errors were logged during the selection and render
if (errors.length > 0) {
// We check if the errors are actually related to the current component selection
// For simplicity in this exhaustive test, any console error is a failure.
throw new Error(`Console errors detected while rendering ${optionName}: ${errors.join(', ')}`);
}
// Verify footer updated correctly
const footer = page.locator('#playground-footer');
await expect(footer).toBeVisible();
await expect(footer).toContainText(optionName);
}
expect(errors.length).toBe(0);
});
});
@@ -1,10 +1,10 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { vi, describe, it, expect } from 'vitest';
import Timetable from './SleepTimetable';
import { useStats } from '../../hooks/useStats';
import Timetable from '../../../../src/components/stats/SleepTimetable';
import { useStats } from '../../../../src/hooks/useStats';
vi.mock('../../hooks/useStats', () => ({
vi.mock('../../../../src/hooks/useStats', () => ({
useStats: vi.fn(),
}));
@@ -45,9 +45,9 @@ describe('SleepTimetable', () => {
render(<Timetable {...defaultProps} />);
// Check for day headers
expect(screen.getByText(/SUN/i)).toBeInTheDocument(); // 2025-04-20 is Sunday
expect(screen.getByText(/MON/i)).toBeInTheDocument(); // 2025-04-21 is Monday
// Check for day headers
expect(screen.getByText(/SUN/i)).toBeInTheDocument(); // 2025-04-20 is Sunday
expect(screen.getByText(/MON/i)).toBeInTheDocument(); // 2025-04-21 is Monday
// Check for time labels (every 1 hour: 00, 01, 02...)
expect(screen.getByText('00:00')).toBeInTheDocument();
@@ -79,18 +79,6 @@ describe('SleepTimetable', () => {
// We are looking for the grid cells.
// Since they don't have text, we look at their classes/backgrounds.
const cells = container.querySelectorAll('.h-8');
// Total cells = 24 rows * (1 label col + 2 days) = 72? No, the structure is:
// Header row has cells. Time grid rows have (1 label + 2 days) = 3 cells each.
// Total elements with .h-8: 1 (header corner) + days.length * 1 (day headers) + 24 * 3 = 75?
// Actually, the code has:
// Header row: header corner + days.map -> 1 + 2 = 3 cells
// Time Rows: (label cell) + days.map -> 24 * 3 = 72 cells
// Total = 75.
// The sleeping cell for 01:00 on 2025-04-20 should be bg-green-500
// The cell for 02:00 on 2025-04-20 should be bg-green-500
// The cell for 03:00 on 2025-04-20 should be bg-gray-50
// Let's just check if ANY cell has bg-green-500 and ALL non-sleeping have bg-gray-50
const greenCells = container.querySelectorAll('.bg-green-500');
expect(greenCells.length).toBeGreaterThan(0);