Compare commits
3 Commits
v1.0.0
..
c371a3d8e8
| Author | SHA1 | Date | |
|---|---|---|---|
| c371a3d8e8 | |||
| 13bc53ea59 | |||
| cdc57a441b |
@@ -1,13 +1,12 @@
|
|||||||
# PROJECT KNOWLEDGE BASE
|
# PROJECT BABY MONITOR
|
||||||
|
|
||||||
**Generated:** 2026-04-23
|
|
||||||
**Mode:** init-deep (create-new)
|
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
|
||||||
Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack monorepo: React 19/Vite 6/TypeScript frontend + Python 3.14/FastAPI backend. Optimized for Raspberry Pi 4 (ARM64).
|
Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorepo: React 19/Vite 6/TypeScript frontend + Python 3.14/FastAPI backend. Optimized for Raspberry Pi 4 (ARM64).
|
||||||
|
|
||||||
## STRUCTURE
|
## STRUCTURE
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
./
|
./
|
||||||
├── frontend/ # React 19 SPA
|
├── frontend/ # React 19 SPA
|
||||||
@@ -21,6 +20,7 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
|
|||||||
│ │ └── utils/
|
│ │ └── utils/
|
||||||
│ └── tests/ # e2e (Playwright), unit (Vitest)
|
│ └── tests/ # e2e (Playwright), unit (Vitest)
|
||||||
├── backend/ # FastAPI + SQLModel
|
├── backend/ # FastAPI + SQLModel
|
||||||
|
│ ├── scripts/ # one-off scripts for migrations / fixes / etc
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── routers/ # API endpoints
|
│ │ ├── routers/ # API endpoints
|
||||||
│ │ └── utils/
|
│ │ └── utils/
|
||||||
@@ -40,37 +40,68 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
|
|||||||
|
|
||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
|
|
||||||
- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio
|
- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio.
|
||||||
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet)
|
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet).
|
||||||
- **Python**: >=3.14 required, Hatch for packaging
|
- **Python**: >=3.14 required. Managed via `uv` with Hatch backend for packaging.
|
||||||
- **Scripts**: All scripts created in the `backend` folder must be placed in `backend/scripts/`.
|
- **Scripts**: All scripts created in the backend folder must be placed explicitly in `backend/scripts/`.
|
||||||
- **No CI/CD**: No .github/workflows, no Makefiles yet
|
- **CI/CD**: Deployment is managed via Gitea workflows (`.gitea/` directory). Do not create `.github/` directories or Makefiles.
|
||||||
|
|
||||||
## ANTI-PATTERNS (THIS PROJECT)
|
## ANTI-PATTERNS (CRITICAL GUARDRAILS)
|
||||||
|
|
||||||
- No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`) - type safety enforced
|
- **Type Safety**: No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`). Type safety is strictly enforced.
|
||||||
- No empty catch blocks - error handling required
|
- **Error Handling**: No empty catch blocks. Explicit error handling/logging is required.
|
||||||
- No deleting failing tests to "pass"
|
- **Testing Integrity**: NEVER delete or comment out failing tests to make a suite "pass". Fix the code or update the test properly.
|
||||||
- **Frontend JS Slop**: NEVER create `.js` files for the frontend. Use only TypeScript (`.ts`, `.tsx`) and write high-quality, type-safe code. Do not repeat this mistake.
|
- **Frontend JS Slop**: NEVER create `.js` or `.jsx` files for the frontend. Use only TypeScript (`.ts`, `.tsx`). Write clean, type-safe code.
|
||||||
- **Assumption Error**: NEVER assume the content of ANY files EVER. ALWAYS read the file to confirm. Do not rely on the name.
|
- **Assumption Error**: NEVER assume the content of ANY files. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it.
|
||||||
|
|
||||||
## COMMANDS
|
## COMMANDS
|
||||||
|
|
||||||
|
### Backend Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
cd backend
|
||||||
cd backend && uv sync
|
uv sync
|
||||||
uv run uvicorn app.main:app --reload
|
uv run uvicorn app.main:app --reload
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
# Frontend
|
### Frontend Development
|
||||||
cd frontend && npm install
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
# Use 'npm test' with caution as it may hang; use specific test scripts instead.
|
|
||||||
|
# WARNING: 'npm test' may hang if run globally. Use specific file paths or vitest flags.
|
||||||
npm run test
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🛑 DIRECTORY NAVIGATION & FILE DISCOVERY
|
||||||
|
|
||||||
|
**Context:** This is a full-stack monorepo containing massive generated/dependency folders (`node_modules`, `dist`, `venv`, `.venv`, `__pycache__`, etc.). Unrestricted file listings will bloat your context window, crash your session, and cause severe token waste.
|
||||||
|
|
||||||
|
* **NEVER use `ls -R`:** This command is strictly forbidden in this repository.
|
||||||
|
* **Primary Discovery:** To map out files or explore the project tree, **ALWAYS use `git ls-files**`. This is your default command and ensures you only interact with human-written source code.
|
||||||
|
* **Structural Context:** If you explicitly need a visual directory layout to understand architecture, use `tree` appended with the mandatory ignore flags:
|
||||||
|
```bash
|
||||||
|
tree -I 'node_modules|.git|dist|build|__pycache__|venv|.venv|env|.pytest_cache|.mypy_cache|.ruff_cache|*.egg-info'
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
* **Searching Code:** If you need to search for text or code blocks across the repo, **ALWAYS use `git grep "search_term"**` instead of standard `grep -r` to avoid crawling ignored or minified assets.
|
||||||
|
|
||||||
|
## 🧠 TASK DELEGATION & CONTEXT PRESERVATION
|
||||||
|
|
||||||
|
**Context:** The main OpenCode agent context window is our most critical resource. Executing complex tasks, debugging cross-file issues, or writing massive features directly in the main thread causes severe context bloat, leading to code hallucinations and dropped instructions.
|
||||||
|
|
||||||
|
* **Delegate Aggressively:** For any complex implementation, structural change, or task larger than a single file edit, you **MUST spawn a sub-agent**. Act strictly as the architectural orchestrator; let sub-agents handle the iterative coding labor.
|
||||||
|
* **Scope Narrowly:** Give sub-agents highly specific, isolated, and atomic objectives (e.g., *"Read `backend/app/routers/auth.py` and summarize endpoints"* or *"Write a unit test for the frontend sleep tracker component"*).
|
||||||
|
* **Strict Summarization:** Sub-agents are **STRICTLY FORBIDDEN** from passing raw terminal dumps, unedited file blocks, or their internal step-by-step logs back to the main thread.
|
||||||
|
* **Return Format:** Sub-agents must return *only* clear, actionable results back to the orchestrator: a concise summary of findings, the exact lines of code that require modification, or direct confirmation of a successful execution.
|
||||||
|
|
||||||
## NOTES
|
## NOTES
|
||||||
|
|
||||||
- Entry point: `frontend/src/main.tsx` + `backend/app/main.py`
|
* **Entry Points:** `frontend/src/main.tsx` (Frontend) & `backend/app/main.py` (Backend).
|
||||||
- DB: SQLite via aiosqlite (async-first backend)
|
* **Database:** Local SQLite managed via `aiosqlite` for an entirely async-first data layer.
|
||||||
- Build: Docker for ARM64 Raspberry Pi deployment
|
* **Target Architecture:** Built specifically for ARM64 deployment on a Raspberry Pi 4 via Docker.
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx
|
||||||
|
index 374e41c..2bd6ea7 100644
|
||||||
|
--- a/frontend/src/components/stats/SessionList.tsx
|
||||||
|
+++ b/frontend/src/components/stats/SessionList.tsx
|
||||||
|
@@ -1,15 +1,29 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSessions } from '../../hooks/useSleepSession';
|
||||||
|
+import { useSettings } from '../../hooks/useSettings';
|
||||||
|
+import { Pagination } from './Pagination';
|
||||||
|
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
||||||
|
import type { SleepSessionRead } from '../../types';
|
||||||
|
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
||||||
|
|
||||||
|
export function SessionList() {
|
||||||
|
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
||||||
|
+ const { settings } = useSettings();
|
||||||
|
+ const sessionsPerPage = settings.sessionsPerPage;
|
||||||
|
+
|
||||||
|
+ const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editStart, setEditStart] = useState<string>('');
|
||||||
|
const [editEnd, setEditEnd] = useState<string>('');
|
||||||
|
|
||||||
|
+ const totalPages = Math.ceil(sessions.length / sessionsPerPage);
|
||||||
|
+ const clampedPage = Math.max(1, Math.min(currentPage, totalPages || 1));
|
||||||
|
+
|
||||||
|
+ const paginatedSessions = sessions.slice(
|
||||||
|
+ (clampedPage - 1) * sessionsPerPage,
|
||||||
|
+ clampedPage * sessionsPerPage
|
||||||
|
+ );
|
||||||
|
+
|
||||||
|
if (isLoading) return <div className="text-center p-4">Loading sessions...</div>;
|
||||||
|
if (!sessions.length) return <div className="text-center p-4 text-gray-500">No sessions recorded.</div>;
|
||||||
|
|
||||||
|
@@ -55,7 +69,7 @@ export function SessionList() {
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
- {sessions.map((s) => (
|
||||||
|
+ {paginatedSessions.map((s) => (
|
||||||
|
<div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
|
||||||
|
{editingId === s.id ? (
|
||||||
|
<div className="flex flex-col space-y-3 py-1">
|
||||||
|
@@ -140,6 +154,13 @@ export function SessionList() {
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
+ {sessions.length > sessionsPerPage && (
|
||||||
|
+ <Pagination
|
||||||
|
+ currentPage={clampedPage}
|
||||||
|
+ totalPages={totalPages}
|
||||||
|
+ onPageChange={setCurrentPage}
|
||||||
|
+ />
|
||||||
|
+ )}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
diff --git a/frontend/src/components/stats/SettingsPopover.tsx b/frontend/src/components/stats/SettingsPopover.tsx
|
||||||
|
index 005a302..8031392 100644
|
||||||
|
--- a/frontend/src/components/stats/SettingsPopover.tsx
|
||||||
|
+++ b/frontend/src/components/stats/SettingsPopover.tsx
|
||||||
|
@@ -6,15 +6,17 @@ interface SettingsPopoverProps {
|
||||||
|
dayEnd: string;
|
||||||
|
wakeupRef: string;
|
||||||
|
bedtimeRef: string;
|
||||||
|
- onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string }) => void;
|
||||||
|
+ sessionsPerPage: number;
|
||||||
|
+ onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string; sessionsPerPage: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
-export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }: SettingsPopoverProps) {
|
||||||
|
+export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage, onSave }: SettingsPopoverProps) {
|
||||||
|
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 [localSessionsPerPage, setLocalSessionsPerPage] = useState(sessionsPerPage);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
@@ -22,7 +24,8 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
||||||
|
setLocalDayEnd(dayEnd);
|
||||||
|
setLocalWakeupRef(wakeupRef);
|
||||||
|
setLocalBedtimeRef(bedtimeRef);
|
||||||
|
- }, [dayStart, dayEnd, wakeupRef, bedtimeRef]);
|
||||||
|
+ setLocalSessionsPerPage(sessionsPerPage);
|
||||||
|
+ }, [dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
@@ -42,6 +45,7 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
||||||
|
dayEnd: localDayEnd,
|
||||||
|
wakeupRef: localWakeupRef,
|
||||||
|
bedtimeRef: localBedtimeRef,
|
||||||
|
+ sessionsPerPage: localSessionsPerPage,
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
@@ -65,44 +69,58 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="day-start"
|
||||||
|
- type="time"
|
||||||
|
- value={localDayStart}
|
||||||
|
- onChange={(e) => setLocalDayStart(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="day-start"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localDayStart}
|
||||||
|
+ onChange={(e) => setLocalDayStart(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="day-end"
|
||||||
|
- type="time"
|
||||||
|
- value={localDayEnd}
|
||||||
|
- onChange={(e) => setLocalDayEnd(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="day-end"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localDayEnd}
|
||||||
|
+ onChange={(e) => setLocalDayEnd(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="wakeup-ref"
|
||||||
|
- type="time"
|
||||||
|
- value={localWakeupRef}
|
||||||
|
- onChange={(e) => setLocalWakeupRef(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="wakeup-ref"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localWakeupRef}
|
||||||
|
+ onChange={(e) => setLocalWakeupRef(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="bedtime-ref"
|
||||||
|
- type="time"
|
||||||
|
- value={localBedtimeRef}
|
||||||
|
- onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="bedtime-ref"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localBedtimeRef}
|
||||||
|
+ onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
+ </div>
|
||||||
|
+ <div>
|
||||||
|
+ <label htmlFor="sessions-per-page" className="block text-xs text-gray-500 mb-1">Results per page</label>
|
||||||
|
+ <select
|
||||||
|
+ id="sessions-per-page"
|
||||||
|
+ value={localSessionsPerPage}
|
||||||
|
+ onChange={(e) => setLocalSessionsPerPage(Number(e.target.value))}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ >
|
||||||
|
+ <option value={5}>5</option>
|
||||||
|
+ <option value={10}>10</option>
|
||||||
|
+ <option value={25}>25</option>
|
||||||
|
+ <option value={50}>50</option>
|
||||||
|
+ </select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx
|
||||||
|
index 2516d55..e3518c8 100644
|
||||||
|
--- a/frontend/src/components/stats/StatsPage.tsx
|
||||||
|
+++ b/frontend/src/components/stats/StatsPage.tsx
|
||||||
|
@@ -27,18 +27,20 @@ const StatsPage: React.FC = () => {
|
||||||
|
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Statistics</h2>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Overview of sleep patterns and trends</p>
|
||||||
|
</div>
|
||||||
|
- <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);
|
||||||
|
- }}
|
||||||
|
- />
|
||||||
|
+ <SettingsPopover
|
||||||
|
+ dayStart={settings.dayStart}
|
||||||
|
+ dayEnd={settings.dayEnd}
|
||||||
|
+ wakeupRef={settings.wakeupRef}
|
||||||
|
+ bedtimeRef={settings.bedtimeRef}
|
||||||
|
+ sessionsPerPage={settings.sessionsPerPage}
|
||||||
|
+ onSave={(s) => {
|
||||||
|
+ update('dayStart', s.dayStart);
|
||||||
|
+ update('dayEnd', s.dayEnd);
|
||||||
|
+ update('wakeupRef', s.wakeupRef);
|
||||||
|
+ update('bedtimeRef', s.bedtimeRef);
|
||||||
|
+ update('sessionsPerPage', s.sessionsPerPage);
|
||||||
|
+ }}
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
|
||||||
|
diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts
|
||||||
|
index c587fb3..d95899b 100644
|
||||||
|
--- a/frontend/src/hooks/useSettings.ts
|
||||||
|
+++ b/frontend/src/hooks/useSettings.ts
|
||||||
|
@@ -8,6 +8,7 @@ interface Settings {
|
||||||
|
wakeupRef: string;
|
||||||
|
bedtimeRef: string;
|
||||||
|
statsInterval: number;
|
||||||
|
+ sessionsPerPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
@@ -16,6 +17,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||||
|
wakeupRef: '04:00',
|
||||||
|
bedtimeRef: '19:00',
|
||||||
|
statsInterval: 7,
|
||||||
|
+ sessionsPerPage: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
const maxVisiblePages = 5;
|
||||||
|
|
||||||
|
if (totalPages <= maxVisiblePages) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always include first page
|
||||||
|
pages.push(1);
|
||||||
|
|
||||||
|
if (currentPage > 3) {
|
||||||
|
pages.push('...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate middle pages
|
||||||
|
const start = Math.max(2, currentPage - 1);
|
||||||
|
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
if (!pages.includes(i)) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage < totalPages - 2) {
|
||||||
|
pages.push('...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include last page
|
||||||
|
if (!pages.includes(totalPages)) {
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = getPageNumbers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="pagination" className="flex items-center justify-center gap-1 mt-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-gray-600"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{pages.map((page, idx) => {
|
||||||
|
if (page === '...') {
|
||||||
|
return (
|
||||||
|
<div key={`ellipsis-${idx}`} className="px-2 text-gray-400">
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = page as number;
|
||||||
|
const isActive = currentPage === pageNum;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => onPageChange(pageNum)}
|
||||||
|
className={`min-w-[36px] h-9 px-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-gray-600"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSessions } from '../../hooks/useSleepSession';
|
import { useSessions } from '../../hooks/useSleepSession';
|
||||||
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
|
import { Pagination } from './Pagination';
|
||||||
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
||||||
import type { SleepSessionRead } from '../../types';
|
import type { SleepSessionRead } from '../../types';
|
||||||
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
||||||
|
|
||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const sessionsPerPage = settings.sessionsPerPage;
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editStart, setEditStart] = useState<string>('');
|
const [editStart, setEditStart] = useState<string>('');
|
||||||
const [editEnd, setEditEnd] = useState<string>('');
|
const [editEnd, setEditEnd] = useState<string>('');
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sessions.length / sessionsPerPage);
|
||||||
|
const clampedPage = Math.max(1, Math.min(currentPage, totalPages || 1));
|
||||||
|
|
||||||
|
const paginatedSessions = sessions.slice(
|
||||||
|
(clampedPage - 1) * sessionsPerPage,
|
||||||
|
clampedPage * sessionsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) return <div className="text-center p-4">Loading sessions...</div>;
|
if (isLoading) return <div className="text-center p-4">Loading sessions...</div>;
|
||||||
if (!sessions.length) return <div className="text-center p-4 text-gray-500">No sessions recorded.</div>;
|
if (!sessions.length) return <div className="text-center p-4 text-gray-500">No sessions recorded.</div>;
|
||||||
|
|
||||||
@@ -52,10 +66,10 @@ export function SessionList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
<div className="session-list bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
|
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sessions.map((s) => (
|
{paginatedSessions.map((s) => (
|
||||||
<div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
|
<div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
|
||||||
{editingId === s.id ? (
|
{editingId === s.id ? (
|
||||||
<div className="flex flex-col space-y-3 py-1">
|
<div className="flex flex-col space-y-3 py-1">
|
||||||
@@ -140,6 +154,13 @@ export function SessionList() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{sessions.length > sessionsPerPage && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={clampedPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ interface SettingsPopoverProps {
|
|||||||
dayEnd: string;
|
dayEnd: string;
|
||||||
wakeupRef: string;
|
wakeupRef: string;
|
||||||
bedtimeRef: string;
|
bedtimeRef: string;
|
||||||
onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string }) => void;
|
sessionsPerPage: number;
|
||||||
|
onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string; sessionsPerPage: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }: SettingsPopoverProps) {
|
export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage, onSave }: SettingsPopoverProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [localDayStart, setLocalDayStart] = useState(dayStart);
|
const [localDayStart, setLocalDayStart] = useState(dayStart);
|
||||||
const [localDayEnd, setLocalDayEnd] = useState(dayEnd);
|
const [localDayEnd, setLocalDayEnd] = useState(dayEnd);
|
||||||
const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef);
|
const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef);
|
||||||
const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef);
|
const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef);
|
||||||
|
const [localSessionsPerPage, setLocalSessionsPerPage] = useState(sessionsPerPage);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,7 +24,8 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
|||||||
setLocalDayEnd(dayEnd);
|
setLocalDayEnd(dayEnd);
|
||||||
setLocalWakeupRef(wakeupRef);
|
setLocalWakeupRef(wakeupRef);
|
||||||
setLocalBedtimeRef(bedtimeRef);
|
setLocalBedtimeRef(bedtimeRef);
|
||||||
}, [dayStart, dayEnd, wakeupRef, bedtimeRef]);
|
setLocalSessionsPerPage(sessionsPerPage);
|
||||||
|
}, [dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
@@ -42,6 +45,7 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
|||||||
dayEnd: localDayEnd,
|
dayEnd: localDayEnd,
|
||||||
wakeupRef: localWakeupRef,
|
wakeupRef: localWakeupRef,
|
||||||
bedtimeRef: localBedtimeRef,
|
bedtimeRef: localBedtimeRef,
|
||||||
|
sessionsPerPage: localSessionsPerPage,
|
||||||
});
|
});
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
@@ -65,44 +69,58 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
<label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
||||||
<input
|
<input
|
||||||
id="day-start"
|
id="day-start"
|
||||||
type="time"
|
type="time"
|
||||||
value={localDayStart}
|
value={localDayStart}
|
||||||
onChange={(e) => setLocalDayStart(e.target.value)}
|
onChange={(e) => setLocalDayStart(e.target.value)}
|
||||||
className="w-full p-2 border rounded-md text-sm"
|
className="w-full p-2 border rounded-md text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
<label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
||||||
<input
|
<input
|
||||||
id="day-end"
|
id="day-end"
|
||||||
type="time"
|
type="time"
|
||||||
value={localDayEnd}
|
value={localDayEnd}
|
||||||
onChange={(e) => setLocalDayEnd(e.target.value)}
|
onChange={(e) => setLocalDayEnd(e.target.value)}
|
||||||
className="w-full p-2 border rounded-md text-sm"
|
className="w-full p-2 border rounded-md text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
<label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
||||||
<input
|
<input
|
||||||
id="wakeup-ref"
|
id="wakeup-ref"
|
||||||
type="time"
|
type="time"
|
||||||
value={localWakeupRef}
|
value={localWakeupRef}
|
||||||
onChange={(e) => setLocalWakeupRef(e.target.value)}
|
onChange={(e) => setLocalWakeupRef(e.target.value)}
|
||||||
className="w-full p-2 border rounded-md text-sm"
|
className="w-full p-2 border rounded-md text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
<label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
||||||
<input
|
<input
|
||||||
id="bedtime-ref"
|
id="bedtime-ref"
|
||||||
type="time"
|
type="time"
|
||||||
value={localBedtimeRef}
|
value={localBedtimeRef}
|
||||||
onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
||||||
className="w-full p-2 border rounded-md text-sm"
|
className="w-full p-2 border rounded-md text-sm"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="sessions-per-page" className="block text-xs text-gray-500 mb-1">Results per page</label>
|
||||||
|
<select
|
||||||
|
id="sessions-per-page"
|
||||||
|
value={localSessionsPerPage}
|
||||||
|
onChange={(e) => setLocalSessionsPerPage(Number(e.target.value))}
|
||||||
|
className="w-full p-2 border rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<option value={5}>5</option>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ const StatsPage: React.FC = () => {
|
|||||||
dayEnd={settings.dayEnd}
|
dayEnd={settings.dayEnd}
|
||||||
wakeupRef={settings.wakeupRef}
|
wakeupRef={settings.wakeupRef}
|
||||||
bedtimeRef={settings.bedtimeRef}
|
bedtimeRef={settings.bedtimeRef}
|
||||||
|
sessionsPerPage={settings.sessionsPerPage}
|
||||||
onSave={(s) => {
|
onSave={(s) => {
|
||||||
update('dayStart', s.dayStart);
|
update('dayStart', s.dayStart);
|
||||||
update('dayEnd', s.dayEnd);
|
update('dayEnd', s.dayEnd);
|
||||||
update('wakeupRef', s.wakeupRef);
|
update('wakeupRef', s.wakeupRef);
|
||||||
update('bedtimeRef', s.bedtimeRef);
|
update('bedtimeRef', s.bedtimeRef);
|
||||||
|
update('sessionsPerPage', s.sessionsPerPage);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
|
|||||||
const isSleeping = !!activeSession;
|
const isSleeping = !!activeSession;
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
|
if (!time) {
|
||||||
|
alert('Please select a valid time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (isSleeping && activeSession?.id) {
|
if (isSleeping && activeSession?.id) {
|
||||||
await endSession({ sessionId: activeSession.id, endTime: time });
|
await endSession({ sessionId: activeSession.id, endTime: time });
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'baby-sleep-settings';
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
dayStart: string;
|
||||||
|
dayEnd: string;
|
||||||
|
wakeupRef: string;
|
||||||
|
bedtimeRef: string;
|
||||||
|
statsInterval: number;
|
||||||
|
sessionsPerPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsContextType {
|
||||||
|
settings: Settings;
|
||||||
|
update: (key: keyof Settings, value: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
dayStart: '07:00',
|
||||||
|
dayEnd: '20:00',
|
||||||
|
wakeupRef: '04:00',
|
||||||
|
bedtimeRef: '19:00',
|
||||||
|
statsInterval: 7,
|
||||||
|
sessionsPerPage: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [settings, setSettings] = useState<Settings>(() => {
|
||||||
|
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: keyof Settings, value: string | number) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{ settings, update }}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useContext } from 'react';
|
||||||
|
import { SettingsContext, Settings } from './SettingsProvider';
|
||||||
const STORAGE_KEY = 'baby-sleep-settings';
|
|
||||||
|
|
||||||
interface Settings {
|
|
||||||
dayStart: string;
|
|
||||||
dayEnd: string;
|
|
||||||
wakeupRef: string;
|
|
||||||
bedtimeRef: string;
|
|
||||||
statsInterval: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = {
|
|
||||||
dayStart: '07:00',
|
|
||||||
dayEnd: '20:00',
|
|
||||||
wakeupRef: '04:00',
|
|
||||||
bedtimeRef: '19:00',
|
|
||||||
statsInterval: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const [settings, setSettings] = useState<Settings>(() => {
|
const context = useContext(SettingsContext);
|
||||||
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
|
if (context === undefined) {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
throw new Error('useSettings must be used within a SettingsProvider');
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
}
|
||||||
}
|
return context;
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const update = (key: keyof Settings, value: string | number) => {
|
|
||||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return { settings, update };
|
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,17 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { SettingsProvider } from './hooks/SettingsProvider';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SettingsProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</SettingsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ export function formatDateTime(date: Date | string): string {
|
|||||||
*/
|
*/
|
||||||
export function toUTCFromLocalInput(inputValue: string): string {
|
export function toUTCFromLocalInput(inputValue: string): string {
|
||||||
const d = new Date(inputValue);
|
const d = new Date(inputValue);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Pagination', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Monitor network requests to see if our mock is hit
|
||||||
|
page.on('request', request => console.log('>>', request.method(), request.url()));
|
||||||
|
page.on('response', response => console.log('<<', response.status(), response.url()));
|
||||||
|
|
||||||
|
// Mock the sessions API
|
||||||
|
await page.route('**/api/sessions', async (route) => {
|
||||||
|
const mockSessions = Array.from({ length: 15 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
start_time: new Date(2024, 0, 1 + i).toISOString(),
|
||||||
|
end_time: new Date(2024, 0, 1 + i, 8, 0).toISOString(),
|
||||||
|
notes: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ sessions: mockSessions, total: 15 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock other stats APIs to prevent crashes from accessing undefined properties
|
||||||
|
await page.route(/\/api\/stats\/.*/, async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
let body = {};
|
||||||
|
|
||||||
|
if (url.includes('summary')) {
|
||||||
|
body = {
|
||||||
|
total_sleeps: 10,
|
||||||
|
avg_sleeps_per_day: 2.5,
|
||||||
|
total_sleeps_all_time: 100,
|
||||||
|
avg_sleeps_per_day_all_time: 2.0,
|
||||||
|
};
|
||||||
|
} else if (url.includes('daily-summary')) {
|
||||||
|
body = {
|
||||||
|
days: [
|
||||||
|
{ date: '2024-01-01', total_minutes: 480, day_minutes: 240, night_minutes: 240 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else if (url.includes('timetable')) {
|
||||||
|
body = {
|
||||||
|
slots: [],
|
||||||
|
};
|
||||||
|
} else if (url.includes('wake-sleep-times')) {
|
||||||
|
body = {
|
||||||
|
days: [
|
||||||
|
{ date: '2024-01-01', wakeup_minutes: 480, bedtime_minutes: 1200 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('http://localhost:5173/stats');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Pagination component is visible when sessions exceed page size', async ({ page }) => {
|
||||||
|
// Wait for one of the mocked dates to appear to ensure data is loaded
|
||||||
|
await expect(page.locator('body')).toContainText('2024-01-01', { timeout: 10000 });
|
||||||
|
await page.screenshot({ path: 'screenshots/pagination_visible.png' });
|
||||||
|
|
||||||
|
const pagination = page.getByTestId('pagination');
|
||||||
|
await expect(pagination).toBeVisible();
|
||||||
|
|
||||||
|
// Use a more specific selector for the first page button
|
||||||
|
await expect(page.getByTestId('pagination').getByRole('button', { name: '1' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Changing results per page in settings updates the list', async ({ page }) => {
|
||||||
|
// Wait for data to load first
|
||||||
|
await expect(page.locator('body')).toContainText('2024-01-01', { timeout: 10000 });
|
||||||
|
await page.screenshot({ path: 'screenshots/before_settings_change.png' });
|
||||||
|
|
||||||
|
// Open Settings
|
||||||
|
await page.getByRole('button', { name: /Settings/i }).click();
|
||||||
|
await expect(page.locator('text=Results per page')).toBeVisible();
|
||||||
|
|
||||||
|
// Change to 5
|
||||||
|
await page.getByLabel(/Results per page/i).selectOption('5');
|
||||||
|
await page.getByRole('button', { name: /Save/i }).click();
|
||||||
|
|
||||||
|
// Wait for the UI to update and settings to be saved
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'screenshots/after_settings_change.png' });
|
||||||
|
|
||||||
|
// Verify sessions per page is 5 (only 5 should be visible)
|
||||||
|
const sessionRows = page.locator('.session-list .border-b');
|
||||||
|
await expect(sessionRows).toHaveCount(5);
|
||||||
|
|
||||||
|
// Verify settings is saved in localStorage
|
||||||
|
const localStorage = await page.evaluate(() => {
|
||||||
|
return window.localStorage.getItem('baby-sleep-settings');
|
||||||
|
});
|
||||||
|
expect(localStorage).toContain('"sessionsPerPage":5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigating through pages works', async ({ page }) => {
|
||||||
|
// Wait for data to load first
|
||||||
|
await expect(page.locator('body')).toContainText('2024-01-01', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Set to 5 via settings first
|
||||||
|
await page.getByRole('button', { name: /Settings/i }).click();
|
||||||
|
await page.getByLabel(/Results per page/i).selectOption('5');
|
||||||
|
await page.getByRole('button', { name: /Save/i }).click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const nextButton = page.getByRole('button', { name: /Next/i });
|
||||||
|
await expect(nextButton).toBeVisible();
|
||||||
|
|
||||||
|
await nextButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Should now show sessions from index 5 to 9 (if total is 15)
|
||||||
|
// Mocked start_time for i=5 is 2024-01-06
|
||||||
|
await expect(page.locator('.text-gray-900').filter({ hasText: '2024-01-06' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,4 +70,20 @@ describe('SleepButton', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(endSessionMock).toHaveBeenCalled());
|
await waitFor(() => expect(endSessionMock).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows alert when time is invalid', async () => {
|
||||||
|
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||||
|
(useSleepSession as any).mockReturnValue({
|
||||||
|
activeSession: null,
|
||||||
|
isCreating: false,
|
||||||
|
isEnding: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<SleepButton time="" />);
|
||||||
|
const button = screen.getByRole('button', { name: /Start Sleep/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith('Please select a valid time.');
|
||||||
|
alertSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,4 +29,18 @@ describe('TimeAdjuster', () => {
|
|||||||
fireEvent.click(resetButton);
|
fireEvent.click(resetButton);
|
||||||
expect(handleChange).toHaveBeenCalled();
|
expect(handleChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles invalid date input gracefully (returns empty string)', async () => {
|
||||||
|
// Since we changed the implementation, it should no longer throw.
|
||||||
|
// We can't easily test the useEffect error anymore because it doesn't throw,
|
||||||
|
// but we can check if onTimeChange is called with "" for invalid input.
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { container } = render(<TimeAdjuster onTimeChange={handleChange} />);
|
||||||
|
const input = container.querySelector('input');
|
||||||
|
|
||||||
|
fireEvent.change(input!, { target: { value: '2026-06-0' } });
|
||||||
|
// We might need to wait for the effect
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(handleChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
adjustForTimezone,
|
adjustForTimezone,
|
||||||
getLocalOffsetFromReference,
|
getLocalOffsetFromReference,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
|
toUTCFromLocalInput,
|
||||||
} from '../../src/utils/time';
|
} from '../../src/utils/time';
|
||||||
|
|
||||||
describe('formatMinutesAfter', () => {
|
describe('formatMinutesAfter', () => {
|
||||||
@@ -152,3 +153,18 @@ describe('getLocalOffsetFromReference', () => {
|
|||||||
expect(offset).toBe(localMins - refMins);
|
expect(offset).toBe(localMins - refMins);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toUTCFromLocalInput', () => {
|
||||||
|
it('converts valid datetime-local string to ISO UTC string', () => {
|
||||||
|
const input = '2024-01-15T10:00';
|
||||||
|
// This is tricky because it depends on local timezone.
|
||||||
|
// But we just want to see if it works for a valid input.
|
||||||
|
const result = toUTCFromLocalInput(input);
|
||||||
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string on invalid datetime-local string', () => {
|
||||||
|
expect(toUTCFromLocalInput('2026-06-0')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# Proposed Implementation Plan
|
||||||
|
|
||||||
|
## Phase 1: Settings Update
|
||||||
|
* **`frontend/src/hooks/useSettings.ts`**: Add `sessionsPerPage` to the settings state and define a default value (e.g., 10).
|
||||||
|
* **`frontend/src/components/stats/SettingsPopover.tsx`**:
|
||||||
|
* Update the props and local state to include `sessionsPerPage`.
|
||||||
|
* Add a dropdown in the settings UI for selecting "Results per page" (e.g., 5, 10, 25, 50).
|
||||||
|
* **`frontend/src/components/stats/StatsPage.tsx`**: Update the `onSave` handler to persist the new `sessionsPerPage` setting.
|
||||||
|
|
||||||
|
## Phase 2: Pagination UI and Logic
|
||||||
|
* **`frontend/src/components/stats/Pagination.tsx` (New Component)**: Implement a "Google-style" pagination component with `Previous`, page numbers, and `Next` buttons.
|
||||||
|
* **`frontend/src/components/stats/SessionList.tsx`**:
|
||||||
|
* Integrate `useSettings` to fetch the selected page size.
|
||||||
|
* Implement local pagination logic (managing `currentPage`).
|
||||||
|
* Slice the session data to show only the current page's items.
|
||||||
|
* Render the new `Pagination` component at the bottom of the list.
|
||||||
|
|
||||||
|
## Phase 3: Verification
|
||||||
|
* Verify that changing "Results per page" in Settings correctly updates the list.
|
||||||
|
* Confirm pagination navigation (Prev/Next/Numbers) works as expected and handles edge cases (e.g., first/last page).
|
||||||
|
|
||||||
|
I will use multiple sub-agents to execute these tasks in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The user wants to implement client-side pagination for "Sleep Sessions".
|
||||||
|
Plan:
|
||||||
|
1. Update `useSettings` to include `sessionsPerPage`.
|
||||||
|
2. Update `SettingsPopover` to allow users to change `sessionsPerPage`.
|
||||||
|
3. Update `StatsPage`'s `onSave` in `SettingsPopover` to include the new setting.
|
||||||
|
4. Create a new `Pagination` component.
|
||||||
|
5. Update `SessionList` to use pagination logic and the new component.
|
||||||
|
6. Verify with Playwright and screenshots.
|
||||||
|
|
||||||
|
I'll start by using `todowrite` to track progress.
|
||||||
|
Then I will launch sub-agents.
|
||||||
|
|
||||||
|
Sub-agents:
|
||||||
|
1. `explore` (or `general`) for Phase 1 (Settings).
|
||||||
|
2. `general` for Phase 2 (Pagination component and SessionList logic).
|
||||||
|
3. `general` for Phase 3 (Playwright verification).
|
||||||
|
|
||||||
|
Wait, the user said "use as many sub-agents as possible". I'll split it.
|
||||||
|
|
||||||
|
Task 1: Update settings (useSettings, SettingsPopover, StatsPage).
|
||||||
|
Task 2: Implement Pagination component and integrate it into SessionList.
|
||||||
|
Task 3: Write Playwright tests to verify functionality and take screenshots.
|
||||||
|
|
||||||
|
Let's start with `todowrite`.
|
||||||
|
|
||||||
|
## TODOS
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"todos": [
|
||||||
|
{
|
||||||
|
"content": "Update useSettings to include sessionsPerPage",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Update SettingsPopover to allow changing sessionsPerPage",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Update StatsPage to persist sessionsPerPage in onSave",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Create Pagination component in frontend/src/components/stats/Pagination.tsx",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Update SessionList to implement client-side pagination logic and use Pagination component",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Write Playwright tests to verify pagination and take screenshots",
|
||||||
|
"priority": "medium",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current progress (git diff)
|
||||||
|
|
||||||
|
```
|
||||||
|
diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx
|
||||||
|
index 374e41c..2bd6ea7 100644
|
||||||
|
--- a/frontend/src/components/stats/SessionList.tsx
|
||||||
|
+++ b/frontend/src/components/stats/SessionList.tsx
|
||||||
|
@@ -1,15 +1,29 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSessions } from '../../hooks/useSleepSession';
|
||||||
|
+import { useSettings } from '../../hooks/useSettings';
|
||||||
|
+import { Pagination } from './Pagination';
|
||||||
|
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
||||||
|
import type { SleepSessionRead } from '../../types';
|
||||||
|
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
||||||
|
|
||||||
|
export function SessionList() {
|
||||||
|
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
||||||
|
+ const { settings } = useSettings();
|
||||||
|
+ const sessionsPerPage = settings.sessionsPerPage;
|
||||||
|
+
|
||||||
|
+ const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editStart, setEditStart] = useState<string>('');
|
||||||
|
const [editEnd, setEditEnd] = useState<string>('');
|
||||||
|
|
||||||
|
+ const totalPages = Math.ceil(sessions.length / sessionsPerPage);
|
||||||
|
+ const clampedPage = Math.max(1, Math.min(currentPage, totalPages || 1));
|
||||||
|
+
|
||||||
|
+ const paginatedSessions = sessions.slice(
|
||||||
|
+ (clampedPage - 1) * sessionsPerPage,
|
||||||
|
+ clampedPage * sessionsPerPage
|
||||||
|
+ );
|
||||||
|
+
|
||||||
|
if (isLoading) return <div className="text-center p-4">Loading sessions...</div>;
|
||||||
|
if (!sessions.length) return <div className="text-center p-4 text-gray-500">No sessions recorded.</div>;
|
||||||
|
|
||||||
|
@@ -55,7 +69,7 @@ export function SessionList() {
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
- {sessions.map((s) => (
|
||||||
|
+ {paginatedSessions.map((s) => (
|
||||||
|
<div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
|
||||||
|
{editingId === s.id ? (
|
||||||
|
<div className="flex flex-col space-y-3 py-1">
|
||||||
|
@@ -140,6 +154,13 @@ export function SessionList() {
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
+ {sessions.length > sessionsPerPage && (
|
||||||
|
+ <Pagination
|
||||||
|
+ currentPage={clampedPage}
|
||||||
|
+ totalPages={totalPages}
|
||||||
|
+ onPageChange={setCurrentPage}
|
||||||
|
+ />
|
||||||
|
+ )}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
diff --git a/frontend/src/components/stats/SettingsPopover.tsx b/frontend/src/components/stats/SettingsPopover.tsx
|
||||||
|
index 005a302..8031392 100644
|
||||||
|
--- a/frontend/src/components/stats/SettingsPopover.tsx
|
||||||
|
+++ b/frontend/src/components/stats/SettingsPopover.tsx
|
||||||
|
@@ -6,15 +6,17 @@ interface SettingsPopoverProps {
|
||||||
|
dayEnd: string;
|
||||||
|
wakeupRef: string;
|
||||||
|
bedtimeRef: string;
|
||||||
|
- onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string }) => void;
|
||||||
|
+ sessionsPerPage: number;
|
||||||
|
+ onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string; sessionsPerPage: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
-export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }: SettingsPopoverProps) {
|
||||||
|
+export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage, onSave }: SettingsPopoverProps) {
|
||||||
|
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 [localSessionsPerPage, setLocalSessionsPerPage] = useState(sessionsPerPage);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
@@ -22,7 +24,8 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
||||||
|
setLocalDayEnd(dayEnd);
|
||||||
|
setLocalWakeupRef(wakeupRef);
|
||||||
|
setLocalBedtimeRef(bedtimeRef);
|
||||||
|
- }, [dayStart, dayEnd, wakeupRef, bedtimeRef]);
|
||||||
|
+ setLocalSessionsPerPage(sessionsPerPage);
|
||||||
|
+ }, [dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
@@ -42,6 +45,7 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
||||||
|
dayEnd: localDayEnd,
|
||||||
|
wakeupRef: localWakeupRef,
|
||||||
|
bedtimeRef: localBedtimeRef,
|
||||||
|
+ sessionsPerPage: localSessionsPerPage,
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
@@ -65,44 +69,58 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="day-start"
|
||||||
|
- type="time"
|
||||||
|
- value={localDayStart}
|
||||||
|
- onChange={(e) => setLocalDayStart(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="day-start"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localDayStart}
|
||||||
|
+ onChange={(e) => setLocalDayStart(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="day-end"
|
||||||
|
- type="time"
|
||||||
|
- value={localDayEnd}
|
||||||
|
- onChange={(e) => setLocalDayEnd(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="day-end"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localDayEnd}
|
||||||
|
+ onChange={(e) => setLocalDayEnd(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="wakeup-ref"
|
||||||
|
- type="time"
|
||||||
|
- value={localWakeupRef}
|
||||||
|
- onChange={(e) => setLocalWakeupRef(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="wakeup-ref"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localWakeupRef}
|
||||||
|
+ onChange={(e) => setLocalWakeupRef(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
-<label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
||||||
|
-<input
|
||||||
|
- id="bedtime-ref"
|
||||||
|
- type="time"
|
||||||
|
- value={localBedtimeRef}
|
||||||
|
- onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
||||||
|
- className="w-full p-2 border rounded-md text-sm"
|
||||||
|
-/>
|
||||||
|
+ <label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
||||||
|
+ <input
|
||||||
|
+ id="bedtime-ref"
|
||||||
|
+ type="time"
|
||||||
|
+ value={localBedtimeRef}
|
||||||
|
+ onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ />
|
||||||
|
+ </div>
|
||||||
|
+ <div>
|
||||||
|
+ <label htmlFor="sessions-per-page" className="block text-xs text-gray-500 mb-1">Results per page</label>
|
||||||
|
+ <select
|
||||||
|
+ id="sessions-per-page"
|
||||||
|
+ value={localSessionsPerPage}
|
||||||
|
+ onChange={(e) => setLocalSessionsPerPage(Number(e.target.value))}
|
||||||
|
+ className="w-full p-2 border rounded-md text-sm"
|
||||||
|
+ >
|
||||||
|
+ <option value={5}>5</option>
|
||||||
|
+ <option value={10}>10</option>
|
||||||
|
+ <option value={25}>25</option>
|
||||||
|
+ <option value={50}>50</option>
|
||||||
|
+ </select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx
|
||||||
|
index 2516d55..e3518c8 100644
|
||||||
|
--- a/frontend/src/components/stats/StatsPage.tsx
|
||||||
|
+++ b/frontend/src/components/stats/StatsPage.tsx
|
||||||
|
@@ -27,18 +27,20 @@ const StatsPage: React.FC = () => {
|
||||||
|
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Statistics</h2>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Overview of sleep patterns and trends</p>
|
||||||
|
</div>
|
||||||
|
- <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);
|
||||||
|
- }}
|
||||||
|
- />
|
||||||
|
+ <SettingsPopover
|
||||||
|
+ dayStart={settings.dayStart}
|
||||||
|
+ dayEnd={settings.dayEnd}
|
||||||
|
+ wakeupRef={settings.wakeupRef}
|
||||||
|
+ bedtimeRef={settings.bedtimeRef}
|
||||||
|
+ sessionsPerPage={settings.sessionsPerPage}
|
||||||
|
+ onSave={(s) => {
|
||||||
|
+ update('dayStart', s.dayStart);
|
||||||
|
+ update('dayEnd', s.dayEnd);
|
||||||
|
+ update('wakeupRef', s.wakeupRef);
|
||||||
|
+ update('bedtimeRef', s.bedtimeRef);
|
||||||
|
+ update('sessionsPerPage', s.sessionsPerPage);
|
||||||
|
+ }}
|
||||||
|
+ />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
|
||||||
|
diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts
|
||||||
|
index c587fb3..d95899b 100644
|
||||||
|
--- a/frontend/src/hooks/useSettings.ts
|
||||||
|
+++ b/frontend/src/hooks/useSettings.ts
|
||||||
|
@@ -8,6 +8,7 @@ interface Settings {
|
||||||
|
wakeupRef: string;
|
||||||
|
bedtimeRef: string;
|
||||||
|
statsInterval: number;
|
||||||
|
+ sessionsPerPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
@@ -16,6 +17,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||||
|
wakeupRef: '04:00',
|
||||||
|
bedtimeRef: '19:00',
|
||||||
|
statsInterval: 7,
|
||||||
|
+ sessionsPerPage: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Last command
|
||||||
|
|
||||||
|
The last command you ran was this:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx playwright test frontend/tests/e2e/pagination.spec.ts --reporter=list
|
||||||
|
```
|
||||||
|
|
||||||
|
so please run this again and see the output, as this might help us know where to pick things up.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
+6653
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user