Slop cleanup
This commit is contained in:
@@ -317,3 +317,8 @@ frontend/test-results
|
||||
frontend/playwright-report
|
||||
backend/data
|
||||
.aider*
|
||||
|
||||
# Additional patterns
|
||||
blob-report/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Generated
+3843
-9
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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] }))] }));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
export {};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user