Slop cleanup

This commit is contained in:
2026-05-28 09:31:20 +02:00
parent f56492fb70
commit 18a767f697
47 changed files with 6376 additions and 1120 deletions
+5
View File
@@ -317,3 +317,8 @@ frontend/test-results
frontend/playwright-report
backend/data
.aider*
# Additional patterns
blob-report/
.DS_Store
Thumbs.db
-33
View File
@@ -1,33 +0,0 @@
# Frontend Systematic Audit & Cleanup Plan
## Goal
Perform a comprehensive audit and cleanup of the `frontend/` directory to remove shadowing, duplicates, corruption (appending), and dead code.
## Phase 1: Discovery (READ-ONLY)
I will conduct a comprehensive scan of the `frontend/` directory to build an accurate inventory.
* **File Shadowing Audit**: Scan for files with overlapping names but different extensions (e.g., `.js` vs `.ts`, `.jsx` vs `.tsx`) that cause build-time shadowing.
* **Implementation Duplicate Audit**: Use content searching to find identical or near-identical function/class definitions across different files.
* **Corruption Check**: Scan files for duplicate symbol declarations (identifying where code was incorrectly appended rather than replaced).
* **Dependency/Import Audit**: Cross-reference `package.json` with actual imports to identify unused packages and orphaned imports in code.
* **Slop Identification**: Locate commented-out blocks, unused variables, and dead functions.
## Phase 2: Categorization (PLANNING)
I will create a structured todo list (`FRONTEND_CLEANUP_TODO.md`) with the following categories:
* **`[DELETE]`**: Conflicting or redundant files (e.g., `client.js`).
* **`[REPAIR]`**: Corrupted files that must be completely overwritten to remove duplicates.
* **`[CLEAN]`**: Files requiring removal of unused imports, variables, or dead code.
* **`[REFACTOR]`**: Files requiring naming or pattern consistency fixes.
## Phase 3: Systematic Execution (IMPLEMENTATION)
I will execute the cleanup in this order to maintain stability:
1. **File Purge**: Remove all files in the `[DELETE]` category first to resolve shadowing.
2. **File Repair**: Use full-file rewrites for files in the `[REPAIR]` category.
3. **Slop Removal**: Systematic pass through files in the `[CLEAN]` category.
4. **Verification**:
- Run `npm install` to ensure dependency integrity.
- Run all existing tests (`npm test`) and the reproduction test to confirm functionality.
- Check for build errors/linting issues.
## Notes
- This plan will be updated and tracked via `FRONTEND_CLEANUP_TODO.md`.
- No changes will be made until the Phase 2 Todo list is reviewed and approved.
+6 -5
View File
@@ -198,7 +198,7 @@ List sessions, most recent first.
---
#### `PUT /api/sessions/{id}`
#### `PATCH /api/sessions/{id}`
Update start_time, end_time, or notes on any session.
@@ -393,10 +393,11 @@ A small gear icon opens an inline settings popover to change `dayStart` / `dayEn
#### `SleepTimetable`
A grid where:
- Rows = days (newest at top)
- Columns = 15-minute slots across 24 hours (96 columns)
- Each cell is a small coloured square: sleeping = navy, awake = light grey
- X-axis labels: 00:00, 03:00, 06:00 … 21:00
- Rows = hours of the day (00:00 at top, 23:00 at bottom — 24 rows)
- Columns = days in the selected interval (newest on the right)
- Each cell is a small coloured square: sleeping = green (`bg-green-500`), awake = light grey (`bg-gray-50`)
- Column headers: short weekday name per day
- A red horizontal line marks the current time of day
- Scrollable horizontally on mobile
#### `WakeupPlot` and `BedtimePlot`
+37
View File
@@ -0,0 +1,37 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
export default tseslint.config(
{
ignores: ['dist/**/*'],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
plugins: {
react: reactPlugin,
'react-hooks': reactHooksPlugin,
},
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: 'detect',
},
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules,
...reactHooksPlugin.configs.recommended.rules,
'react/prop-types': 'off',
},
}
);
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
# Missing Frontend Features — Gaps vs SPEC.md
This file lists things the spec explicitly requires that are **not yet implemented** in the frontend. It does **not** duplicate items already in `frontend_improvements.md` (code quality, performance, testing).
---
## 1. StatusBanner — Awake State Missing Elapsed Time
**Severity: Medium**
**Spec requirement:**
> "No active session: 'Baby is awake 👶' with elapsed time since last sleep ended (if known)."
**What is currently implemented** (`src/components/tracker/StatusBanner.tsx:40-50`):
The awake state renders only "Baby is awake" with the 👶 emoji. There is no elapsed time displayed.
**What is missing:**
A line below "Baby is awake" that shows how long ago the last sleep session ended (e.g. "2h 15m ago"), updating every second. Should only appear "if known" — i.e. when a previous session with an `end_time` exists.
**How to fix:**
1. In `StatusBanner.tsx`, add a second `useQuery` call using `fetchSessions` from `src/api/client.ts`. Pass `{ limit: 1 }` to get only the most recent session. Example:
```ts
import { fetchSessions } from '../../api/client';
import { useQuery } from '@tanstack/react-query';
const { data: sessionsData } = useQuery({
queryKey: ['sessions', 'last'],
queryFn: () => fetchSessions(), // already exists in api/client.ts
enabled: !activeSession, // only fetch when no active session
});
const lastEndTime = sessionsData?.sessions?.[0]?.end_time ?? null;
```
2. Add a second elapsed-time state and `useEffect` that runs only when `activeSession` is null and `lastEndTime` is set — mirrors the existing sleeping elapsed-time effect at lines 9-22:
```ts
const [awakeElapsed, setAwakeElapsed] = useState<string>('');
useEffect(() => {
if (activeSession || !lastEndTime) { setAwakeElapsed(''); return; }
const update = () => setAwakeElapsed(formatElapsedTime(lastEndTime));
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [activeSession, lastEndTime]);
```
`formatElapsedTime` is already imported from `../../utils/time`.
3. In the awake JSX (currently lines 40-50), add the elapsed time below the "Baby is awake" text:
```tsx
{awakeElapsed && (
<p className="text-sm text-blue-700 font-mono">({awakeElapsed} ago)</p>
)}
```
---
## 2. TimeAdjuster — Does Not Reset to "Now" on Session State Change
**Severity: Medium**
**Spec requirement:**
> "Resets to 'now' each time the state changes (start → stop or stop → start), but the user can freely edit before confirming."
**What is currently implemented** (`src/components/tracker/TrackerPage.tsx:13`):
```ts
const [time, setTime] = useState<string>(getLocalTime());
```
`time` is set once when the component mounts and never reset. After the user starts or stops a session, the datetime field keeps whatever value it had before.
**What is missing:**
An automatic reset of the datetime input to the current local time whenever the active session state flips (null → session, or session → null).
**How to fix:**
In `TrackerPage.tsx`, import `useSleepSession` and add a `useEffect` that watches `activeSession?.id`. When it changes the time resets to "now":
```ts
import { useSleepSession } from '../../hooks/useSleepSession';
const TrackerPage: React.FC = () => {
const getLocalTime = () => { /* existing implementation */ };
const [time, setTime] = useState<string>(getLocalTime());
// ADD THIS:
const { activeSession } = useSleepSession();
useEffect(() => {
setTime(getLocalTime());
}, [activeSession?.id]); // fires whenever session starts or ends
// ... rest unchanged
```
Note: `useSleepSession` is already used inside `SleepButton.tsx` (a sibling), so there is no extra network cost — TanStack Query deduplicates the request. `getLocalTime` is already defined in `TrackerPage.tsx:7-11`.
---
## 3. SleepButton — "Stop Sleep" Should Be Amber, Not Rose
**Severity: Low**
**Spec requirement:**
> "Large button labeled 'Stop Sleep' in a warm amber."
**What is currently implemented** (`src/components/tracker/SleepButton.tsx:31`):
```ts
'bg-rose-500 text-white hover:bg-rose-600 shadow-rose-200'
```
The button is red/rose, not amber.
**How to fix:**
In `SleepButton.tsx` at the ternary starting on line 28, change the three rose classes to amber:
```ts
// Before:
'bg-rose-500 text-white hover:bg-rose-600 shadow-rose-200'
// After:
'bg-amber-500 text-white hover:bg-amber-600 shadow-amber-200'
```
That is the only change needed in this file.
+3843 -9
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -29,13 +29,19 @@
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.5",
"autoprefixer": "^10.5.0",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.0",
"postcss": "^8.5.12",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
"vite": "^8.0.10",
"vitest": "^4.1.5"
}
-10
View File
@@ -1,10 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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, {}) }), _jsx(Route, { path: "/playground", element: _jsx(Playground, {}) })] }) })] }) }));
}
export default App;
+13 -5
View File
@@ -1,4 +1,12 @@
import type { SleepSessionCreate, SleepSessionUpdate, SleepSessionRead, SessionListResponse } from '@/types';
import type {
SleepSessionCreate,
SleepSessionUpdate,
SleepSessionRead,
SessionListResponse,
DailySummaryResponse,
TimetableResponse,
WakeupBedtimeResponse
} from '@/types';
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
@@ -22,7 +30,7 @@ export function localToUTC(localValue: string): string {
return new Date(localValue).toISOString();
}
async function fetchStats(endpoint: string, from_date: string, to_date: string, extraParams: Record<string, string> = {}): Promise<any> {
async function fetchStats<T>(endpoint: string, from_date: string, to_date: string, extraParams: Record<string, string> = {}): Promise<T> {
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 });
@@ -31,15 +39,15 @@ async function fetchStats(endpoint: string, from_date: string, to_date: string,
return response.json();
}
export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string): Promise<any> {
export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string): Promise<DailySummaryResponse> {
return fetchStats('daily-summary', from_date, to_date, { day_start, day_end });
}
export async function fetchTimetable(from_date: string, to_date: string, day_start: string, day_end: string): Promise<any> {
export async function fetchTimetable(from_date: string, to_date: string, day_start: string, day_end: string): Promise<TimetableResponse> {
return fetchStats('timetable', from_date, to_date, { day_start, day_end });
}
export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise<any> {
export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise<WakeupBedtimeResponse> {
return fetchStats('wake-sleep-times', from_date, to_date, {
wakeup_ref: localHHMMToUTCHHMM(wakeup_ref),
bedtime_ref: localHHMMToUTCHHMM(bedtime_ref)
-20
View File
@@ -1,20 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Component } from 'react';
export class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error("ErrorBoundary caught an error", error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return (this.props.fallback || (_jsxs("div", { className: "bg-red-50 border border-red-200 rounded-lg p-4 text-center", children: [_jsx("p", { className: "text-red-600 font-medium", children: "Failed to load this component" }), _jsx("button", { onClick: () => this.setState({ hasError: false }), className: "mt-2 text-sm text-red-500 hover:underline", children: "Try again" })] })));
}
return this.props.children;
}
}
-9
View File
@@ -1,9 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Link, useLocation } from 'react-router-dom';
const Navbar = () => {
const location = useLocation();
// Helper to check if a path is active
const isActive = (path) => location.pathname === path;
return (_jsx("nav", { className: "bg-white/80 backdrop-blur-md sticky top-0 z-50 border-b border-gray-100", children: _jsx("div", { className: "max-w-md mx-auto px-4 flex justify-center items-center h-16", children: _jsxs("div", { className: "flex space-x-2 bg-gray-100 p-1 rounded-xl", children: [_jsx(Link, { to: "/", className: `px-6 py-2 rounded-lg text-sm font-bold transition-all duration-200 ${isActive('/') ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`, children: "Tracker" }), _jsx(Link, { to: "/stats", className: `px-6 py-2 rounded-lg text-sm font-bold transition-all duration-200 ${isActive('/stats') ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`, children: "Stats" })] }) }) }));
};
export default Navbar;
-105
View File
@@ -1,105 +0,0 @@
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] }))] }));
}
+32 -55
View File
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useQueryClient, type QueryClient } from '@tanstack/react-query';
import type { ComponentType } from 'react';
// Types
@@ -69,29 +70,12 @@ const generateMockStats = (days = 7): {
};
};
// 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) {
function injectAllMocks(queryClient: 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;
@@ -107,13 +91,11 @@ function injectAllMocks(queryClient: any) {
}
}
// 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>;
@@ -122,18 +104,15 @@ interface PlaygroundComponent {
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: {} },
{ name: 'TimeAdjuster', component: TimeAdjuster, props: { onTimeChange: () => {}, defaultValue: new Date().toISOString() } },
{ name: 'SleepButton', component: SleepButton, props: { time: new Date().toISOString() } },
{ 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: () => {} } },
];
export default function Playground() {
@@ -144,11 +123,9 @@ export default function Playground() {
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]);
}, [selectedIndex, queryClient, selectedComponent]);
return (
<div className="p-6 space-y-8">
@@ -174,26 +151,26 @@ export default function Playground() {
</option>
))}
</select>
</div>
</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>
<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 >
)}
</div>
</main>
{selectedComponent && (
<footer className="text-sm text-slate-400 italic" id="playground-footer">
Rendering: {selectedComponent.name}
</footer >
)}
</div >
);
}
}
@@ -1,37 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { useStats } from '../../hooks/useStats';
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);
const [chartData, setChartData] = useState([]);
useEffect(() => {
if (!wakeupBedtime?.days) {
setChartData([]);
return;
}
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: offsetFromRef,
time: day.bedtime_minutes !== null
? formatMinutesAfter(offsetFromRef, bedtime_ref)
: 'N/A',
};
}).reverse();
setChartData(data);
}, [wakeupBedtime, bedtime_ref]);
if (isLoading)
return _jsx("div", { className: "text-center p-4", children: "Loading plot..." });
if (!chartData.length) {
return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." });
}
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: "Bedtime" }), _jsx(ResponsiveContainer, { width: "100%", height: 200, children: _jsxs(LineChart, { data: chartData, margin: { top: 5, right: 20, left: 0, bottom: 5 }, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: "#e5e7eb" }), _jsx(XAxis, { dataKey: "date", tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' } }), _jsx(YAxis, { tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' }, tickFormatter: (value) => formatMinutesAfter(value, bedtime_ref), domain: ['dataMin - 30', 'dataMax + 30'] }), _jsx(Tooltip, { formatter: (value) => [
value !== undefined ? formatMinutesAfter(value, bedtime_ref) : '',
'Bedtime',
], labelFormatter: (label) => label }), _jsx(Line, { type: "monotone", dataKey: "minutes", stroke: "#8b5cf6", strokeWidth: 2, dot: { fill: '#8b5cf6', r: 4 }, connectNulls: false })] }) })] }));
};
export default BedtimePlot;
+22 -28
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useStats } from '../../hooks/useStats';
import { formatMinutesAfter, getToday, getLocalOffsetFromReference } from '../../utils/time';
import { formatMinutesAfter, getToday } from '../../utils/time';
import {
LineChart,
Line,
@@ -20,30 +20,24 @@ interface WakeupBedtimeProps {
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 }[]>([]);
useEffect(() => {
if (!wakeupBedtime?.days) {
setChartData([]);
return;
}
const chartData = useMemo(() => {
if (!wakeupBedtime?.days) return [];
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 ?? 0;
return {
date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`,
minutes: offsetFromRef,
time:
day.bedtime_minutes !== null
? formatMinutesAfter(offsetFromRef, bedtime_ref)
: 'N/A',
};
}).reverse();
setChartData(data);
return 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 ?? 0;
return {
date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`,
minutes: offsetFromRef,
time:
day.bedtime_minutes !== null
? formatMinutesAfter(offsetFromRef, bedtime_ref)
: 'N/A',
};
}).reverse();
}, [wakeupBedtime, bedtime_ref]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
@@ -71,10 +65,10 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: any) => [
value !== undefined ? formatMinutesAfter(value, bedtime_ref) : '',
formatter={(value: number) => [
formatMinutesAfter(value, bedtime_ref),
'Bedtime',
] as any}
]}
labelFormatter={(label) => label}
/>
<Line
@@ -91,4 +85,4 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
);
};
export default BedtimePlot;
export default BedtimePlot;
@@ -1,48 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { useStats } from '../../hooks/useStats';
import { formatMinutes } from '../../utils/time';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts';
const DailySummary = ({ from_date, to_date, day_start, day_end }) => {
const { dailySummary, isLoading } = useStats(from_date, to_date, day_start, day_end);
const [chartData, setChartData] = useState([]);
const [avgStats, setAvgStats] = useState({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 });
useEffect(() => {
if (!dailySummary?.days) {
setChartData([]);
setAvgStats({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 });
return;
}
const data = dailySummary.days.map((day) => ({
date: day.date,
daySleep: day.day_minutes / 60,
nightSleep: day.night_minutes / 60,
total: day.total_minutes / 60,
}));
setChartData(data.reverse());
if (data.length) {
const totals = data.reduce((acc, d) => ({
total: acc.total + d.total,
day: acc.day + d.daySleep,
night: acc.night + d.nightSleep,
}), { total: 0, day: 0, night: 0 });
const count = data.length;
setAvgStats({
avgTotal: totals.total / count,
avgDay: totals.day / count,
avgNight: totals.night / count,
count,
});
}
}, [dailySummary]);
if (isLoading)
return _jsx("div", { className: "text-center p-4", children: "Loading summary..." });
if (!chartData.length) {
return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." });
}
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: "Daily Summary" }), _jsx(ResponsiveContainer, { width: "100%", height: Math.max(200, chartData.length * 30), children: _jsxs(BarChart, { data: chartData, layout: "vertical", margin: { top: 5, right: 30, left: 60, bottom: 5 }, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: "#e5e7eb" }), _jsx(XAxis, { type: "number", tickFormatter: (value) => `${Math.round(value)}h`, tick: { fontSize: 12 }, tickLine: false }), _jsx(YAxis, { type: "category", dataKey: "date", tick: { fontSize: 12 }, tickLine: false, width: 60 }), _jsx(Tooltip, { formatter: (value, name) => [
formatMinutes(Math.round((value ?? 0) * 60)),
name === 'daySleep' ? 'Day' : 'Night',
] }), _jsx(Bar, { dataKey: "nightSleep", stackId: "a", fill: "#1e3a5f" }), _jsx(Bar, { dataKey: "daySleep", stackId: "a", fill: "#fbbf24" })] }) }), _jsxs("div", { className: "mt-4 grid grid-cols-3 gap-4 text-center", children: [_jsxs("div", { className: "p-3 bg-gray-50 rounded-lg", children: [_jsx("div", { className: "text-xs text-gray-500", children: "Avg Total" }), _jsx("div", { className: "text-lg font-semibold text-gray-800", children: formatMinutes(Math.round(avgStats.avgTotal * 60)) })] }), _jsxs("div", { className: "p-3 bg-gray-50 rounded-lg", children: [_jsx("div", { className: "text-xs text-gray-500", children: "Avg Day" }), _jsx("div", { className: "text-lg font-semibold text-gray-800", children: formatMinutes(Math.round(avgStats.avgDay * 60)) })] }), _jsxs("div", { className: "p-3 bg-gray-50 rounded-lg", children: [_jsx("div", { className: "text-xs text-gray-500", children: "Avg Night" }), _jsx("div", { className: "text-lg font-semibold text-gray-800", children: formatMinutes(Math.round(avgStats.avgNight * 60)) })] })] })] }));
};
export default DailySummary;
+19 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useStats } from '../../hooks/useStats';
import { formatMinutes } from '../../utils/time';
import {
@@ -9,9 +9,9 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
interface DailySummaryProps {
from_date: string;
to_date: string;
@@ -26,16 +26,19 @@ interface ChartDataItem {
total: number;
}
interface AvgStats {
avgTotal: number;
avgDay: number;
avgNight: number;
count: number;
}
const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_start, day_end }) => {
const { dailySummary, isLoading } = useStats(from_date, to_date, day_start, day_end);
const [chartData, setChartData] = useState<ChartDataItem[]>([]);
const [avgStats, setAvgStats] = useState({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 });
useEffect(() => {
const { chartData, avgStats } = useMemo<{ chartData: ChartDataItem[]; avgStats: AvgStats }>(() => {
if (!dailySummary?.days) {
setChartData([]);
setAvgStats({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 });
return;
return { chartData: [] as ChartDataItem[], avgStats: { avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 } };
}
const data = dailySummary.days.map((day) => ({
@@ -43,9 +46,9 @@ const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_sta
daySleep: day.day_minutes / 60,
nightSleep: day.night_minutes / 60,
total: day.total_minutes / 60,
}));
})).reverse();
setChartData(data.reverse());
let averages = { avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 };
if (data.length) {
const totals = data.reduce(
@@ -57,13 +60,15 @@ const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_sta
{ total: 0, day: 0, night: 0 }
);
const count = data.length;
setAvgStats({
averages = {
avgTotal: totals.total / count,
avgDay: totals.day / count,
avgNight: totals.night / count,
count,
});
};
}
return { chartData: data, avgStats: averages };
}, [dailySummary]);
if (isLoading) return <div className="text-center p-4">Loading summary...</div>;
@@ -95,10 +100,10 @@ const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_sta
width={60}
/>
<Tooltip
formatter={(value: any, name: any) => [
formatter={(value: number, name: string) => [
formatMinutes(Math.round((value ?? 0) * 60)),
name === 'daySleep' ? 'Day' : 'Night',
] as any}
]}
/>
<Bar dataKey="nightSleep" stackId="a" fill="#1e3a5f" />
<Bar dataKey="daySleep" stackId="a" fill="#fbbf24" />
@@ -1,7 +0,0 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
const INTERVALS = [7, 14, 30, 90];
export function IntervalPicker({ value, onChange }) {
return (_jsx("div", { className: "flex justify-center space-x-1 bg-gray-100 p-1 rounded-lg", children: INTERVALS.map((days) => (_jsxs("button", { onClick: () => onChange(days), className: `px-4 py-2 rounded-md text-sm font-medium transition-all ${value === days
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}`, children: [days, "d"] }, days))) }));
}
@@ -1,61 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { useSessions } from '../../hooks/useSleepSession';
import { Edit2, Check, X } from 'lucide-react';
function formatDateTime(isoString) {
const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z';
const d = new Date(iso);
return d.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(',', ' ');
}
function toDatetimeLocal(isoString) {
const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z';
const d = new Date(iso);
const pad = (n) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function SessionList() {
const { sessions, isLoading, updateSession } = useSessions();
const [editingId, setEditingId] = useState(null);
const [editStart, setEditStart] = useState('');
const [editEnd, setEditEnd] = useState('');
if (isLoading)
return _jsx("div", { className: "text-center p-4", children: "Loading sessions..." });
if (!sessions.length)
return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No sessions recorded." });
const handleSave = async (id) => {
try {
await updateSession({
sessionId: id,
payload: {
start_time: new Date(editStart).toISOString(),
end_time: editEnd ? new Date(editEnd).toISOString() : null,
}
});
setEditingId(null);
}
catch (err) {
console.error(err);
alert('Failed to update session');
}
};
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
? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min`
: '—' }), _jsx(Edit2, { size: 14, className: "text-gray-400" })] })] })) }, s.id))) })] }));
}
@@ -21,7 +21,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);
@@ -1,37 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useRef, useEffect } from 'react';
export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }) {
const [isOpen, setIsOpen] = useState(false);
const [localDayStart, setLocalDayStart] = useState(dayStart);
const [localDayEnd, setLocalDayEnd] = useState(dayEnd);
const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef);
const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef);
const popoverRef = useRef(null);
useEffect(() => {
setLocalDayStart(dayStart);
setLocalDayEnd(dayEnd);
setLocalWakeupRef(wakeupRef);
setLocalBedtimeRef(bedtimeRef);
}, [dayStart, dayEnd, wakeupRef, bedtimeRef]);
useEffect(() => {
const handleClickOutside = (e) => {
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const handleSave = () => {
onSave({
dayStart: localDayStart,
dayEnd: localDayEnd,
wakeupRef: localWakeupRef,
bedtimeRef: localBedtimeRef,
});
setIsOpen(false);
};
return (_jsxs("div", { className: "relative", ref: popoverRef, children: [_jsx("button", { onClick: () => setIsOpen(!isOpen), className: "p-2 text-gray-500 hover:text-gray-700", "aria-label": "Settings", children: _jsxs("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: [_jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" }), _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" })] }) }), isOpen && (_jsxs("div", { className: "absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-50", children: [_jsx("h4", { className: "font-semibold text-gray-800 mb-4", children: "Settings" }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "day-start", className: "block text-xs text-gray-500 mb-1", children: "Day starts (HH:MM)" }), _jsx("input", { id: "day-start", type: "time", value: localDayStart, onChange: (e) => setLocalDayStart(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "day-end", className: "block text-xs text-gray-500 mb-1", children: "Day ends (HH:MM)" }), _jsx("input", { id: "day-end", type: "time", value: localDayEnd, onChange: (e) => setLocalDayEnd(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "wakeup-ref", className: "block text-xs text-gray-500 mb-1", children: "Wakeup ref (HH:MM)" }), _jsx("input", { id: "wakeup-ref", type: "time", value: localWakeupRef, onChange: (e) => setLocalWakeupRef(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "bedtime-ref", className: "block text-xs text-gray-500 mb-1", children: "Bedtime ref (HH:MM)" }), _jsx("input", { id: "bedtime-ref", type: "time", value: localBedtimeRef, onChange: (e) => setLocalBedtimeRef(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] })] }), _jsx("button", { onClick: handleSave, className: "w-full mt-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700", children: "Save" })] }))] }));
}
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/set-state-in-effect */
import React, { useState, useRef, useEffect } from 'react';
interface SettingsPopoverProps {
@@ -1,63 +0,0 @@
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;
@@ -16,7 +16,7 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
const start = new Date(from_date + 'T00:00:00');
const end = new Date(to_date + 'T23:59:59');
const result: string[] = [];
let current = new Date(start);
const current = new Date(start);
while (current <= end) {
result.push(current.toLocaleDateString('en-CA'));
current.setDate(current.getDate() + 1);
@@ -47,8 +47,8 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
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);
const sStart = timeToMinutes(slot.slot_start);
const sEnd = timeToMinutes(slot.slot_end);
if (sEnd <= sStart) {
// Handle wrap-around within a single slot if it happens (though unlikely for 15m segments)
@@ -1,23 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { IntervalPicker } from './IntervalPicker';
import { SettingsPopover } from './SettingsPopover';
import DailySummary from './DailySummary';
import Timetable from './SleepTimetable';
import WakeupPlot from './WakeupPlot';
import BedtimePlot from './BedtimePlot';
import { SessionList } from './SessionList';
import { ErrorBoundary } from '../../components/ErrorBoundary';
import { useSettings } from '../../hooks/useSettings';
const StatsPage = () => {
const { settings, update } = useSettings();
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);
update('wakeupRef', s.wakeupRef);
update('bedtimeRef', s.bedtimeRef);
} })] }), _jsx("div", { className: "bg-white p-6 rounded-[2rem] shadow-md border border-gray-100", children: _jsx(IntervalPicker, { value: settings.statsInterval, onChange: (days) => update('statsInterval', days) }) }), _jsx(ErrorBoundary, { children: _jsx(DailySummary, { from_date: from_date, to_date: to_date, day_start: settings.dayStart, day_end: settings.dayEnd }) }), _jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-6", children: [_jsx(ErrorBoundary, { children: _jsx(WakeupPlot, { from_date: from_date, to_date: to_date, wakeup_ref: settings.wakeupRef, bedtime_ref: settings.bedtimeRef }) }), _jsx(ErrorBoundary, { children: _jsx(BedtimePlot, { from_date: from_date, to_date: to_date, wakeup_ref: settings.wakeupRef, bedtime_ref: settings.bedtimeRef }) })] }), _jsx(ErrorBoundary, { children: _jsx(Timetable, { from_date: from_date, to_date: to_date, day_start: settings.dayStart, day_end: settings.dayEnd }) }), _jsx(ErrorBoundary, { children: _jsx(SessionList, {}) })] }));
};
export default StatsPage;
@@ -1,37 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { useStats } from '../../hooks/useStats';
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);
const [chartData, setChartData] = useState([]);
useEffect(() => {
if (!wakeupBedtime?.days) {
setChartData([]);
return;
}
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: offsetFromRef,
time: day.wakeup_minutes !== null
? formatMinutesAfter(offsetFromRef, wakeup_ref)
: 'N/A',
};
}).reverse();
setChartData(data);
}, [wakeupBedtime, wakeup_ref]);
if (isLoading)
return _jsx("div", { className: "text-center p-4", children: "Loading plot..." });
if (!chartData.length) {
return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." });
}
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: "Wakeup Times" }), _jsx(ResponsiveContainer, { width: "100%", height: 200, children: _jsxs(LineChart, { data: chartData, margin: { top: 5, right: 20, left: 0, bottom: 5 }, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: "#e5e7eb" }), _jsx(XAxis, { dataKey: "date", tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' } }), _jsx(YAxis, { tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' }, tickFormatter: (value) => formatMinutesAfter(value, wakeup_ref), domain: ['dataMin - 30', 'dataMax + 30'] }), _jsx(Tooltip, { formatter: (value) => [
formatMinutesAfter(value ?? 0, wakeup_ref),
'Wakeup',
], labelFormatter: (label) => label }), _jsx(Line, { type: "monotone", dataKey: "minutes", stroke: "#3b82f6", strokeWidth: 2, dot: { fill: '#3b82f6', r: 4 }, connectNulls: false })] }) })] }));
};
export default WakeupPlot;
+21 -27
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useStats } from '../../hooks/useStats';
import { formatMinutesAfter, getToday, getLocalOffsetFromReference } from '../../utils/time';
import { formatMinutesAfter, getToday } from '../../utils/time';
import {
LineChart,
Line,
@@ -20,32 +20,26 @@ interface WakeupBedtimeProps {
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 }[]>([]);
useEffect(() => {
if (!wakeupBedtime?.days) {
setChartData([]);
return;
}
const chartData = useMemo(() => {
if (!wakeupBedtime?.days) return [];
const data = wakeupBedtime.days.map((day) => {
const daysAgo = Math.floor(
(new Date(getToday()).getTime() - new Date(day.date).getTime()) / (1000 * 60 * 60 * 24)
);
return 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 ?? 0;
const offsetFromRef = day.wakeup_minutes ?? 0;
return {
date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`,
minutes: offsetFromRef,
time:
day.wakeup_minutes !== null
? formatMinutesAfter(offsetFromRef, wakeup_ref)
: 'N/A',
};
}).reverse();
setChartData(data);
return {
date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`,
minutes: offsetFromRef,
time:
day.wakeup_minutes !== null
? formatMinutesAfter(offsetFromRef, wakeup_ref)
: 'N/A',
};
}).reverse();
}, [wakeupBedtime, wakeup_ref]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
@@ -73,10 +67,10 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: any) => [
formatter={(value: number) => [
formatMinutesAfter(value, wakeup_ref),
'Wakeup',
] as any}
]}
labelFormatter={(label) => label}
/>
<Line
@@ -89,7 +83,7 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
/>
</LineChart>
</ResponsiveContainer>
</div>
</div >
);
};
@@ -1,23 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useSleepSession } from '../../hooks/useSleepSession';
const SleepButton = ({ time }) => {
const { activeSession, createSession, endSession, isCreating, isEnding } = useSleepSession();
const isSleeping = !!activeSession;
const handleClick = async () => {
try {
if (isSleeping && activeSession?.id) {
await endSession({ sessionId: activeSession.id, endTime: time });
}
else {
await createSession({ start_time: time });
}
}
catch (error) {
console.error('Failed to update sleep session:', error?.message || error);
}
};
return (_jsx("button", { onClick: handleClick, disabled: isCreating || isEnding, className: `w-full py-8 px-8 rounded-3xl text-2xl font-extrabold shadow-xl transition-all active:scale-95 flex items-center justify-center ${isSleeping
? 'bg-rose-500 text-white hover:bg-rose-600 shadow-rose-200'
: 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200'} ${isCreating || isEnding ? 'opacity-50 cursor-not-allowed' : ''}`, children: isCreating || isEnding ? (_jsxs("div", { className: "flex items-center space-x-2", children: [_jsx("span", { className: "animate-spin rounded-full h-6 w-6 border-b-2 border-white" }), _jsx("span", { children: "Processing..." })] })) : isSleeping ? ('Stop Sleep') : ('Start Sleep') }));
};
export default SleepButton;
@@ -16,9 +16,9 @@ const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
} else {
await createSession({ start_time: time });
}
} catch (error: any) {
console.error('Failed to update sleep session:', error?.message || error);
}
} catch (error: unknown) {
console.error('Failed to update sleep session:', error instanceof Error ? error.message : error);
}
};
return (
@@ -1,27 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
import { useSleepSession } from '../../hooks/useSleepSession';
import { formatElapsedTime } from '../../utils/time';
const StatusBanner = () => {
const { activeSession, isLoading } = useSleepSession();
const [elapsed, setElapsed] = useState('');
useEffect(() => {
if (!activeSession) {
setElapsed('');
return;
}
const updateElapsed = () => {
setElapsed(formatElapsedTime(activeSession.start_time));
};
updateElapsed();
const interval = setInterval(updateElapsed, 1000);
return () => clearInterval(interval);
}, [activeSession?.start_time]);
if (isLoading)
return _jsx("div", { className: "p-4 text-center", children: "Loading..." });
if (activeSession) {
return (_jsxs("div", { className: "bg-amber-50 border border-amber-200 text-amber-900 p-4 rounded-2xl shadow-sm mb-4 flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center space-x-3", children: [_jsx("span", { className: "text-2xl", children: "\uD83D\uDE34" }), _jsxs("div", { children: [_jsx("p", { className: "font-semibold leading-tight", children: "Baby is sleeping" }), elapsed && _jsxs("p", { className: "text-sm text-amber-700 font-mono", children: ["(", elapsed, ")"] })] })] }), _jsx("div", { className: "h-2 w-2 bg-amber-500 rounded-full animate-pulse" })] }));
}
return (_jsx("div", { className: "bg-blue-50 border border-blue-200 text-blue-900 p-4 rounded-2xl shadow-sm mb-4 flex items-center justify-between", children: _jsxs("div", { className: "flex items-center space-x-3", children: [_jsx("span", { className: "text-2xl", children: "\uD83D\uDC76" }), _jsx("div", { children: _jsx("p", { className: "font-semibold leading-tight", children: "Baby is awake" }) })] }) }));
};
export default StatusBanner;
@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from 'react';
import { useSleepSession } from '../../hooks/useSleepSession';
import { formatElapsedTime } from '../../utils/time';
@@ -19,7 +20,7 @@ const StatusBanner = () => {
updateElapsed();
const interval = setInterval(updateElapsed, 1000);
return () => clearInterval(interval);
}, [activeSession?.start_time]);
}, [activeSession?.start_time, activeSession]);
if (isLoading) return <div className="p-4 text-center">Loading...</div>;
@@ -46,7 +47,7 @@ const StatusBanner = () => {
<p className="font-semibold leading-tight">Baby is awake</p>
</div>
</div>
</div>
</div >
);
};
@@ -1,32 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
function toLocalDatetimeInputValue(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function toUTCFromLocalInput(inputValue) {
const [datePart, timePart] = inputValue.split('T');
const [year, month, day] = datePart.split('-').map(Number);
const [hours, minutes] = timePart.split(':').map(Number);
const localDate = new Date(year, month - 1, day, hours, minutes);
return localDate.toISOString();
}
const TimeAdjuster = ({ onTimeChange, defaultValue }) => {
const [value, setValue] = useState(() => {
if (defaultValue) {
const d = new Date(defaultValue);
return toLocalDatetimeInputValue(d);
}
return toLocalDatetimeInputValue(new Date());
});
useEffect(() => {
onTimeChange(toUTCFromLocalInput(value));
}, [value, onTimeChange]);
const resetToNow = () => {
const now = toLocalDatetimeInputValue(new Date());
setValue(now);
onTimeChange(toUTCFromLocalInput(now));
};
return (_jsxs("div", { className: "flex flex-col items-center space-y-3 w-full", children: [_jsx("div", { className: "relative w-full max-w-[280px]", children: _jsx("input", { type: "datetime-local", value: value, onChange: (e) => setValue(e.target.value), className: "w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-lg font-medium transition-all outline-none" }) }), _jsx("button", { onClick: resetToNow, className: "text-sm font-medium text-indigo-600 hover:text-indigo-500 transition-colors", children: "Reset to now" })] }));
};
export default TimeAdjuster;
@@ -1,15 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import StatusBanner from './StatusBanner';
import SleepButton from './SleepButton';
import TimeAdjuster from './TimeAdjuster';
const TrackerPage = () => {
const getLocalTime = () => {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
};
const [time, setTime] = useState(getLocalTime());
return (_jsxs("div", { className: "max-w-md mx-auto pb-12", children: [_jsx(StatusBanner, {}), _jsxs("div", { className: "bg-white p-8 rounded-[2.5rem] shadow-xl border border-gray-100 space-y-8", children: [_jsxs("div", { className: "space-y-4 text-center", children: [_jsx("h2", { className: "text-xl font-bold text-gray-800", children: "Set Sleep Time" }), _jsx("p", { className: "text-gray-500 text-sm", children: "Adjust the time if you are logging a session that already started." })] }), _jsx("div", { className: "pt-4", children: _jsx(TimeAdjuster, { onTimeChange: setTime, defaultValue: time }) }), _jsx("div", { className: "pt-4", children: _jsx(SleepButton, { time: time }) })] })] }));
};
export default TrackerPage;
-32
View File
@@ -1,32 +0,0 @@
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'baby-sleep-settings';
const DEFAULT_SETTINGS = {
dayStart: '07:00',
dayEnd: '20:00',
wakeupRef: '04:00',
bedtimeRef: '19:00',
statsInterval: 7,
};
export function useSettings() {
const [settings, setSettings] = useState(() => {
if (typeof window === 'undefined')
return DEFAULT_SETTINGS;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
}
catch {
return DEFAULT_SETTINGS;
}
}
return DEFAULT_SETTINGS;
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const update = (key, value) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
return { settings, update };
}
-60
View File
@@ -1,60 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchActiveSession, createSession, endSession, fetchSessions, deleteSession, updateSession } from '../api/client';
export function useSleepSession() {
const queryClient = useQueryClient();
const activeSessionQuery = useQuery({
queryKey: ['activeSession'],
queryFn: fetchActiveSession,
refetchInterval: 30000, // Poll every 30 seconds
});
const createMutation = useMutation({
mutationFn: createSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activeSession'] });
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const endMutation = useMutation({
mutationFn: ({ sessionId, endTime }) => endSession(sessionId, { end_time: endTime }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activeSession'] });
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
return {
activeSession: activeSessionQuery.data,
isLoading: activeSessionQuery.isLoading,
isFetching: activeSessionQuery.isFetching,
createSession: createMutation.mutateAsync,
endSession: endMutation.mutateAsync,
isCreating: createMutation.isPending,
isEnding: endMutation.isPending,
};
}
export function useSessions() {
const queryClient = useQueryClient();
const sessionsQuery = useQuery({
queryKey: ['sessions'],
queryFn: () => fetchSessions(),
});
const deleteMutation = useMutation({
mutationFn: deleteSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const updateMutation = useMutation({
mutationFn: ({ sessionId, payload }) => updateSession(sessionId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
return {
sessions: sessionsQuery.data?.sessions || [],
total: sessionsQuery.data?.total || 0,
isLoading: sessionsQuery.isLoading,
isDeleting: deleteMutation.isPending,
deleteSession: deleteMutation.mutateAsync,
updateSession: updateMutation.mutateAsync,
};
}
+7 -7
View File
@@ -54,13 +54,13 @@ export function useSessions() {
},
});
const updateMutation = useMutation({
mutationFn: ({ sessionId, payload }: { sessionId: number; payload: any }) =>
updateSession(sessionId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const updateMutation = useMutation({
mutationFn: ({ sessionId, payload }: { sessionId: number; payload: import('../types').SleepSessionUpdate }) =>
updateSession(sessionId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
return {
sessions: sessionsQuery.data?.sessions || [],
-22
View File
@@ -1,22 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client';
export function useStats(from_date, to_date, day_start = '07:00', day_end = '20:00', wakeup_ref = '04:00', bedtime_ref = '19:00') {
const dailySummaryQuery = useQuery({
queryKey: ['dailySummary', from_date, to_date, day_start, day_end],
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end),
});
const timetableQuery = useQuery({
queryKey: ['timetable', from_date, to_date, day_start, day_end],
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end),
});
const wakeupBedtimeQuery = useQuery({
queryKey: ['wakeSleepTimes', from_date, to_date, wakeup_ref, bedtime_ref],
queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref),
});
return {
dailySummary: dailySummaryQuery.data,
timetable: timetableQuery.data,
wakeupBedtime: wakeupBedtimeQuery.data,
isLoading: dailySummaryQuery.isLoading || timetableQuery.isLoading || wakeupBedtimeQuery.isLoading,
};
}
-8
View File
@@ -1,8 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(_jsx(React.StrictMode, { children: _jsx(QueryClientProvider, { client: queryClient, children: _jsx(App, {}) }) }));
-1
View File
@@ -1 +0,0 @@
export {};
-109
View File
@@ -1,109 +0,0 @@
/**
* Chart data transformation utilities for baby sleep tracker
*/
/**
* Build timetable slots for visualization
* @param slots - raw timetable slots from API
* @returns grouped slots by date
*/
export function buildTimetableSlots(slots) {
return slots.reduce((acc, slot) => {
if (!acc[slot.date]) {
acc[slot.date] = [];
}
acc[slot.date].push(slot);
return acc;
}, {});
}
/**
* Transform wakeup minutes for Recharts LineChart
* @param days - wakeup/bedtime data from API
* @returns array formatted for LineChart
*/
export function computeWakeupMinutes(days, wakeupRef = "04:00") {
return days.map((day) => ({
date: day.date,
minutes: day.wakeup_minutes ?? 0,
time: day.wakeup_minutes !== null
? formatMinutesAfter(day.wakeup_minutes, wakeupRef)
: "N/A",
}));
}
/**
* Transform bedtime minutes for Recharts LineChart
* @param days - wakeup/bedtime data from API
* @returns array formatted for LineChart
*/
export function computeBedtimeMinutes(days, bedtimeRef = "19:00") {
return days.map((day) => ({
date: day.date,
minutes: day.bedtime_minutes ?? 0,
time: day.bedtime_minutes !== null
? formatMinutesAfter(day.bedtime_minutes, bedtimeRef)
: "N/A",
}));
}
/**
* Transform daily summary for Recharts BarChart (vertical layout)
* @param days - daily summary data from API
* @returns array formatted for BarChart
*/
export function computeDailySummaryData(days) {
return days.map((day) => ({
date: day.date,
daySleep: day.day_minutes,
nightSleep: day.night_minutes,
total: day.total_minutes,
}));
}
/**
* Calculate average summary stats from daily data
* @param days - daily summary data from API
* @returns averages for total, day, and night sleep
*/
export function computeAverageStats(days) {
if (!days.length) {
return { avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 };
}
const totals = days.reduce((acc, day) => ({
total: acc.total + day.total_minutes,
day: acc.day + day.day_minutes,
night: acc.night + day.night_minutes,
}), { total: 0, day: 0, night: 0 });
const count = days.length;
return {
avgTotal: Math.round(totals.total / count),
avgDay: Math.round(totals.day / count),
avgNight: Math.round(totals.night / count),
count,
};
}
/**
* Convert minutes after reference to clock time string
* Internal helper - kept for chartHelpers module
*/
function formatMinutesAfter(minutes, reference) {
const [refHours, refMinutes] = reference.split(":").map(Number);
const totalRefMinutes = refHours * 60 + refMinutes;
const targetMinutes = totalRefMinutes + minutes;
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
}
/**
* Reverse date order (newest first for charts)
* @param days - array of day data
* @returns reversed array
*/
export function reverseDateOrder(days) {
return [...days].reverse();
}
/**
* Filter days to only include those with data
* @param days - array of day data
* @returns filtered array
*/
export function filterDaysWithData(days) {
return days.filter((day) => (day.total_minutes ?? 0) > 0);
}
-137
View File
@@ -1,137 +0,0 @@
/**
* Time formatting utilities for baby sleep tracker
*/
/**
* Convert minutes after a reference time to clock time string
* @param minutes - minutes after reference time (can be negative)
* @param reference - reference time as HH:MM string
* @returns formatted time string like "07:15"
*/
export function formatMinutesAfter(minutes, reference = "04:00") {
if (minutes === null)
return "N/A";
const [refHours, refMinutes] = reference.split(":").map(Number);
const totalRefMinutes = refHours * 60 + refMinutes;
const targetMinutes = totalRefMinutes + minutes;
// Handle wrap around midnight
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
const hours = Math.floor(normalizedMinutes / 60);
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
* @returns string in "YYYY-MM-DDTHH:MM" format
*/
export function toLocalDatetimeInputValue(date) {
const offset = date.getTimezoneOffset() * 60 * 1000;
const localDate = new Date(date.getTime() - offset);
return localDate.toISOString().slice(0, 16);
}
export function formatElapsedTime(utcTimestamp) {
let isoString = utcTimestamp;
if (utcTimestamp.includes(' ')) {
isoString = utcTimestamp.replace(' ', 'T');
}
if (!isoString.endsWith('Z')) {
isoString += 'Z';
}
const then = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - then.getTime();
const diffMins = Math.floor(diffMs / 60000);
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours > 0)
return `${hours}h ${mins}m`;
return `${mins}m`;
}
/**
* Clamp a date to day boundaries (start or end of day)
* @param date - Date to clamp
* @param bound - 'start' for 00:00, 'end' for 23:59:59.999
* @returns new Date clamped to day boundary
*/
export function clampToDay(date, bound) {
const result = new Date(date);
if (bound === "start") {
result.setHours(0, 0, 0, 0);
}
else {
result.setHours(23, 59, 59, 999);
}
return result;
}
/**
* Parse HH:MM time string to Date on a given day
* @param timeStr - HH:MM format
* @param baseDate - base date (defaults to today)
* @returns Date object
* @throws Error if timeStr is invalid
*/
export function parseTime(timeStr, baseDate = new Date()) {
const [hours, minutes] = timeStr.split(":").map(Number);
const result = new Date(baseDate);
result.setHours(hours, minutes, 0, 0);
return result;
}
/**
* Get date string for N days ago
* @param days - number of days ago
* @returns ISO date string (YYYY-MM-DD)
*/
export function getDaysAgo(days) {
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString().split("T")[0];
}
/**
* Get date string for today
* @returns ISO date string (YYYY-MM-DD)
*/
export function getToday() {
return new Date().toISOString().split("T")[0];
}
/**
* Convert HH:MM time string to total minutes from start of day
* @param timeStr - HH:MM format
* @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
* @returns Formatted string
*/
export function formatMinutes(totalMinutes) {
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
}
-1
View File
@@ -1,5 +1,4 @@
import type { Config } from 'tailwindcss';
import tailwindcss from '@tailwindcss/vite';
export default {
content: [
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { test, expect } from '@playwright/test';
test.describe('Sleep Timetable Timezone Alignment', () => {
test.use({
timezoneId: 'America/Los_Angeles',
@@ -78,9 +80,6 @@ test.describe('Sleep Timetable Timezone Alignment', () => {
if (markerBox) {
// The marker is at 08:00, which is (8*60)/1440 = 33.33% from the top of the container
// We can check if the cell at that height is green.
const timetableContainer = page.locator('.relative');
// Note: 'relative' is the container for the grid and marker.
// We look for a green cell that intersects with the red marker's Y position.
const allGreenCells = page.locator('.bg-green-500');
const count = await allGreenCells.count();
let foundAlignedCell = false;
+2
View File
@@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import SleepButton from '../../src/components/tracker/SleepButton';
@@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import StatusBanner from '../../src/components/tracker/StatusBanner';
@@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { vi, describe, it, expect } from 'vitest';
import Timetable from '../../../../src/components/stats/SleepTimetable';
@@ -78,7 +80,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');
const greenCells = container.querySelectorAll('.bg-green-500');
expect(greenCells.length).toBeGreaterThan(0);