36 Commits

Author SHA1 Message Date
jacobh2 ac637a6dae Update todo 2026-06-03 22:55:29 +02:00
jacobh2 32af0f4ba3 ci: update checkout version, fix shell quoting, and add actionlint job 2026-06-03 22:50:45 +02:00
jacobh2 a4852a574a docs: fix OpenCode subagent terminology in AGENTS.md
Replace "sub-agent" (hyphenated) with "subagent" and update "spawn a
sub-agent" to "invoke a subagent via the Task tool" to match OpenCode's
actual documented terminology.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:45:41 +02:00
jacobh2 ecaa40c523 Update todo 2026-06-03 22:38:02 +02:00
jacobh2 905f53c74e ci: fix tag versioning by fetching all tags in workflows 2026-06-03 22:37:46 +02:00
jacobh2 e39f930cf9 Update agents 2026-06-03 22:35:38 +02:00
jacobh2 852dd38add fix(stats): make API endpoints timezone-aware to fix sleep session attribution 2026-06-03 22:29:24 +02:00
jacobh2 7ff1c22edc Update todo 2026-06-03 22:22:16 +02:00
jacobh2 7d4e2a376c timetable maybe working, very cloase at least 2026-06-03 22:14:18 +02:00
jacobh2 db0f717560 Add relative base, this is test to see if it works to proxy via HA
Build Backend / build (push) Failing after 2m16s
Build Frontend / build (push) Failing after 9m59s
2026-06-03 21:51:45 +02:00
jacobh2 aa4e28e7f6 chore: cleanup deleted playground.js file 2026-06-03 13:47:03 +02:00
jacobh2 837c3b84cb chore: ignore JS/JSX files and add clean script to frontend 2026-06-03 13:45:43 +02:00
jacobh2 96fb3cb988 docs: update plan with implementation details 2026-06-03 13:40:37 +02:00
jacobh2 8eb5946108 feat(frontend): add median statistic to wakeup and bedtime plots 2026-06-03 13:37:17 +02:00
jacobh2 7e3bd0a16b fix: prevent double-counting of sleep sessions in stats by grouping by start date 2026-06-03 09:20:33 +02:00
jacobh2 c371a3d8e8 fix(frontend): prevent RangeError in TimeAdjuster and add validation to SleepButton
Build Frontend / build (push) Failing after 3m15s
2026-06-02 21:20:38 +02:00
jacobh2 13bc53ea59 And not it says it is done
Build Frontend / build (push) Failing after 2m45s
2026-05-31 17:45:32 +02:00
jacobh2 cdc57a441b Pagination (but according to agent, not done) 2026-05-31 17:40:03 +02:00
jacobh2 5534a25ce5 Update build
Build Backend / build (push) Successful in 1m55s
Build Frontend / build (push) Failing after 2m48s
2026-05-31 10:40:50 +02:00
jacobh2 f05be2a5bf Add oci labels 2026-05-31 10:05:30 +02:00
jacobh2 7061e78305 Update stats
Build Backend / build (push) Successful in 1m59s
Build Frontend / build (push) Failing after 2m55s
2026-05-31 09:23:50 +02:00
jacobh2 7d9c22d5d3 Add stats 2026-05-31 08:42:25 +02:00
jacobh2 bd8d1b1936 Update migration script to handle offset 2026-05-30 19:08:25 +02:00
jacobh2 8852cf202e Fix timezones
Build Backend / build (push) Successful in 1m54s
Build Frontend / build (push) Successful in 3m43s
2026-05-30 16:55:09 +02:00
jacobh2 91184cfc96 Partly fixing timetable 2026-05-30 16:55:09 +02:00
jacobh2 6979f1685a Update .gitea/workflows/frontend.yml 2026-05-30 10:31:08 +02:00
jacobh2 733066ff29 Update .gitea/workflows/backend.yml 2026-05-30 10:16:16 +02:00
jacobh2 940e14eef5 Update .gitea/workflows/backend.yml 2026-05-30 10:08:10 +02:00
jacobh2 44ad363fcd Update .gitea/workflows/backend.yml 2026-05-30 09:16:12 +02:00
jacobh2 92becf413d Update .gitea/workflows/backend.yml 2026-05-29 16:59:51 +02:00
jacobh2 05f71283db Update .gitea/workflows/backend.yml 2026-05-29 16:31:57 +02:00
jacobh2 0ca64b9b5d Use buildx
Build Frontend / build (push) Has been cancelled
Build Backend / build (push) Has been cancelled
2026-05-29 16:21:40 +02:00
jacobh2 7c9593448c Trigger build
Build Backend / build (push) Successful in 8m33s
Build Frontend / build (push) Successful in 18m59s
2026-05-29 11:41:18 +02:00
jacobh2 c7bc5c4e93 Trigger build
Build Backend / build (push) Failing after 35s
2026-05-29 11:10:40 +02:00
jacobh2 c2b01bd679 Trigger build
Build Frontend / build (push) Failing after 38s
2026-05-29 10:35:35 +02:00
jacobh2 6d9eba94dc Update frontend to match backend 2026-05-29 10:34:48 +02:00
54 changed files with 8276 additions and 2557 deletions
+52 -8
View File
@@ -1,4 +1,7 @@
name: Build Backend
env:
MAJOR: 1
MINOR: 0
defaults:
run:
@@ -8,23 +11,64 @@ on:
push:
paths:
- "backend/**"
workflow_dispatch: {}
jobs:
build:
runs-on: host
steps:
- name: Install Git
run: apk add --no-cache git
- name: Install Git and Docker CLI
run: apk add --no-cache git docker-cli docker-cli-buildx
- name: Log in to Gitea Container Registry
# This uses the automated token provided natively by Gitea Actions
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
- name: Create Custom Buildx Builder for HTTP
run: |
# 1. Write a configuration rule forcing plain HTTP for your local domain
echo '[registry."gitea.lan"]' > buildkitd.toml
echo ' http = true' >> buildkitd.toml
# 2. Tell buildx to create and use a builder with this rule
docker buildx create --use --name local-http-builder --config buildkitd.toml
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
# Tells the action to use the local 'git' binary we just installed
set-safe-directory: 'true'
fetch-depth: 0
- name: Determine Version
run: |
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0"
else
# Strip the 'v' if present and get the patch version
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3)
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build Docker image
run: |
docker build \
docker buildx build \
--platform linux/arm64 \
-t babymonitor-backend:latest \
./backend
-t gitea.lan/jacobh2/babymonitor-backend:${{ env.VERSION }} \
./backend \
--push
- name: Tag and Push Version
run: |
git tag "$VERSION"
git push origin "$VERSION"
lint:
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Run actionlint
run: |
# Trying to install it via apk if possible, otherwise fallback
apk add --no-cache actionlint || echo "actionlint not found in apk"
if command -v actionlint >/dev/null 2>&1; then
actionlint .gitea/workflows/*.yml
else
echo "Skipping actionlint: not installed"
fi
+55 -5
View File
@@ -1,4 +1,7 @@
name: Build Frontend
env:
MAJOR: 1
MINOR: 0
defaults:
run:
@@ -8,17 +11,64 @@ on:
push:
paths:
- "frontend/**"
workflow_dispatch: {}
jobs:
build:
runs-on: host
steps:
- name: Install Git and Docker CLI
run: apk add --no-cache git docker-cli docker-cli-buildx
- name: Log in to Gitea Container Registry
# This uses the automated token provided natively by Gitea Actions
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
- name: Create Custom Buildx Builder for HTTP
run: |
# 1. Write a configuration rule forcing plain HTTP for your local domain
echo '[registry."gitea.lan"]' > buildkitd.toml
echo ' http = true' >> buildkitd.toml
# 2. Tell buildx to create and use a builder with this rule
docker buildx create --use --name local-http-builder --config buildkitd.toml
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
# Tells the action to use the local 'git' binary we just installed
set-safe-directory: 'true'
fetch-depth: 0
- name: Determine Version
run: |
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0"
else
# Strip the 'v' if present and get the patch version
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3)
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build Docker image
run: |
docker build \
docker buildx build \
--platform linux/arm64 \
-t babymonitor-frontend:latest \
./frontend
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
./frontend \
--push
- name: Tag and Push Version
run: |
git tag "$VERSION"
git push origin "$VERSION"
lint:
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Run actionlint
run: |
# Trying to install it via apk if possible, otherwise fallback
apk add --no-cache actionlint || echo "actionlint not found in apk"
if command -v actionlint >/dev/null 2>&1; then
actionlint .gitea/workflows/*.yml
else
echo "Skipping actionlint: not installed"
fi
+4
View File
@@ -1,6 +1,10 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,python
# Edit at https://www.toptal.com/developers/gitignore?templates=node,python
# Frontend generated files
*.js
*.jsx
### Node ###
# Logs
logs
+89 -26
View File
@@ -1,13 +1,11 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-04-23
**Mode:** init-deep (create-new)
# PROJECT BABY MONITOR
## 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
```
./
├── frontend/ # React 19 SPA
@@ -21,6 +19,7 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
│ │ └── utils/
│ └── tests/ # e2e (Playwright), unit (Vitest)
├── backend/ # FastAPI + SQLModel
│ ├── scripts/ # one-off scripts for migrations / fixes / etc
│ ├── app/
│ │ ├── routers/ # API endpoints
│ │ └── utils/
@@ -37,39 +36,103 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
| UI components | `frontend/src/components/` | tracker/, stats/ subdirs |
| Backend models | `backend/app/models.py` | SQLModel definitions |
| API schemas | `backend/app/schemas.py` | Pydantic request/response |
| Settings / env vars | `backend/app/config.py` | Pydantic-settings, reads `.env` |
| Database init | `backend/app/database.py` | Engine setup, `init_db()` |
| Timezone logic | `backend/app/utils/time_utils.py` | Historical bug source — handle with care |
## CONVENTIONS
- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet)
- **Python**: >=3.14 required, Hatch for packaging
- **No CI/CD**: No .github/workflows, no Makefiles yet
- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio.
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet).
- **Python**: >=3.14 required. Managed via `uv` with Hatch backend for packaging.
- **Scripts**: All scripts created in the backend folder must be placed explicitly in `backend/scripts/`.
- **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
- No empty catch blocks - error handling required
- No deleting failing tests to "pass"
- **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.
- **Assumption Error**: NEVER assume the content of ANY files EVER. ALWAYS read the file to confirm. Do not rely on the name.
- **Type Safety**: No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`). Type safety is strictly enforced.
- **Error Handling**: No empty catch blocks. Explicit error handling/logging is required.
- **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` 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. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it.
## COMMANDS
### Backend Development
```bash
# Backend
cd backend && uv sync
uv run uvicorn app.main:app --reload
cd backend
uv sync
uv run uvicorn app.main:app --reload # runs on http://localhost:8000
# Run all tests
uv run pytest
# Frontend
cd frontend && npm install
npm run dev
# Use 'npm test' with caution as it may hang; use specific test scripts instead.
npm run test
# Run a specific test file (preferred during debugging)
uv run pytest tests/test_stats.py -v
```
### Frontend Development
```bash
cd frontend
npm install
npm run dev # runs on http://localhost:5173
# Unit tests (Vitest)
npm run test
# Unit + e2e tests (Vitest + Playwright)
npm run test:all
# Lint
npm run lint
```
### Docker (production-like, ARM64)
```bash
# Full stack — frontend served on http://localhost:8080
docker compose up --build
```
## ENVIRONMENT VARIABLES
Backend reads from `backend/.env` (optional — all have defaults):
| Variable | Default | Purpose |
|----------|---------|---------|
| `DATABASE_URL` | `sqlite+aiosqlite:///./data/sleep.db` | SQLite DB path |
| `CORS_ORIGINS` | `http://localhost:5173` | Allowed CORS origins (comma-separated) |
| `LOG_LEVEL` | `info` | Uvicorn log level |
In Docker the DB is persisted via a bind-mount at `./backend/data/`.
## 🛑 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.
* **NEVER use `find` without `-maxdepth`:** Unbounded `find` crawls dependency trees just like `ls -R`. Always add `-maxdepth 3` or tighter.
* **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 invoke a subagent via the Task tool**. Act strictly as the architectural orchestrator; let subagents handle the iterative coding labor.
* **Scope Narrowly:** Give subagents 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:** Subagents 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:** Subagents 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
- Entry point: `frontend/src/main.tsx` + `backend/app/main.py`
- DB: SQLite via aiosqlite (async-first backend)
- Build: Docker for ARM64 Raspberry Pi deployment
* **Entry Points:** `frontend/src/main.tsx` (Frontend) & `backend/app/main.py` (Backend).
* **Database:** Local SQLite managed via `aiosqlite` for an entirely async-first data layer.
* **Target Architecture:** Built specifically for ARM64 deployment on a Raspberry Pi 4 via Docker.
+3
View File
@@ -8,6 +8,9 @@ COPY pyproject.toml README.md ./
RUN pip install --no-cache-dir .
FROM base
LABEL org.opencontainers.image.source="http://gitea.lan/jacobh2/babymonitor"
LABEL org.opencontainers.image.description="Babymonitor Backend"
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
+3
View File
@@ -73,3 +73,6 @@ npm run test
- `backend/`: FastAPI application, models, and API routes.
- `frontend/`: React application with components and hooks.
- `docker-compose.yml`: Orchestration for the full stack.
+26
View File
@@ -111,3 +111,29 @@ async def update_session(
await session.commit()
await session.refresh(sleep_session)
return sleep_session
async def get_sleep_counts_by_start_date(
session: AsyncSession,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
) -> List[tuple]:
if to_date and to_date.hour == 0 and to_date.minute == 0 and to_date.second == 0 and to_date.microsecond == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
query_to_date = to_date.replace(tzinfo=None) if to_date and to_date.tzinfo else to_date
query_from_date = from_date.replace(tzinfo=None) if from_date and from_date.tzinfo else from_date
statement = (
select(func.date(SleepSession.start_time), func.count())
.group_by(func.date(SleepSession.start_time))
.order_by(func.date(SleepSession.start_time).desc())
)
if query_from_date:
statement = statement.where(SleepSession.start_time >= query_from_date)
if query_to_date:
statement = statement.where(SleepSession.start_time <= query_to_date)
result = await session.exec(statement)
return list(result.all())
+88 -26
View File
@@ -1,10 +1,10 @@
from datetime import datetime, time, timedelta
from datetime import datetime, time, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import get_session
from app.crud import get_sessions
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse
from app.crud import get_sessions, get_sleep_counts_by_start_date
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse
from app.utils.time_utils import get_day_night_splits
router = APIRouter(prefix="/api/stats", tags=["stats"])
@@ -16,23 +16,31 @@ async def get_daily_summary(
to_date: datetime,
day_start: str = Query("07:00"),
day_end: str = Query("20:00"),
timezone_offset: int = Query(0),
session: AsyncSession = Depends(get_session),
):
if to_date.hour == 0 and to_date.minute == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
try:
day_start_time = time.fromisoformat(day_start)
day_end_time = time.fromisoformat(day_end)
except ValueError:
return {"days": []}
adj_from_date = from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
utc_from_date = from_date + timedelta(minutes=timezone_offset)
utc_to_date = to_date + timedelta(minutes=timezone_offset)
adj_utc_from_date = utc_from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_utc_from_date, to_date=utc_to_date)
summary_map = {}
for s in sessions:
if not s.end_time:
continue
local_start = s.start_time - timedelta(minutes=timezone_offset)
local_end = s.end_time - timedelta(minutes=timezone_offset)
segments = get_day_night_splits(
s.start_time, s.end_time, day_start_time, day_end_time
local_start, local_end, day_start_time, day_end_time
)
for seg in segments:
if not (from_date.date() <= seg["start"].date() <= to_date.date()):
@@ -66,26 +74,37 @@ async def get_timetable(
to_date: datetime,
day_start: str = Query("07:00"),
day_end: str = Query("20:00"),
timezone_offset: int = Query(0),
session: AsyncSession = Depends(get_session),
):
adj_from_date = from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
if to_date.hour == 0 and to_date.minute == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
utc_from_date = from_date + timedelta(minutes=timezone_offset)
utc_to_date = to_date + timedelta(minutes=timezone_offset)
adj_utc_from_date = utc_from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_utc_from_date, to_date=utc_to_date)
slots = []
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
current_date = from_date.date()
while current_date <= to_date.date():
date_str = current_date.strftime("%Y-%m-%d")
for i in range(96):
slot_start_time = datetime.combine(current_date, time(0, 0)) + timedelta(
slot_start_local = datetime.combine(current_date, time(0, 0)) + timedelta(
minutes=i * 15
)
slot_end_time = slot_start_time + timedelta(minutes=15)
slot_start_str = slot_start_time.strftime("%H:%M")
slot_end_str = slot_end_time.strftime("%H:%M")
slot_end_local = slot_start_local + timedelta(minutes=15)
slot_start_str = slot_start_local.strftime("%H:%M")
slot_end_str = slot_end_local.strftime("%H:%M")
# Convert local slots to UTC for comparison with DB sessions
slot_start_utc = slot_start_local + timedelta(minutes=timezone_offset)
slot_end_utc = slot_end_local + timedelta(minutes=timezone_offset)
is_sleeping = False
for s in sessions:
if s.start_time <= slot_end_time and (
s.end_time is None or s.end_time >= slot_start_time
):
effective_end_time_utc = s.end_time if s.end_time is not None else now_utc
if s.start_time <= slot_end_utc and effective_end_time_utc >= slot_start_utc:
is_sleeping = True
break
slots.append(
@@ -100,23 +119,62 @@ async def get_timetable(
return {"slots": slots}
@router.get("/summary", response_model=SummaryResponse)
async def get_summary(
from_date: datetime,
to_date: datetime,
session: AsyncSession = Depends(get_session),
):
# Period Stats
period_counts = await get_sleep_counts_by_start_date(session, from_date=from_date, to_date=to_date)
total_sleeps = sum(count for _, count in period_counts)
num_days = (to_date.date() - from_date.date()).days + 1
avg_sleeps_per_day = total_sleeps / num_days if num_days > 0 else 0.0
# All-Time Stats
all_counts = await get_sleep_counts_by_start_date(session)
total_sleeps_all_time = sum(count for _, count in all_counts)
avg_sleeps_per_day_all_time = 0.0
if all_counts:
# counts are ordered by desc date (from crud.py)
# so last one is earliest.
earliest_date_str = all_counts[-1][0]
# SQLite date() returns YYYY-MM-DD string
earliest_date = datetime.strptime(earliest_date_str, "%Y-%m-%d").date()
today = datetime.now(timezone.utc).date()
days_all_time = (today - earliest_date).days + 1
avg_sleeps_per_day_all_time = total_sleeps_all_time / days_all_time if days_all_time > 0 else 0.0
return {
"total_sleeps": total_sleeps,
"avg_sleeps_per_day": avg_sleeps_per_day,
"total_sleeps_all_time": total_sleeps_all_time,
"avg_sleeps_per_day_all_time": avg_sleeps_per_day_all_time,
}
@router.get("/wake-sleep-times", response_model=WakeupBedtimeResponse)
async def get_wake_sleep_times(
from_date: datetime,
to_date: datetime,
wakeup_ref: str = Query("04:00"),
bedtime_ref: str = Query("19:00"),
timezone_offset: int = Query(0),
session: AsyncSession = Depends(get_session),
):
if to_date.hour == 0 and to_date.minute == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
try:
w_ref_time = time.fromisoformat(wakeup_ref)
b_ref_time = time.fromisoformat(bedtime_ref)
except ValueError:
return {"days": []}
# Fetch sessions starting from one day before to catch night sleep ending on from_date
adj_from_date = from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
utc_from_date = from_date + timedelta(minutes=timezone_offset)
utc_to_date = to_date + timedelta(minutes=timezone_offset)
adj_utc_from_date = utc_from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_utc_from_date, to_date=utc_to_date)
days_data = []
current_date = from_date.date()
while current_date <= to_date.date():
@@ -128,20 +186,24 @@ async def get_wake_sleep_times(
night_session = None
for s in sessions:
# A session that runs over midnight into current_date
if s.start_time < midnight_today and s.end_time and s.end_time >= midnight_today:
night_session = s
# Convert to local for comparison
s_start_local = s.start_time - timedelta(minutes=timezone_offset)
s_end_local = s.end_time - timedelta(minutes=timezone_offset) if s.end_time else None
if s_start_local < midnight_today and s_end_local and s_end_local >= midnight_today:
night_session = (s, s_start_local, s_end_local)
break
if night_session:
# Bedtime offset from b_ref_time on the day it started
s_start_date = night_session.start_time.date()
s, s_start_local, s_end_local = night_session
# Bedtime offset from b_ref_time on the day it started (local)
s_start_date = s_start_local.date()
ref_datetime_bedtime = datetime.combine(s_start_date, b_ref_time)
bedtime_min = int((night_session.start_time - ref_datetime_bedtime).total_seconds() / 60.0)
bedtime_min = int((s_start_local - ref_datetime_bedtime).total_seconds() / 60.0)
# Wakeup offset from w_ref_time on the current day
# Wakeup offset from w_ref_time on the current day (local)
ref_datetime_wakeup = datetime.combine(current_date, w_ref_time)
wakeup_min = int((night_session.end_time - ref_datetime_wakeup).total_seconds() / 60.0)
wakeup_min = int((s_end_local - ref_datetime_wakeup).total_seconds() / 60.0)
days_data.append(
{
+8
View File
@@ -57,3 +57,11 @@ class WakeupBedtimeDay(BaseModel):
class WakeupBedtimeResponse(BaseModel):
days: List[WakeupBedtimeDay]
class SummaryResponse(BaseModel):
total_sleeps: int
avg_sleeps_per_day: float
total_sleeps_all_time: int
avg_sleeps_per_day_all_time: float
+12 -2
View File
@@ -1,10 +1,11 @@
import sqlite3
import argparse
from datetime import datetime, timedelta
SOURCE_DB = 'backend/data/upload.db'
DEST_DB = 'backend/data/sleep.db'
def migrate():
def migrate(offset_minutes: int = 0):
try:
src_conn = sqlite3.connect(SOURCE_DB)
src_cur = src_conn.cursor()
@@ -13,6 +14,8 @@ def migrate():
dest_cur = dest_conn.cursor()
print(f"Connecting to {SOURCE_DB} and {DEST_DB}...")
if offset_minutes != 0:
print(f"Applying {offset_minutes} minute offset to all timestamps.")
# 1. Truncate the destination table
print("Truncating sleep_session table...")
@@ -38,6 +41,9 @@ def migrate():
# Convert Unix timestamp to datetime object
try:
start_time = datetime.fromtimestamp(timestamp_seconds)
# Apply offset
if offset_minutes != 0:
start_time += timedelta(minutes=offset_minutes)
except Exception as e:
print(f"Error converting timestamp {timestamp_seconds}: {e}")
error_count += 1
@@ -105,4 +111,8 @@ def migrate():
if 'dest_conn' in locals(): dest_conn.close()
if __name__ == "__main__":
migrate()
parser = argparse.ArgumentParser(description="Migrate sleep data from source DB to destination DB.")
parser.add_argument("--offset", type=int, default=0, help="Minute offset to apply to all timestamps.")
args = parser.parse_args()
migrate(offset_minutes=args.offset)
+68
View File
@@ -0,0 +1,68 @@
import asyncio
from datetime import datetime, timezone
from typing import AsyncGenerator
from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import get_session
from app.crud import get_sessions, get_all_time_stats
from sqlmodel import select, func
from app.models import SleepSession
async def get_session_context() -> AsyncGenerator[AsyncSession, None]:
async for session in get_session():
yield session
async def verify_stats():
# Use the async generator correctly
gen = get_session_context()
session: AsyncSession = await gen.__anext__()
try:
import datetime as dt
to_date = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=999999)
from_date = (to_date - dt.timedelta(days=6)).replace(hour=0, minute=0, second=0, microsecond=0)
print(f"Verifying range: {from_date} to {to_date}")
# 1. Verify 'in period' stats via get_sessions (as used in router)
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
total_sleeps = len(sessions)
num_days = (to_date.date() - from_date.date()).days + 1
avg_sleeps_per_day = total_sleeps / num_days if num_days > 0 else 0.0
print(f"In-period: Total={total_sleeps}, Days={num_days}, Avg={avg_sleeps_per_day}")
# 2. Verify 'all time' stats via get_all_time_stats
total_sleeps_all_time, avg_sleeps_per_day_all_time = await get_all_time_stats(session)
print(f"All-time: Total={total_sleeps_all_time}, Avg={avg_sleeps_per_day_all_time}")
# 3. Direct DB check for raw session count
statement = select(func.count()).select_from(SleepSession)
raw_total_result = await session.exec(statement)
db_total = raw_total_result.one()
print(f"DB Raw Total: {db_total}")
# 4. Direct DB check for first session to see if it matches
first_stmt = select(SleepSession.start_time).order_by(SleepSession.start_time.asc()).limit(1)
first_result = await session.exec(first_stmt)
first_time = first_result.one()
print(f"First Session in DB: {first_time}")
if total_sleeps > 0 and total_sleeps_all_time == total_sleeps:
print("INFO: All sessions are within the period.")
elif total_sleeps < total_sleeps_all_time:
print(f"INFO: Found {total_sleeps_all_time - total_sleeps} sessions outside the period.")
if avg_sleeps_per_day > avg_sleeps_per_day_all_time and total_sleeps_all_time > 0:
print("!!! ALERT: In-period average is HIGHER than All-time average. This contradicts the user's observation if all sessions are in period.")
else:
print("SUCCESS: Average calculation seems logically consistent (in-period <= all-time).")
finally:
# Cleanup the generator
try:
await gen.__anext__()
except StopAsyncIteration:
pass
if __name__ == "__main__":
asyncio.run(verify_stats())
+34 -59
View File
@@ -1,69 +1,44 @@
import pytest
from datetime import datetime, timedelta, timezone
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.database import engine
from app.models import SQLModel
from app.models import SleepSession
from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import engine, get_session
from sqlmodel import SQLModel
@pytest.fixture
async def setup_db():
@pytest.mark.asyncio
async def test_summary_double_counting_midnight(api_client):
# Setup: Fresh database for this test
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
await conn.run_sync(SQLModel.metadata.create_all)
@pytest.mark.asyncio
async def test_list_sessions_date_only_filter(setup_db):
# Test that passing a date (midnight) also works and includes the whole day
target_date = datetime(2023, 10, 1, tzinfo=timezone.utc)
other_date = datetime(2023, 10, 2, tzinfo=timezone.utc)
# Use a fresh session for insertion
async for db_session in get_session():
# 1. Setup: Insert a sleep session that spans across midnight
start_time = datetime(2024, 1, 1, 23, 0)
end_time = datetime(2024, 1, 2, 7, 0)
new_session = SleepSession(start_time=start_time, end_time=end_time)
db_session.add(new_session)
await db_session.commit()
break # Exit after one iteration
# 2. Call the stats endpoint for a period that includes this session
from_date = "2024-01-01T00:00:00"
to_date = "2024-01-02T23:59:59"
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
# 1. Create a session that starts LATER in the day on OTHER date (should be excluded)
# We end it immediately to avoid 409s in the test
start_time_other = other_date.replace(hour=20, minute=0)
resp_other = await ac.post("/api/sessions", json={"start_time": start_time_other.isoformat()})
assert resp_other.status_code == 201
session_id_other = resp_other.json()["id"]
await ac.patch(f"/api/sessions/{session_id_other}/end", json={"end_time": (start_time_other + timedelta(minutes=30)).isoformat()})
# 2. Create a session that starts ON the target_date (should be included)
start_time_target = target_date.replace(hour=20, minute=0)
resp_target = await ac.post("/api/sessions", json={"start_time": start_time_target.isoformat()})
assert resp_target.status_code == 201
session_id_target = resp_target.json()["id"]
await ac.patch(f"/api/sessions/{session_id_target}/end", json={"end_time": (start_time_target + timedelta(minutes=30)).isoformat()})
# 3. Create a session that starts EARLIER on the target_date (should be included)
start_time_early = target_date.replace(hour=2, minute=0)
resp_early = await ac.post("/api/sessions", json={"start_time": start_time_early.isoformat()})
assert resp_early.status_code == 201
session_id_early = resp_early.json()["id"]
await ac.patch(f"/api/sessions/{session_id_early}/end", json={"end_time": (start_time_early + timedelta(minutes=30)).isoformat()})
# 4. Create a session that is on the OTHER date (should be excluded)
start_time_other2 = other_date.replace(hour=2, minute=0)
resp_other2 = await ac.post("/api/sessions", json={"start_time": start_time_other2.isoformat()})
assert resp_other2.status_code == 201
session_id_other2 = resp_other2.json()["id"]
await ac.patch(f"/api/sessions/{session_id_other2}/end", json={"end_time": (start_time_other2 + timedelta(minutes=30)).isoformat()})
response = await api_client.get(f"/api/stats/summary?from_date={from_date}&to_date={to_date}")
assert response.status_code == 200
data = response.json()
# 3. Verify that total_sleeps is exactly 1 for the requested period
print(f"DEBUG: data={data}")
assert data["total_sleeps"] == 1, f"Expected 1 sleep session, got {data['total_sleeps']}"
# 4. Verify that all-time stats also count this session exactly once
assert data["total_sleeps_all_time"] == 1, f"Expected 1 all-time sleep session, got {data['total_sleeps_all_time']}"
# Query with to_date = target_date (midnight)
# This should be expanded to 23:59:59 on target_date.
# Should include the 2 sessions on target_date (target and early).
# Should exclude the 2 sessions on other_date.
to_date_str = target_date.strftime("%Y-%m-%d") # "2023-10-01"
print(f"\nGET: to_date={to_date_str}")
response = await ac.get(f"/api/sessions?to_date={to_date_str}")
print(f"GET RESPONSE: {response.status_code}, body={response.json()}")
assert response.status_code == 200
sessions = response.json()["sessions"]
# We expect exactly 2 sessions (the ones on target_date)
assert len(sessions) == 2, f"Expected 2 sessions (the ones on {to_date_str}), but got {len(sessions)}. (Body: {response.json()})"
for s in sessions:
# Verify that the sessions returned are indeed from the target_date
assert target_date.strftime("%Y-%m-%d") in s["start_time"]
+36 -1
View File
@@ -69,5 +69,40 @@ async def test_timetable(setup_db):
f"/api/stats/timetable?from_date={from_date}&to_date={to_date}"
)
@pytest.mark.asyncio
async def test_summary(setup_db):
from datetime import date, timedelta, timezone
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
from app.database import get_session
from app.models import SleepSession
from sqlalchemy import select
async with AsyncSession(engine) as session:
# Create two sessions, one a few days ago and one today
start1 = datetime.now(timezone.utc) - timedelta(days=5)
end1 = start1 + timedelta(hours=8)
new_s1 = SleepSession(start_time=start1, end_time=end1)
session.add(new_s1)
start2 = datetime.now(timezone.utc) - timedelta(hours=2)
end2 = datetime.now(timezone.utc)
new_s2 = SleepSession(start_time=start2, end_time=end2)
session.add(new_s2)
await session.commit()
from_date = (date.today() - timedelta(days=7)).isoformat()
to_date = date.today().isoformat()
response = await ac.get(
f"/api/stats/summary?from_date={from_date}&to_date={to_date}"
)
assert response.status_code == 200
assert len(response.json()["slots"]) > 0
data = response.json()
assert data["total_sleeps"] == 2
assert data["total_sleeps_all_time"] == 2
assert data["avg_sleeps_per_day_all_time"] > 0
+247
View File
@@ -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() {
+3
View File
@@ -17,6 +17,9 @@ RUN npm run build
# Stage 2: Production stage
FROM nginx:1.27-alpine
LABEL org.opencontainers.image.source="http://gitea.lan/jacobh2/babymonitor"
LABEL org.opencontainers.image.description="Babymonitor Frontend"
# Copy the built assets from the build stage to Nginx's default HTML directory
COPY --from=build /app/dist /usr/share/nginx/html
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview",
"test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color",
"test:all": "vitest run && npx playwright test",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"clean": "find src -name '*.js' -o -name '*.jsx' -delete"
},
"dependencies": {
"@tanstack/react-query": "^5.100.5",
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

+14 -10
View File
@@ -5,7 +5,8 @@ import type {
SessionListResponse,
DailySummaryResponse,
TimetableResponse,
WakeupBedtimeResponse
WakeupBedtimeResponse,
SummaryResponse
} from '@/types';
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
@@ -31,26 +32,29 @@ export function localToUTC(localValue: string): string {
}
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 });
const params = new URLSearchParams({ from_date, to_date, ...extraParams });
const response = await fetch(`${BASE_URL}/api/stats/${endpoint}?${params.toString()}`);
if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`);
return response.json();
}
export async function fetchDailySummary(from_date: 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 fetchSummary(from_date: string, to_date: string): Promise<SummaryResponse> {
return fetchStats('summary', from_date, to_date);
}
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 fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string, timezone_offset = 0): Promise<DailySummaryResponse> {
return fetchStats('daily-summary', from_date, to_date, { day_start, day_end, timezone_offset: timezone_offset.toString() });
}
export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise<WakeupBedtimeResponse> {
export async function fetchTimetable(from_date: string, to_date: string, day_start: string, day_end: string, timezone_offset = 0): Promise<TimetableResponse> {
return fetchStats('timetable', from_date, to_date, { day_start, day_end, timezone_offset: timezone_offset.toString() });
}
export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string, timezone_offset = 0): Promise<WakeupBedtimeResponse> {
return fetchStats('wake-sleep-times', from_date, to_date, {
wakeup_ref: localHHMMToUTCHHMM(wakeup_ref),
bedtime_ref: localHHMMToUTCHHMM(bedtime_ref)
bedtime_ref: localHHMMToUTCHHMM(bedtime_ref),
timezone_offset: timezone_offset.toString()
});
}
+44 -4
View File
@@ -40,6 +40,26 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
}).reverse();
}, [wakeupBedtime, bedtime_ref]);
const stats = useMemo(() => {
if (!wakeupBedtime?.days) return null;
const validMinutes = wakeupBedtime.days
.map((day) => day.bedtime_minutes)
.filter((m): m is number => m !== null);
if (validMinutes.length === 0) return null;
const min = Math.min(...validMinutes);
const max = Math.max(...validMinutes);
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
const sorted = [...validMinutes].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 !== 0
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
return { min, max, avg, median };
}, [wakeupBedtime]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
if (!chartData.length) {
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
@@ -65,12 +85,12 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: any) => [
formatMinutesAfter(Number(value), bedtime_ref),
formatter={(value: unknown) => [
typeof value === 'number' ? formatMinutesAfter(value, bedtime_ref) : String(value ?? ''),
'Bedtime',
]}
labelFormatter={(label) => label}
/>
labelFormatter={(label) => label}
/>
<Line
type="monotone"
dataKey="minutes"
@@ -81,6 +101,26 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
/>
</LineChart>
</ResponsiveContainer>
{stats && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-4 gap-2 text-center">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, bedtime_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Latest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.max, bedtime_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), bedtime_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Median</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.median), bedtime_ref)}</p>
</div>
</div>
)}
</div>
);
};
@@ -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>
);
}
+23 -2
View File
@@ -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>;
@@ -52,10 +66,10 @@ export function SessionList() {
};
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>
<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>
);
}
@@ -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>
@@ -27,7 +27,8 @@ function formatTimeLabel(mins: number) {
}
const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) => {
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end);
const timezoneOffset = typeof window !== 'undefined' ? new Date().getTimezoneOffset() : 0;
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end, '04:00', '19:00', timezoneOffset);
const [selectedDayIndex, setSelectedDayIndex] = useState(0);
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
@@ -203,6 +204,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
return (
<div
key={`${day}-${step}`}
data-testid={`cell-${day}-${formatTimeLabel(step)}`}
className={`h-2 w-full border border-gray-100 ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
/>
);
+21 -14
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { IntervalPicker } from './IntervalPicker';
import { SettingsPopover } from './SettingsPopover';
import SummaryCards from './SummaryCards';
import DailySummary from './DailySummary';
import Timetable from './SleepTimetable';
import WakeupPlot from './WakeupPlot';
@@ -8,6 +9,7 @@ import BedtimePlot from './BedtimePlot';
import { SessionList } from './SessionList';
import { ErrorBoundary } from '../../components/ErrorBoundary';
import { useSettings } from '../../hooks/useSettings';
import { useSummary } from '../../hooks/useStats';
const StatsPage: React.FC = () => {
const { settings, update } = useSettings();
@@ -16,6 +18,7 @@ const StatsPage: React.FC = () => {
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');
const { data: summary } = useSummary(from_date, to_date);
return (
<div className="max-w-4xl mx-auto space-y-8 pb-12">
@@ -24,19 +27,21 @@ 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);
}}
/>
</div >
<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">
<IntervalPicker
@@ -45,6 +50,8 @@ const StatsPage: React.FC = () => {
/>
</div>
<SummaryCards summary={summary} />
<ErrorBoundary>
<DailySummary
from_date={from_date}
@@ -71,7 +78,7 @@ const StatsPage: React.FC = () => {
bedtime_ref={settings.bedtimeRef}
/>
</ErrorBoundary>
</div >
</div>
<ErrorBoundary>
<Timetable
@@ -0,0 +1,59 @@
import React from 'react';
import { Moon, Calendar } from 'lucide-react';
import type { SummaryResponse } from '@/types';
interface SummaryCardsProps {
summary?: SummaryResponse;
}
const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
if (!summary) return null;
return (
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
<div className="grid grid-cols-2 gap-6">
<div className="flex items-center space-x-4">
<div className="p-3 bg-indigo-50 rounded-2xl text-indigo-600">
<Moon size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Total sleeps in period</p>
<p className="text-lg font-bold text-gray-900">{summary.total_sleeps}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="p-3 bg-amber-50 rounded-2xl text-amber-600">
<Calendar size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Avg sleeps per day in period</p>
<p className="text-lg font-bold text-gray-900">{summary.avg_sleeps_per_day.toFixed(1)}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="p-3 bg-indigo-100 rounded-2xl text-indigo-700">
<Moon size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Total sleeps (all time)</p>
<p className="text-lg font-bold text-gray-900">{summary.total_sleeps_all_time}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="p-3 bg-amber-100 rounded-2xl text-amber-700">
<Calendar size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Avg sleeps per day (all time)</p>
<p className="text-lg font-bold text-gray-900">{summary.avg_sleeps_per_day_all_time.toFixed(1)}</p>
</div>
</div>
</div>
</div>
);
};
export default SummaryCards;
+47 -6
View File
@@ -42,6 +42,27 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
}).reverse();
}, [wakeupBedtime, wakeup_ref]);
const stats = useMemo(() => {
if (!wakeupBedtime?.days) return null;
const validMinutes = wakeupBedtime.days
.map((day) => day.wakeup_minutes)
.filter((m): m is number => m !== null);
if (validMinutes.length === 0) return null;
const min = Math.min(...validMinutes);
const max = Math.max(...validMinutes);
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
const sorted = [...validMinutes].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 !== 0
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
return { min, max, avg, median };
}, [wakeupBedtime]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
if (!chartData.length) {
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
@@ -66,11 +87,11 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
tickFormatter={(value) => formatMinutesAfter(Number(value), wakeup_ref)}
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: any) => [
formatMinutesAfter(Number(value), wakeup_ref),
'Wakeup',
]}
<Tooltip
formatter={(value: unknown) => [
typeof value === 'number' ? formatMinutesAfter(value, wakeup_ref) : String(value ?? ''),
'Wakeup',
]}
labelFormatter={(label) => label}
/>
<Line
@@ -83,7 +104,27 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
/>
</LineChart>
</ResponsiveContainer>
</div >
{stats && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-4 gap-2 text-center">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, wakeup_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Latest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.max, wakeup_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), wakeup_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Median</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.median), wakeup_ref)}</p>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,123 @@
import React, { useState, useRef } from 'react';
interface ClockPickerProps {
hour: number;
minute: number;
onChange: (hour: number, minute: number) => void;
}
interface NumberDialPickerProps {
mode: 'hour' | 'minute';
value: number;
onChange: (val: number) => void;
onModeChange?: (mode: 'hour' | 'minute') => void;
}
const NumberDialPicker: React.FC<NumberDialPickerProps> = ({ mode, value, onChange, onModeChange }) => {
const containerRef = useRef<HTMLDivElement>(null);
const totalItems = mode === 'hour' ? 24 : 60;
const items = Array.from({ length: totalItems }, (_, i) => i);
const handleDialClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const x = e.clientX - centerX;
const y = e.clientY - centerY;
let angle = Math.atan2(y, x) * (180 / Math.PI) + 90;
if (angle < 0) angle += 360;
const step = 360 / totalItems;
// Use a small epsilon or rounding to ensure clicking near boundaries works well
const selectedIndex = Math.round(angle / step) % totalItems;
onChange(selectedIndex);
if (mode === 'hour' && onModeChange) {
setTimeout(() => onModeChange('minute'), 150);
}
};
return (
<div className="flex flex-col items-center w-full">
<div className="flex bg-gray-100 p-1 rounded-full w-full max-w-[280px] mb-6">
<button
onClick={() => onModeChange?.('hour')}
className={`flex-1 py-2 text-sm font-medium rounded-full transition-all ${
mode === 'hour' ? 'bg-white shadow-sm text-indigo-600' : 'text-gray-500'
}`}
>
Hour
</button>
<button
onClick={() => onModeChange?.('minute')}
className={`flex-1 py-2 text-sm font-medium rounded-full transition-all ${
mode === 'minute' ? 'bg-white shadow-sm text-indigo-600' : 'text-gray-500'
}`}
>
Minute
</button>
</div >
<div
ref={containerRef}
onClick={handleDialClick}
className="relative w-64 h-64 rounded-full bg-gray-50 border-4 border-white shadow-inner cursor-pointer touch-none select-none overflow-hidden"
style={{ backgroundImage: 'radial-gradient(circle, #f3f4f6 1px, transparent 1px)', backgroundSize: '20px 20px' }}
>
<div className="absolute top-1/2 left-1/2 w-1 h-1 bg-indigo-600 -translate-x-1/2 -translate-y-1/2 z-30 opacity-50" />
{items.map((item) => {
const angle = (item * (360 / totalItems)) - 90;
return (
<div
key={item}
className={`absolute top-1/2 left-1/2 text-sm font-bold transition-all duration-200
${item === value ? 'text-indigo-600 scale-125' : 'text-gray-400'}
`}
style={{
transform: `translate(-50%, -50%) rotate(${angle}deg) translateY(-108px) rotate(${-angle}deg)`
}}
>
{item.toString().padStart(2, '0')}
</div >
);
})}
<div className="absolute top-1/2 left-1/2 w-12 h-12 bg-indigo-50 rounded-full -translate-x-1/2 -translate-y-1/2 z-0" />
</div >
</div >
);
};
const ClockPicker: React.FC<ClockPickerProps> = ({ hour, minute, onChange }) => {
const [mode, setMode] = useState<'hour' | 'minute'>('hour');
const handleTimeChange = (newHour: number, newMinute: number) => {
onChange(newHour, newMinute);
};
const handleNumberChange = (val: number) => {
if (mode === 'hour') {
handleTimeChange(val, minute);
} else {
handleTimeChange(hour, val);
}
};
return (
<NumberDialPicker
mode={mode}
value={mode === 'hour' ? hour : minute}
onChange={handleNumberChange}
onModeChange={setMode}
/>
);
};
export default ClockPicker;
@@ -10,6 +10,10 @@ const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
const isSleeping = !!activeSession;
const handleClick = async () => {
if (!time) {
alert('Please select a valid time.');
return;
}
try {
if (isSleeping && activeSession?.id) {
await endSession({ sessionId: activeSession.id, endTime: time });
+57
View File
@@ -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>
);
}
+8 -42
View File
@@ -1,44 +1,10 @@
import { useState, useEffect } from 'react';
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,
};
import { useContext } from 'react';
import { SettingsContext, Settings } from './SettingsProvider';
export function useSettings() {
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 { settings, update };
}
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
}
+17 -9
View File
@@ -1,6 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client';
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse } from '../types';
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes, fetchSummary } from '../api/client';
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse } from '../types';
export function useSummary(from_date: string, to_date: string) {
return useQuery<SummaryResponse>({
queryKey: ['summary', from_date, to_date],
queryFn: () => fetchSummary(from_date, to_date),
});
}
export function useStats(
from_date: string,
@@ -8,21 +15,22 @@ export function useStats(
day_start = '07:00',
day_end = '20:00',
wakeup_ref = '04:00',
bedtime_ref = '19:00'
bedtime_ref = '19:00',
timezoneOffset = 0
) {
const dailySummaryQuery = useQuery<DailySummaryResponse>({
queryKey: ['dailySummary', from_date, to_date, day_start, day_end],
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end),
queryKey: ['dailySummary', from_date, to_date, day_start, day_end, timezoneOffset],
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end, timezoneOffset),
});
const timetableQuery = useQuery<TimetableResponse>({
queryKey: ['timetable', from_date, to_date, day_start, day_end],
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end),
queryKey: ['timetable', from_date, to_date, day_start, day_end, timezoneOffset],
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end, timezoneOffset),
});
const wakeupBedtimeQuery = useQuery<WakeupBedtimeResponse>({
queryKey: ['wakeSleepTimes', from_date, to_date, wakeup_ref, bedtime_ref],
queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref),
queryKey: ['wakeSleepTimes', from_date, to_date, wakeup_ref, bedtime_ref, timezoneOffset],
queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref, timezoneOffset),
});
return {
+5 -2
View File
@@ -2,14 +2,17 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
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()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<SettingsProvider>
<App />
</SettingsProvider>
</QueryClientProvider>
</React.StrictMode>,
)
-1
View File
@@ -1 +0,0 @@
"use strict";
+7
View File
@@ -1,3 +1,10 @@
export interface SummaryResponse {
total_sleeps: number;
avg_sleeps_per_day: number;
total_sleeps_all_time: number;
avg_sleeps_per_day_all_time: number;
}
export interface SleepSession {
id: number;
start_time: string;
+8 -1
View File
@@ -68,7 +68,11 @@ export function formatElapsedTime(utcTimestamp: string): string {
* @returns formatted datetime string
*/
export function formatDateTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
let dateToParse = date;
if (typeof date === 'string' && !date.endsWith('Z') && !date.includes('+')) {
dateToParse = date + 'Z';
}
const d = typeof dateToParse === 'string' ? new Date(dateToParse) : dateToParse;
return d.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
@@ -86,6 +90,9 @@ export function formatDateTime(date: Date | string): string {
*/
export function toUTCFromLocalInput(inputValue: string): string {
const d = new Date(inputValue);
if (isNaN(d.getTime())) {
return "";
}
return d.toISOString();
}
+127
View File
@@ -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();
});
});
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
const BASE_URL = 'http://localhost:5173';
test('visual inspection of TimeAdjuster', async ({ page }) => {
await page.goto(`${BASE_URL}/playground`);
await page.waitForLoadState('networkidle');
// Select 'TimeAdjuster'
const select = page.locator('select#component-select');
await select.selectOption({ label: 'TimeAdjuster' });
// Wait for component to render
const mainArea = page.getByRole('main').last();
await expect(mainArea).toBeVisible();
// Take a clean, high-resolution screenshot of the component
const componentContainer = mainArea.locator('div').first(); // The wrapper in playground
await componentContainer.screenshot({ path: 'time_adjuster_component_clean.png' });
});
+16
View File
@@ -70,4 +70,20 @@ describe('SleepButton', () => {
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();
});
});
+14
View File
@@ -29,4 +29,18 @@ describe('TimeAdjuster', () => {
fireEvent.click(resetButton);
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("");
});
});
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { vi, describe, it, expect, beforeEach } from 'vitest';
@@ -35,78 +35,46 @@ describe('SleepTimetable', () => {
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
});
it('renders in desktop mode (chronological order)', () => {
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
isLoading: false,
});
it('shows sleeping status for intervals spanning midnight across days', () => {
const day1 = '2025-04-18';
const day2 = '2025-04-19';
vi.stubGlobal('innerWidth', 1024);
render(<Timetable {...defaultProps} />);
// Check for day headers in chronological order
const days = screen.getAllByText(/^[A-Z]{3}$/i); // SUN, MON, etc.
// 2025-04-18 is Friday (FRI)
expect(days[0]).toHaveTextContent(/FRI/i);
expect(days[1]).toHaveTextContent(/SAT/i);
});
it('renders in medium mode (3-day tabs and 3-column grid)', () => {
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
isLoading: false,
});
vi.stubGlobal('innerWidth', 600);
render(<Timetable {...defaultProps} />);
fireEvent(window, new Event('resize'));
// Check for 3-day interval tabs
// In reversed order (25, 24, 23...), the first group is Fri - Wed
expect(screen.getByRole('button', { name: /Fri \d+ - Wed \d+/i })).toBeInTheDocument();
});
it('renders in small mode (single-day tabs and 1-column grid)', () => {
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
isLoading: false,
});
vi.stubGlobal('innerWidth', 300);
render(<Timetable {...defaultProps} />);
fireEvent(window, new Event('resize'));
// Check for single-day tabs
const dayTabs = screen.getAllByRole('button');
// At least one tab should have the text FRI
const friTab = dayTabs.find(tab => tab.textContent?.toUpperCase().includes('FRI'));
expect(friTab).toBeInTheDocument();
});
it('colors sleeping intervals correctly', () => {
(useStats as any).mockReturnValue({
timetable: {
slots: [
{
date: '2025-04-18',
date: day1,
sleeping: true,
slot_start: '01:00',
slot_end: '03:00', // Covers 01:00 and 02:00
slot_start: '23:00',
slot_end: '23:45',
},
{
date: day2,
sleeping: true,
slot_start: '00:00',
slot_end: '01:00',
},
],
},
isLoading: false,
});
const { container } = render(<Timetable {...defaultProps} />);
const testProps = {
from_date: day1,
to_date: day2,
day_start: '22:00',
day_end: '08:00',
};
// We are looking for the grid cells.
// Since they don't have text, we look at their classes/backgrounds.
const greenCells = container.querySelectorAll('.bg-green-500');
expect(greenCells.length).toBeGreaterThan(0);
vi.stubGlobal('innerWidth', 1024); // Desktop mode
const { container } = render(<Timetable {...testProps} />);
const grayCells = container.querySelectorAll('.bg-gray-50');
expect(grayCells.length).toBeGreaterThan(0);
// Check Day 1: 23:00
const cellD1_2300 = container.querySelector(`[data-testid="cell-${day1}-23:00"]`);
expect(cellD1_2300).toHaveClass('bg-green-500');
// Check Day 2: 00:15
const cellD2_0015 = container.querySelector(`[data-testid="cell-${day2}-00:15"]`);
expect(cellD2_0015).toHaveClass('bg-green-500');
});
});
});
+32
View File
@@ -11,6 +11,8 @@ import {
timeToMinutes,
adjustForTimezone,
getLocalOffsetFromReference,
formatDateTime,
toUTCFromLocalInput,
} from '../../src/utils/time';
describe('formatMinutesAfter', () => {
@@ -125,6 +127,21 @@ describe('adjustForTimezone', () => {
});
});
describe('formatDateTime', () => {
it('treats string without Z as UTC', () => {
const dateStr = '2024-01-15T10:00:00';
const dateWithZ = '2024-01-15T10:00:00Z';
expect(formatDateTime(dateStr)).toBe(formatDateTime(dateWithZ));
});
it('formats a Date object correctly', () => {
const date = new Date('2024-01-15T10:00:00Z');
// The output depends on the local timezone, so we check for part of it
const result = formatDateTime(date);
expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
});
});
describe('getLocalOffsetFromReference', () => {
it('returns local offset from reference in minutes', () => {
const utcMins = 600; // 10:00 UTC
@@ -136,3 +153,18 @@ describe('getLocalOffsetFromReference', () => {
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('');
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+2 -1
View File
@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
@@ -18,4 +19,4 @@ export default defineConfig({
},
},
},
})
})
+32 -16
View File
@@ -1,22 +1,38 @@
# Sleep Timetable Bug Fixes
# Implementation Plan: Fix Sleep Timetable Timezone Issue
## Identified Issues
1. **Pre-selection**: Component doesn't always pre-select the latest date across all tiers.
2. **Grid Layout (Medium Tier)**: Grid columns are hardcoded to 3, causing breakage when fewer than 3 days are displayed at the end of a range.
3. **Selector Information**: Tabs only show weekday names, making it hard to distinguish dates.
## Overview
The `SleepTimetable` component incorrectly attributes sleep segments spanning midnight to the wrong day because the backend calculates time-slots in UTC without awareness of the user's local timezone. This plan implements a timezone-aware API to ensure sleep sessions are correctly mapped to the user's local dates.
## Implementation Plan
## Detailed Steps
### 1. Fix Pre-selection
- Add a `useEffect` to set `selectedDayIndex` to the last index of the `days` array whenever the `days` list is initialized or updated. This ensures "today" (the latest date) is selected by default.
### 1. Reproduction (Frontend Test)
- Create a new test file or add to `frontend/tests/unit/components/stats/SleepTimetable.test.tsx`.
- Mock `useStats` to return a timetable containing a session that spans midnight (e.g., Wed 23:00 - Thu 06:00).
- The mock data should reflect what the backend currently returns (which is incorrectly attributed to the wrong day due to timezone mismatch).
- Assert that for a user in a non-UTC timezone, the green slots for 00:00-02:00 are on the correct day (Thu).
- This test is expected to fail initially because the current implementation attributes them to Wed.
### 2. Fix Grid Alignment
- Refactor the `gridTemplateColumns` logic for both the header and time grid.
- Instead of hardcoding tiers (e.g., `repeat(3, 1fr)`), use the actual number of days in the current view: `4rem repeat(${visibleDays.length}, 1fr)`. This allows the grid to gracefully handle 1, 2, or 3 columns.
### 2. Backend Implementation (API & Logic)
- **Modify API**: Update `backend/app/routers/stats.py`:
- `get_timetable` to accept `timezone_offset: int = Query(0)`.
- `get_daily_summary` and `get_wake_sleep_times` to also accept `timezone_offset`.
- **Core Logic Change**: In `get_timetable`:
- Use `timezone_offset` to translate the requested `from_date`/`to_date` (local) into a UTC range for database queries.
- When generating slots, calculate the `slot_start_time` and `slot_end_time` in UTC for comparison with session times.
- When creating the response object, convert those UTC times to Local Time and attach the correct Local Date.
- This ensures that a session spanning midnight is correctly mapped to its local date segments.
### 3. Enhance Selector Labels
- Implement a `formatDayLabel` helper that combines the short weekday name and the day of the month (e.g., "FRI 24").
- Update `tabGroups` generation for both 'medium' (range) and 'small' (single day) tiers to use this descriptive label.
### 3. Frontend Implementation (Integration)
- **Update Hook**: Modify `frontend/src/hooks/useStats.ts` to accept `timezoneOffset` and pass it to the API calls (`fetchTimetable`, etc.).
- **Update Client**: Modify `frontend/src/api/client.ts` to include the offset in request parameters.
- **Update Component**: In `frontend/src/components/stats/SleepTimetable.tsx`, ensure the component retrieves the offset via `new Date().getTimezoneOffset()` and passes it to `useStats`.
- **Cleanup**: Remove the manual `adjustForTimezone` calls in `SleepTimetable.tsx` since the backend will now provide correctly adjusted local times.
### 4. UI Compactness
- Adjust spacing (margins/paddings) in the mobile/medium view to reduce overall component height and keep selectors closer to the grid.
### 4. Verification
- Run the frontend unit tests.
- Verify the fix against the reported issue using a manual test or e2e test if possible.
---
**Note to Developer:**
To maintain a high-quality context window and avoid hallucinations, you **MUST** delegate complex tasks to sub-agents. Break down each phase above into smaller, atomic objectives for sub-agents (e.g., one agent for backend API changes, another for frontend hook updates). Do not attempt to execute the entire plan in a single thread.
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
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
# Things I need to tell my AI to do
- [ ] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing
- [ ] Add a test coverage metric to both frontend and backend so that we can check how much of the code is tested. I would like >=90%
- [ ] Go over Claude's slop-cleanup-stuff
- [x] Update the "Sleep Sessions" by adding a fake pagination view in the frontend so that I don't have to scroll so much. Instead, I want to simply click "next" and "previous" (with like numbers in between like a google search)
- [x] Bug with gitea workflow:
```
Tag and Push Version
2s
Run git tag $VERSION
To http://gitea:3001/jacobh2/babymonitor
! [rejected] v1.0.0 -> v1.0.0 (already exists)
error: failed to push some refs to 'http://gitea:3001/jacobh2/babymonitor';
hint: Updates were rejected because the tag already exists in the remote.
❌ Failure - Main Tag and Push Version
exitcode '1': failure
```
- [ ] Editing a sleep session in "sleep sessions", when I click the edit button the times show up as UTC but should show as my local timezone
- [ ] We should drop the "notes" column from the sleep_session table
- [ ] Switch over to oh-my-opencode and try it out
- [ ] Adda footer at the bottom of the app that shows the version (aka git tag) of the app