diff --git a/.gitignore b/.gitignore index 5c03d2d..b2f4587 100644 --- a/.gitignore +++ b/.gitignore @@ -316,3 +316,4 @@ pyrightconfig.json frontend/test-results frontend/playwright-report backend/data +.aider* diff --git a/PLAYGROUND_PLAN.md b/PLAYGROUND_PLAN.md new file mode 100644 index 0000000..a9bbb40 --- /dev/null +++ b/PLAYGROUND_PLAN.md @@ -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. diff --git a/frontend/playground_component_rendered.png b/frontend/playground_component_rendered.png new file mode 100644 index 0000000..26fc77f Binary files /dev/null and b/frontend/playground_component_rendered.png differ diff --git a/frontend/playground_dailysummary.png b/frontend/playground_dailysummary.png new file mode 100644 index 0000000..3f4a60e Binary files /dev/null and b/frontend/playground_dailysummary.png differ diff --git a/frontend/playground_debug_load.png b/frontend/playground_debug_load.png new file mode 100644 index 0000000..f5b920d Binary files /dev/null and b/frontend/playground_debug_load.png differ diff --git a/frontend/playground_intervalpicker.png b/frontend/playground_intervalpicker.png new file mode 100644 index 0000000..fdfae84 Binary files /dev/null and b/frontend/playground_intervalpicker.png differ diff --git a/frontend/playground_loaded.png b/frontend/playground_loaded.png new file mode 100644 index 0000000..f5b920d Binary files /dev/null and b/frontend/playground_loaded.png differ diff --git a/frontend/playground_sessionlist.png b/frontend/playground_sessionlist.png new file mode 100644 index 0000000..95815ec Binary files /dev/null and b/frontend/playground_sessionlist.png differ diff --git a/frontend/playground_settingspopover.png b/frontend/playground_settingspopover.png new file mode 100644 index 0000000..0859124 Binary files /dev/null and b/frontend/playground_settingspopover.png differ diff --git a/frontend/playground_sleepbutton.png b/frontend/playground_sleepbutton.png new file mode 100644 index 0000000..476ca56 Binary files /dev/null and b/frontend/playground_sleepbutton.png differ diff --git a/frontend/playground_sleeptimetable.png b/frontend/playground_sleeptimetable.png new file mode 100644 index 0000000..56d80bb Binary files /dev/null and b/frontend/playground_sleeptimetable.png differ diff --git a/frontend/playground_statusbanner.png b/frontend/playground_statusbanner.png new file mode 100644 index 0000000..e97db3e Binary files /dev/null and b/frontend/playground_statusbanner.png differ diff --git a/frontend/src/App.js b/frontend/src/App.js index ce6d7cd..b800ab1 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dab3f75..6b8dbd9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> + } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 93836f4..abb643d 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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(); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5b04148..355f0c9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { return fetchStats('wake-sleep-times', from_date, to_date, { wakeup_ref, bedtime_ref }); } + +// Session API functions +export async function fetchActiveSession(): Promise { + const response = await fetch(`${BASE_URL}/api/sessions/active`); + if (!response.ok) return null; + return response.json(); +} + +export async function fetchSessions(): Promise { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/components/Playground.js b/frontend/src/components/Playground.js new file mode 100644 index 0000000..75f053f --- /dev/null +++ b/frontend/src/components/Playground.js @@ -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] }))] })); +} diff --git a/frontend/src/components/Playground.tsx b/frontend/src/components/Playground.tsx new file mode 100644 index 0000000..9cd4341 --- /dev/null +++ b/frontend/src/components/Playground.tsx @@ -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; + props?: Record; +} + +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(-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 ( +
+
+

Component Playground

+

Select a component to render it in isolation with mock data.

