28 Commits

Author SHA1 Message Date
jacobh2 a86bf2ded4 feat(frontend): remove 'v' prefix from footer and add dev mode versioning
Build Backend / build (push) Successful in 2m42s
Build Frontend / build (push) Successful in 11m0s
2026-06-07 20:31:30 +02:00
jacobh2 969b8e033f No idea what it is doing... very sad 2026-06-07 19:59:31 +02:00
jacobh2 cfc3156906 refactor: update sleep session logic, models, and agent protocols 2026-06-04 16:37:37 +02:00
jacobh2 81eadf618a Update gitignore
Build Frontend / build (push) Successful in 9m6s
2026-06-04 11:33:42 +02:00
jacobh2 6847886e7a chore: add vite environment type definitions 2026-06-04 11:33:21 +02:00
jacobh2 5d8a8b3c57 chore: update deployment and app structure 2026-06-04 11:31:44 +02:00
jacobh2 32d3e86d38 fix(frontend): resolve linting errors and component duplication 2026-06-04 11:30:18 +02:00
jacobh2 b763aa1e9d Small cleanup
Build Backend / build (push) Successful in 1m54s
2026-06-04 09:18:22 +02:00
jacobh2 df210c66a0 Update todo 2026-06-04 09:00:11 +02:00
jacobh2 560863d914 Remove linting jobs from Gitea workflows 2026-06-04 08:58:42 +02:00
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
57 changed files with 7931 additions and 625 deletions
+6 -11
View File
@@ -19,26 +19,22 @@ jobs:
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)
@@ -50,8 +46,7 @@ jobs:
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build Docker image
run: |
docker buildx build \
@@ -59,8 +54,8 @@ jobs:
-t gitea.lan/jacobh2/babymonitor-backend:${{ env.VERSION }} \
./backend \
--push
- name: Tag and Push Version
run: |
git tag $VERSION
git push origin $VERSION
git tag "$VERSION"
git push origin "$VERSION"
+8 -11
View File
@@ -19,26 +19,22 @@ jobs:
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)
@@ -50,17 +46,18 @@ jobs:
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build Docker image
run: |
docker buildx build \
--platform linux/arm64 \
--build-arg VITE_APP_VERSION=$(git describe --tags) \
--build-arg VITE_APP_GITSHA=$(git rev-parse --short HEAD) \
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
./frontend \
--push
- name: Tag and Push Version
run: |
git tag $VERSION
git push origin $VERSION
git tag "$VERSION"
git push origin "$VERSION"
+6
View File
@@ -1,6 +1,12 @@
# 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
.omo/
### Node ###
# Logs
logs
+95 -27
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,40 +36,109 @@ 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
- **Scripts**: All scripts created in the `backend` folder must be placed in `backend/scripts/`.
- **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.
## MANDATORY EXECUTION PROTOCOLS (INJECTABLE)
The following protocols must be strictly adhered to upon injection:
- **Read Before Edit**: ALWAYS read the entire target file before performing any `edit` operation.
- **Post-Edit Verification**: Immediately after an edit, verify that no lines have been duplicated.
- **Stability Check**: Following any code change, run both backend and frontend services AND the full test suite to ensure system stability.
## 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.
+23 -18
View File
@@ -113,22 +113,27 @@ async def update_session(
return sleep_session
async def get_all_time_stats(session: AsyncSession) -> tuple[int, float]:
statement = select(func.count(), func.min(SleepSession.start_time))
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)
total_sessions, first_session_time = result.one()
if total_sessions == 0 or first_session_time is None:
return 0, 0.0
# DB has naive datetimes
first_session_time_naive = first_session_time.replace(tzinfo=None)
# Get current date (naive for comparison)
today_date = datetime.now().date()
first_date = first_session_time_naive.date()
num_days = (today_date - first_date).days + 1
avg_sleeps = total_sessions / num_days if num_days > 0 else 0.0
return total_sessions, avg_sleeps
return list(result.all())
-1
View File
@@ -9,6 +9,5 @@ class SleepSession(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
start_time: datetime = Field(nullable=False)
end_time: Optional[datetime] = Field(default=None, nullable=True)
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+70 -30
View File
@@ -3,7 +3,7 @@ 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, get_all_time_stats
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
@@ -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 = datetime.now(timezone.utc).replace(tzinfo=None)
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:
effective_end_time = s.end_time if s.end_time is not None else now
if s.start_time <= slot_end_time and effective_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(
@@ -106,14 +125,26 @@ async def get_summary(
to_date: datetime,
session: AsyncSession = Depends(get_session),
):
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
total_sleeps = len(sessions)
# 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
total_sleeps_all_time, avg_sleeps_per_day_all_time = await get_all_time_stats(session)
# 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,
@@ -128,17 +159,22 @@ async def get_wake_sleep_times(
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():
@@ -150,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(
{
-5
View File
@@ -6,22 +6,17 @@ from pydantic import BaseModel
class SleepSessionCreate(BaseModel):
start_time: datetime
class SleepSessionUpdate(BaseModel):
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
notes: Optional[str] = None
class SleepSessionRead(BaseModel):
id: int
start_time: datetime
end_time: Optional[datetime]
notes: Optional[str]
created_at: datetime
updated_at: datetime
class SessionListResponse(BaseModel):
sessions: List[SleepSessionRead]
total: int
+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"]
-14
View File
@@ -1,14 +0,0 @@
INFO: Will watch for changes in these directories: ['/Users/jhag/projects/babymonitor/backend']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [63655] using StatReload
INFO: Started server process [63657]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: StatReload detected changes in 'app/main.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [63657]
INFO: Started server process [71064]
INFO: Waiting for application startup.
INFO: Application startup complete.
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
docker buildx build \
--platform linux/arm64 \
-t babymonitor-frontend:latest \
-f frontend/Dockerfile \
--load \
frontend
docker buildx build \
--platform linux/arm64 \
-t babymonitor-backend:latest \
-f backend/Dockerfile \
--load \
backend
+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() {
+4
View File
@@ -1,5 +1,9 @@
FROM node:22-alpine AS build
ARG VITE_APP_VERSION=unknown
ARG VITE_APP_GITSHA=unknown
ENV VITE_APP_VERSION=$VITE_APP_VERSION
ENV VITE_APP_GITSHA=$VITE_APP_GITSHA
WORKDIR /app
# Copy package files first to leverage Docker cache
+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

+3
View File
@@ -4,6 +4,8 @@ import TrackerPage from './components/tracker/TrackerPage';
import StatsPage from './components/stats/StatsPage';
import Navbar from './components/Navbar';
import Playground from './components/Playground';
import Footer from './components/Footer';
function App() {
return (
@@ -17,6 +19,7 @@ function App() {
<Route path="/playground" element={<Playground />} />
</Routes>
</main>
<Footer />
</div>
</Router>
);
+8 -9
View File
@@ -32,9 +32,7 @@ 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();
@@ -44,18 +42,19 @@ export async function fetchSummary(from_date: string, to_date: string): Promise<
return fetchStats('summary', from_date, to_date);
}
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 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 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 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): Promise<WakeupBedtimeResponse> {
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()
});
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
const Footer: React.FC = () => {
return (
<footer className="py-4 text-center">
<p className="text-xs text-slate-500">
{__APP_VERSION__} ({__APP_GITSHA__})
</p>
</footer>
);
};
export default Footer;
+27 -18
View File
@@ -51,8 +51,13 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
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 };
return { min, max, avg, median };
}, [wakeupBedtime]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
@@ -79,11 +84,11 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
tickFormatter={(value) => formatMinutesAfter(Number(value), bedtime_ref)}
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: number) => [
formatMinutesAfter(value, bedtime_ref),
'Bedtime',
]}
<Tooltip
formatter={(value: unknown) => [
typeof value === 'number' ? formatMinutesAfter(value, bedtime_ref) : String(value ?? ''),
'Bedtime',
]}
labelFormatter={(label) => label}
/>
<Line
@@ -97,20 +102,24 @@ 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-3 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 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">Average</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), bedtime_ref)}</p>
<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>
<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>
);
@@ -100,8 +100,8 @@ const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_sta
width={60}
/>
<Tooltip
formatter={(value: any, name: any) => [
formatMinutes(Math.round(Number(value ?? 0) * 60)),
formatter={(value: number | string | readonly (string | number)[] | undefined, name: string | number | undefined) => [
formatMinutes(Math.round(Number((Array.isArray(value) ? value[0] : value ?? 0) * 60))),
name === 'daySleep' ? 'Day' : 'Night',
]}
/>
@@ -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>
);
}
+34 -13
View File
@@ -1,27 +1,41 @@
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';
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes, toUTCFromLocalInput, parseBackendDateTime } 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>;
const handleSave = async (id: number) => {
try {
await updateSession({
sessionId: id,
payload: {
start_time: new Date(editStart).toISOString(),
end_time: editEnd ? new Date(editEnd).toISOString() : undefined,
}
});
await updateSession({
sessionId: id,
payload: {
start_time: toUTCFromLocalInput(editStart),
end_time: editEnd ? toUTCFromLocalInput(editEnd) : undefined,
},
});
setEditingId(null);
} catch (err) {
console.error(err);
@@ -43,8 +57,8 @@ export function SessionList() {
const startEditing = (s: SleepSessionRead) => {
setEditingId(s.id);
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : '');
setEditStart(toLocalDatetimeInputValue(parseBackendDateTime(s.start_time)));
setEditEnd(s.end_time ? toLocalDatetimeInputValue(parseBackendDateTime(s.end_time)) : '');
};
const handleCancel = () => {
@@ -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>
</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>
@@ -1,6 +1,6 @@
import { useMemo, useState, useEffect, Fragment } from 'react';
import { useStats } from '../../hooks/useStats';
import { timeToMinutes, adjustForTimezone } from '../../utils/time';
import { timeToMinutes } from '../../utils/time';
interface TimetableProps {
from_date: string;
@@ -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);
@@ -51,7 +52,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
useEffect(() => {
if (days.length > 0) {
setSelectedDayIndex(days.length - 1);
const newIndex = days.length - 1;
setSelectedDayIndex(prev => (prev !== newIndex ? newIndex : prev));
}
}, [days]);
@@ -106,8 +108,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
return timetable.slots.some((slot) => {
if (slot.date !== date || !slot.sleeping) return false;
const sStart = adjustForTimezone(timeToMinutes(slot.slot_start));
const sEnd = adjustForTimezone(timeToMinutes(slot.slot_end));
const sStart = timeToMinutes(slot.slot_start);
const sEnd = timeToMinutes(slot.slot_end);
if (sEnd <= sStart) {
const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
@@ -157,7 +159,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
</button>
);
})}
</div >
</div>
)}
{/* Grid Container */}
@@ -175,27 +177,27 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
{visibleDays.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
{getDayName(day)}
</div >
</div>
))}
</div >
</div>
{/* Time Grid Container */}
<div className="relative">
<div
className="grid"
style={{
gridTemplateColumns: tier === 'desktop'
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
: `4rem repeat(${visibleDays.length}, 1fr)`
}}
>
className="grid"
style={{
gridTemplateColumns: tier === 'desktop'
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
: `4rem repeat(${visibleDays.length}, 1fr)`
}}
>
{/* Time Rows */}
{timeSteps.map((step) => (
<Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
{step % 60 === 0 ? formatTimeLabel(step) : ''}
</div >
</div>
{/* Grid Cells */}
{visibleDays.map((day) => {
@@ -203,6 +205,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'}`}
/>
);
@@ -218,11 +221,11 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
transform: 'translateY(-50%)'
}}
/>
</div >
</div >
</div >
</div >
</div>
</div>
</div>
</div>
);
};
export default Timetable;
export default Timetable;
+14 -12
View File
@@ -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">
+27 -17
View File
@@ -42,20 +42,26 @@ 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);
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;
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 min = Math.min(...validMinutes);
const max = Math.max(...validMinutes);
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
return { min, max, avg };
}, [wakeupBedtime]);
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) {
@@ -81,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: number) => [
formatMinutesAfter(value, wakeup_ref),
'Wakeup',
]}
<Tooltip
formatter={(value: unknown) => [
typeof value === 'number' ? formatMinutesAfter(value, wakeup_ref) : String(value ?? ''),
'Wakeup',
]}
labelFormatter={(label) => label}
/>
<Line
@@ -99,7 +105,7 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
</LineChart>
</ResponsiveContainer>
{stats && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-2 text-center">
<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>
@@ -112,6 +118,10 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
<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>
@@ -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 { createContext, 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 } 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;
}
+8 -7
View File
@@ -15,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";
+24 -3
View File
@@ -38,9 +38,15 @@ export function adjustForTimezone(utcMinutes: number): number {
* @returns string in "YYYY-MM-DDTHH:MM" format
*/
export function toLocalDatetimeInputValue(date: Date): string {
const offset = date.getTimezoneOffset() * 60 * 1000;
const localDate = new Date(date.getTime() - offset);
return localDate.toISOString().slice(0, 16);
const formatter = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return formatter.format(date).replace(' ', 'T');
}
export function formatElapsedTime(utcTimestamp: string): string {
@@ -90,6 +96,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();
}
@@ -189,3 +198,15 @@ export function formatMinutes(totalMinutes: number): string {
}
return `${mins}m`;
}
/**
* Parses a backend timestamp string into a Date object,
* ensuring it is treated as UTC if no timezone info is present.
*/
export function parseBackendDateTime(time: string): Date {
if (typeof time === 'string' && !time.endsWith('Z') && !time.includes('+')) {
return new Date(time + 'Z');
}
// If it already has Z or offset, new Date() will handle it correctly.
return new Date(time);
}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_GITSHA__: string;
+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();
});
});
+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 Wed - Fri
expect(screen.getByRole('button', { name: /Wed \d+ - Fri \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');
});
});
});
+42
View File
@@ -12,6 +12,8 @@ import {
adjustForTimezone,
getLocalOffsetFromReference,
formatDateTime,
toUTCFromLocalInput,
parseBackendDateTime,
} from '../../src/utils/time';
describe('formatMinutesAfter', () => {
@@ -152,3 +154,43 @@ 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('');
});
});
describe('parseBackendDateTime', () => {
it('should parse a UTC string without Z as UTC', () => {
const time = '2026-06-04 13:58:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
it('should parse a UTC string with Z as UTC', () => {
const time = '2026-06-04T13:58:00Z';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
it('should parse a string with an offset correctly', () => {
const time = '2026-06-04T13:58:00+02:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T11:58:00.000Z');
});
it('should handle ISO format with T', () => {
const time = '2026-06-04T13:58:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { toLocalDatetimeInputValue } from '../../src/utils/time';
describe('toLocalDatetimeInputValue - Timezone Correctness', () => {
it('converts a UTC date to the correct local datetime-local input value (Stockholm)', () => {
// 11:58 UTC on June 4th, 2026 should be 13:58 in Stockholm (UTC+2)
const date = new Date('2026-06-04T11:58:00Z');
// We calculate the expected value using a deterministic formatter with explicit timezone
const expected = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Europe/Stockholm'
}).format(date).replace(' ', 'T');
const result = toLocalDatetimeInputValue(date);
// This assertion will fail if the function returns UTC instead of Stockholm local time
expect(result).toBe(expected);
});
it('converts a UTC date to the correct local datetime-local input value (other timezone)', () => {
// Testing with another timezone to ensure it's not hardcoded (though it shouldn't be)
const date = new Date('2026-06-04T11:58:00Z');
// Tokyo is UTC+9. 11:58 UTC -> 20:58 Tokyo
const expectedTokyo = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Asia/Tokyo'
}).format(date).replace(' ', 'T');
const result = toLocalDatetimeInputValue(date);
// NOTE: This second test might fail if the environment is NOT Tokyo/Stockholm,
// but it's useful to show that the current implementation depends on runtime TZ.
// However, for our specific bug fix, we only need to prove it's NOT necessarily local if the runner is UTC.
// Actually, let's just stick to one robust test that proves it doesn't match the intended local time if the runner is UTC.
});
});
+20
View File
@@ -2,9 +2,29 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { execSync } from 'child_process';
const isDev = process.env.NODE_ENV === 'development' || process.env.vite === 'true';
let APP_VERSION = process.env.VITE_APP_VERSION || 'unknown';
let APP_GITSHA = process.env.VITE_APP_GITSHA || 'unknown';
if (isDev) {
APP_VERSION = 'DEV';
try {
APP_GITSHA = execSync('git rev-parse --short HEAD').toString().trim();
} catch (e) {
APP_GITSHA = 'unknown';
}
}
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(APP_VERSION),
__APP_GITSHA__: JSON.stringify(APP_GITSHA),
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+38
View File
@@ -0,0 +1,38 @@
# Implementation Plan: Fix Sleep Timetable Timezone Issue
## 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.
## Detailed Steps
### 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. 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. 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. 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.
-5
View File
@@ -1,5 +0,0 @@
podman save babymonitor-frontend:latest -o frontend.tar
podman save babymonitor-backend:latest -o backend.tar
scp frontend.tar backend.tar jacobhagstedt@raspberrypi.local:~/
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
-4
View File
@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}
-33
View File
@@ -1,33 +0,0 @@
const adjustForTimezone = (utcMinutes) => {
const offsetInMinutes = -120; // Simulate UTC+2
return utcMinutes - offsetInMinutes;
};
const formatMinutesAfter = (minutes, reference = "04:00") => {
const [refHours, refMinutes] = reference.split(":").map(Number);
const totalRefMinutes = refHours * 60 + refMinutes;
const targetMinutes = totalRefMinutes + minutes;
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
};
// Case 1: Backend sends offset from 04:00 UTC (-5 minutes)
const backendOffset = -5;
const adjusted = adjustForTimezone(backendOffset);
console.log("Adjusted offset:", adjusted); // Should be 115
console.log("Formatted time:", formatMinutesAfter(adjusted, "04:00")); // Should be 05:55
// Case 2: Backend sends minutes from UTC midnight (235 minutes)
const backendMidnightMins = 235;
const adjusted2 = adjustForTimezone(backendMidnightMins);
console.log("Adjusted midnight mins:", adjusted2); // Should be 355
// If we use it as an offset from reference:
console.log("Formatted time (as offset):", formatMinutesAfter(adjusted2, "04:00")); // Should be 09:55 (WRONG if it was supposed to be 05:55)
// If we want the offset from reference for Case 2:
const refMins = 4 * 60;
const correctOffset = adjusted2 - refMins; // 355 - 240 = 115
console.log("Corrected offset (from midnight):", correctOffset); // 115
console.log("Formatted time (corrected):", formatMinutesAfter(correctOffset, "04:00")); // 05:55
+30 -1
View File
@@ -3,4 +3,33 @@
- [ ] 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
- [ ] 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] 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
- [x] We should drop the "notes" column from the sleep_session table
- [x] Switch over to oh-my-opencode and try it out
- [x] Adda footer at the bottom of the app that shows the version (aka git tag) of the app
- [x] Fix race condition of gitea workflows
If both workflows run at the same time, a race condition occurs:
1. Collision: Both workflows will query the latest tag (e.g., v1.0.5) and calculate the same next version (v1.0.6).
2. The Winner: The first workflow to finish its push will successfully create the v1.0.6 tag on the remote.
3. The Loser: The second workflow will attempt to push v1.0.6, but the remote will reject it because that tag already exists, causing the workflow to fail.
This happens because both workflows share the same global tag namespace (v1.0.x). To fix this, you should differentiate them by using unique prefixes, such as backend-v1.0.x and frontend-v1.0.x.
--> Invalid. the containers are named differently.
- [x] remove the lint form the gitea workflows added by the agent. makes no sense there
-58
View File
@@ -1,58 +0,0 @@
import datetime
def verify_real_wakeup_with_mock_api():
# --- DATA FROM DATABASE (REAL) ---
# session id 1 wakeup: 2026-05-27 03:17:00 UTC
wakeup_utc = datetime.datetime(2026, 5, 27, 3, 17)
wakeup_utc_mins = wakeup_utc.hour * 60 + wakeup_utc.minute
# --- SETUP PARAMETERS (CLIENT SIDE) ---
# User is in Stockholm (+2)
offset_in_minutes = -120
local_ref_str = "04:00"
local_ref_mins = 4 * 60
print(f"1. Database Wakeup (UTC): {wakeup_utc} ({wakeup_utc_mins}m)")
print(f"2. Client Local Reference: {local_ref_str} ({local_ref_mins}m)")
print(f"3. Client Timezone Offset: {offset_in_minutes}m")
# --- STEP 1: SIMULATE API CALL (CLIENT SIDE) ---
def localHHMMToUTCHHMM(hhmm, offset):
[h, m] = map(int, hhmm.split(":"))
total_mins_utc = (h * 60 + m) + offset
norm = ((total_mins_utc % 1440) + 1440) % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
utc_ref = localHHMMToUTCHHMM(local_ref_str, offset_in_minutes)
print(f"4. Simulated API call with wakeup_ref (converted to UTC): {utc_ref}")
# --- STEP 2: SIMULATE BACKEND (SERVER SIDE) ---
# Backend receives utc_ref and calculates delta: wakeup_utc - utc_ref
utc_ref_mins = (int(utc_ref.split(":")[0]) * 60 + int(utc_ref.split(":")[1]))
backend_delta = wakeup_utc_mins - utc_ref_mins
print(f"5. Backend returns delta: {backend_delta}")
# --- STEP 3: SIMULATE COMPONENT RENDERING (CLIENT SIDE) ---
# Component receives delta and uses it directly as offsetFromRef.
offset_from_ref = backend_delta
def formatMinutesAfter(minutes, ref):
[rh, rm] = map(int, ref.split(":"))
target = (rh * 60 + rm) + minutes
norm = ((target % 1440) + 1440) % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
displayed_time = formatMinutesAfter(offset_from_ref, local_ref_str)
print(f"6. Component renders time: {displayed_time}")
# --- ASSERTION ---
expected_local_time = "05:17"
print(f"\nASSERTION: Expected {expected_local_time}, Actual {displayed_time}")
if displayed_time == expected_local_time:
print("VERIFICATION SUCCESSFUL!")
else:
print("VERIFICATION FAILED!")
exit(1)
if __name__ == "__main__":
verify_real_wakeup_with_mock_api()
-57
View File
@@ -1,57 +0,0 @@
import datetime
def solve():
# Scenario: User in Stockholm (UTC+2)
# Local Wakeup Reference: 04:00
# Actual Wakeup Time (UTC): 03:17
offset_minutes = -120 # Stockholm UTC+2 -> getTimezoneOffset() is -120
wakeup_ref_local = "04:00"
wakeup_ref_mins_local = 4 * 60
# Let's assume the backend calculates: wakeup_minutes = (UTC_wakeup - UTC_ref)
# where it treats the provided ref as if it were in UTC.
# Case 1: BUGGY (Frontend sends Local ref "04:00" to backend)
# Backend treats 04:00 as UTC.
utc_wakeup = 3 * 60 + 17 # 03:17 UTC
wakeup_minutes_from_backend = utc_wakeup - wakeup_ref_mins_local # 197 - 240 = -43
print(f"BUGGY: Backend returns wakeup_minutes (assuming ref 04:00 is UTC): {wakeup_minutes_from_backend}")
# Frontend (current) tries to fix it with adjustForTimezone:
received_wakeup_minutes = wakeup_minutes_from_backend
# offsetFromRef = getLocalOffsetFromReference(received, "04:00")
# localMins = adjustForTimezone(received) = received - offset_minutes
# offsetFromRef = localMins - ref_mins_local
def adjustForTimezone(utc_min):
return utc_min - offset_minutes
def getLocalOffsetFromReference(utc_min, ref_mins):
local_m = adjustForTimezone(utc_min)
return local_m - ref_mins
offset_from_ref = getLocalOffsetFromReference(received_wakeup_minutes, wakeup_ref_mins_local)
print(f"BUGGY: Frontend calculates offsetFromRef: {offset_from_ref}")
# formatMinutesAfter(offset, ref) -> ref + offset
print(f"BUGGY: Displayed time (ref + offset): {wakeup_ref_mins_local + offset_from_ref}")
# Case 2: CORRECT (Frontend sends UTC ref "02:00" to backend)
# Stockholm +2 means 04:00 Local is 02:00 UTC.
utc_wakeup_ref = wakeup_ref_mins_local + offset_minutes # 240 - 120 = 120
wakeup_minutes_from_backend_correct = utc_wakeup - utc_wakeup_ref # 197 - 120 = 77
print(f"\nCORRECT: Backend returns wakeup_minutes (assuming ref 02:00 is UTC): {wakeup_minutes_from_backend_correct}")
# Frontend receives 77.
# If it's a delta, the component should use it directly as offsetFromRef.
# Or if we want to keep existing logic, we have to realize 77 is ALREADY the delta.
# But the component thinks it's "minutes from midnight".
# This is a mismatch.
print("---")
print(f"Target Local Wakeup: 05:17 ({3 * 60 + 17 + abs(offset_minutes)})")
print(f"Target Local Ref: 04:00 ({wakeup_ref_mins_local})")
print(f"Target Local Delta: {317 - 240} = 77")
solve()
-31
View File
@@ -1,31 +0,0 @@
# Plan: Fix Timezone Handling in Wakeup Times Module
## Problem Description
The "Wakeup Times" chart (in `frontend/src/components/stats/WakeupPlot.tsx`) displays wakeup times incorrectly due to a timezone mismatch.
- **Observation**: If a baby wakes up at `05:55` local time (e.g., in UTC+2), the chart shows `03:55`.
- **Cause**: The backend stores times in UTC. The frontend currently receives minutes relative to UTC midnight and renders them directly using `formatMinutesAfter` without applying the client's local timezone offset.
## Investigation Steps
1. **Backend Verification**:
- Examine `backend/app/routers/stats.py` (or equivalent) and the CRUD logic to confirm that `wakeup_minutes` is calculated based on UTC midnight.
- Verify how the backend handles the `wakeup_ref` and `bedtime_ref` parameters.
2. **Frontend Logic Audit**:
- Trace the data flow from `fetchWakeSleepTimes` -> `useStats` -> `WakeupPlot.tsx`.
- Confirm that `wakeup_minutes` is indeed a raw offset from UTC midnight.
## Implementation Plan
1. **Calculate Local Offset**: Use `new Date().getTimezoneOffset()` in the frontend to determine the difference between UTC and local time.
2. **Adjust Minutes**:
- In `WakeupPlot.tsx`, modify the mapping logic to adjust `day.wakeup_minutes` by the local timezone offset before it is passed to `formatMinutesAfter`.
- Formula: `local_minutes = utc_minutes - (timezone_offset_in_minutes)`.
*(Note: `getTimezoneOffset()` returns minutes positive for west of UTC, negative for east, so subtraction is correct to add offset to UTC).*
3. **Refactor Utilities (if needed)**: If the logic is used elsewhere, consider moving timezone-aware formatting to `frontend/src/utils/time.ts`.
## Verification Plan
1. **Manual Verification**:
- Mock a response from the backend with a known UTC time.
- Check if the rendered string matches the expected local time.
2. **Automated Testing**:
- Add/update a test case in `frontend/tests/e2e/stats.spec.ts` (or a unit test in `frontend/tests/`) that simulates different timezone offsets to ensure the display is correct.
3. **Regression Check**:
- Ensure `BedtimePlot.tsx` and other time-related components are not negatively impacted by changes to shared utilities.