+ +
+ + +
+
+ +
+ {selectedComponent ? ( +
+ {React.createElement(selectedComponent.component, { ...selectedComponent.props })} +
+ ) : ( +
+

No component selected

+
+ )} +
+ + {selectedComponent && ( +
+ Rendering: {selectedComponent.name} +
+ )} +
+ ); +} diff --git a/frontend/src/components/stats/BedtimePlot.js b/frontend/src/components/stats/BedtimePlot.js index e163007..4e7e758 100644 --- a/frontend/src/components/stats/BedtimePlot.js +++ b/frontend/src/components/stats/BedtimePlot.js @@ -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(); diff --git a/frontend/src/components/stats/BedtimePlot.tsx b/frontend/src/components/stats/BedtimePlot.tsx index e272fbb..faa47a2 100644 --- a/frontend/src/components/stats/BedtimePlot.tsx +++ b/frontend/src/components/stats/BedtimePlot.tsx @@ -18,7 +18,7 @@ interface WakeupBedtimeProps { bedtime_ref: string; } -const BedtimePlot: React.FC = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => { +const BedtimePlot: React.FC = ({ 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 }[]>([]); diff --git a/frontend/src/components/stats/SessionList.js b/frontend/src/components/stats/SessionList.js index e0b4c7c..3db577a 100644 --- a/frontend/src/components/stats/SessionList.js +++ b/frontend/src/components/stats/SessionList.js @@ -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 diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx index f34bc9e..b20d62d 100644 --- a/frontend/src/components/stats/SessionList.tsx +++ b/frontend/src/components/stats/SessionList.tsx @@ -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 (

Sleep Sessions

diff --git a/frontend/src/components/stats/SleepTimetable.js b/frontend/src/components/stats/SleepTimetable.js new file mode 100644 index 0000000..e0aa147 --- /dev/null +++ b/frontend/src/components/stats/SleepTimetable.js @@ -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; diff --git a/frontend/src/components/stats/SleepTimetable.test.js b/frontend/src/components/stats/SleepTimetable.test.js deleted file mode 100644 index 0801e94..0000000 --- a/frontend/src/components/stats/SleepTimetable.test.js +++ /dev/null @@ -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); - }); -}); diff --git a/frontend/src/components/stats/StatsPage.js b/frontend/src/components/stats/StatsPage.js index 8bf21b7..b695088 100644 --- a/frontend/src/components/stats/StatsPage.js +++ b/frontend/src/components/stats/StatsPage.js @@ -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); diff --git a/frontend/src/components/stats/WakeupPlot.js b/frontend/src/components/stats/WakeupPlot.js index 63b96c3..3399404 100644 --- a/frontend/src/components/stats/WakeupPlot.js +++ b/frontend/src/components/stats/WakeupPlot.js @@ -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(); diff --git a/frontend/src/components/stats/WakeupPlot.tsx b/frontend/src/components/stats/WakeupPlot.tsx index c940ac3..c04a799 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 } 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 = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => { +const WakeupPlot: React.FC = ({ 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 = ({ 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 = ({ from_date, to_date, wakeup_r /> [ - formatMinutesAfter(value ?? 0, wakeup_ref), + formatMinutesAfter(value, wakeup_ref), 'Wakeup', ] as any} labelFormatter={(label) => label} @@ -91,4 +105,4 @@ const WakeupPlot: React.FC = ({ from_date, to_date, wakeup_r ); }; -export default WakeupPlot; \ No newline at end of file +export default WakeupPlot; diff --git a/frontend/src/components/tracker/TimeAdjuster.tsx b/frontend/src/components/tracker/TimeAdjuster.tsx index 676fae4..26b6ef8 100644 --- a/frontend/src/components/tracker/TimeAdjuster.tsx +++ b/frontend/src/components/tracker/TimeAdjuster.tsx @@ -28,7 +28,9 @@ const TimeAdjuster: React.FC = ({ onTimeChange, defaultValue }); useEffect(() => { - onTimeChange(toUTCFromLocalInput(value)); + if (onTimeChange) { + onTimeChange(toUTCFromLocalInput(value)); + } }, [value, onTimeChange]); const resetToNow = () => { diff --git a/frontend/src/pages/Playground.tsx b/frontend/src/pages/Playground.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/utils/time.js b/frontend/src/utils/time.js index 3ce209b..2846579 100644 --- a/frontend/src/utils/time.js +++ b/frontend/src/utils/time.js @@ -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 diff --git a/frontend/tests/e2e/playground.spec.ts b/frontend/tests/e2e/playground.spec.ts new file mode 100644 index 0000000..524a9ed --- /dev/null +++ b/frontend/tests/e2e/playground.spec.ts @@ -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' }); + }); +}); diff --git a/frontend/tests/e2e/playground_exhaustive.spec.ts b/frontend/tests/e2e/playground_exhaustive.spec.ts new file mode 100644 index 0000000..f197b72 --- /dev/null +++ b/frontend/tests/e2e/playground_exhaustive.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/components/stats/SleepTimetable.test.tsx b/frontend/tests/unit/components/stats/SleepTimetable.test.tsx similarity index 70% rename from frontend/src/components/stats/SleepTimetable.test.tsx rename to frontend/tests/unit/components/stats/SleepTimetable.test.tsx index c9f5dda..7f3425e 100644 --- a/frontend/src/components/stats/SleepTimetable.test.tsx +++ b/frontend/tests/unit/components/stats/SleepTimetable.test.tsx @@ -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(); - // 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);