Compare commits
26 Commits
13bc53ea59
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| a86bf2ded4 | |||
| 969b8e033f | |||
| cfc3156906 | |||
| 81eadf618a | |||
| 6847886e7a | |||
| 5d8a8b3c57 | |||
| 32d3e86d38 | |||
| b763aa1e9d | |||
| df210c66a0 | |||
| 560863d914 | |||
| ac637a6dae | |||
| 32af0f4ba3 | |||
| a4852a574a | |||
| ecaa40c523 | |||
| 905f53c74e | |||
| e39f930cf9 | |||
| 852dd38add | |||
| 7ff1c22edc | |||
| 7d4e2a376c | |||
| db0f717560 | |||
| aa4e28e7f6 | |||
| 837c3b84cb | |||
| 96fb3cb988 | |||
| 8eb5946108 | |||
| 7e3bd0a16b | |||
| c371a3d8e8 |
@@ -19,26 +19,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install Git and Docker CLI
|
- name: Install Git and Docker CLI
|
||||||
run: apk add --no-cache git docker-cli docker-cli-buildx
|
run: apk add --no-cache git docker-cli docker-cli-buildx
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
# This uses the automated token provided natively by Gitea Actions
|
# This uses the automated token provided natively by Gitea Actions
|
||||||
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
|
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
- name: Create Custom Buildx Builder for HTTP
|
- name: Create Custom Buildx Builder for HTTP
|
||||||
run: |
|
run: |
|
||||||
# 1. Write a configuration rule forcing plain HTTP for your local domain
|
# 1. Write a configuration rule forcing plain HTTP for your local domain
|
||||||
echo '[registry."gitea.lan"]' > buildkitd.toml
|
echo '[registry."gitea.lan"]' > buildkitd.toml
|
||||||
echo ' http = true' >> buildkitd.toml
|
echo ' http = true' >> buildkitd.toml
|
||||||
|
|
||||||
# 2. Tell buildx to create and use a builder with this rule
|
# 2. Tell buildx to create and use a builder with this rule
|
||||||
docker buildx create --use --name local-http-builder --config buildkitd.toml
|
docker buildx create --use --name local-http-builder --config buildkitd.toml
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Tells the action to use the local 'git' binary we just installed
|
# Tells the action to use the local 'git' binary we just installed
|
||||||
set-safe-directory: 'true'
|
set-safe-directory: 'true'
|
||||||
|
fetch-depth: 0
|
||||||
- name: Determine Version
|
- name: Determine Version
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
|
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
|
||||||
@@ -50,8 +46,7 @@ jobs:
|
|||||||
NEW_PATCH=$((PATCH + 1))
|
NEW_PATCH=$((PATCH + 1))
|
||||||
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
|
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
|
||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
@@ -59,8 +54,8 @@ jobs:
|
|||||||
-t gitea.lan/jacobh2/babymonitor-backend:${{ env.VERSION }} \
|
-t gitea.lan/jacobh2/babymonitor-backend:${{ env.VERSION }} \
|
||||||
./backend \
|
./backend \
|
||||||
--push
|
--push
|
||||||
|
|
||||||
- name: Tag and Push Version
|
- name: Tag and Push Version
|
||||||
run: |
|
run: |
|
||||||
git tag $VERSION
|
git tag "$VERSION"
|
||||||
git push origin $VERSION
|
git push origin "$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -19,26 +19,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install Git and Docker CLI
|
- name: Install Git and Docker CLI
|
||||||
run: apk add --no-cache git docker-cli docker-cli-buildx
|
run: apk add --no-cache git docker-cli docker-cli-buildx
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
# This uses the automated token provided natively by Gitea Actions
|
# This uses the automated token provided natively by Gitea Actions
|
||||||
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
|
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
- name: Create Custom Buildx Builder for HTTP
|
- name: Create Custom Buildx Builder for HTTP
|
||||||
run: |
|
run: |
|
||||||
# 1. Write a configuration rule forcing plain HTTP for your local domain
|
# 1. Write a configuration rule forcing plain HTTP for your local domain
|
||||||
echo '[registry."gitea.lan"]' > buildkitd.toml
|
echo '[registry."gitea.lan"]' > buildkitd.toml
|
||||||
echo ' http = true' >> buildkitd.toml
|
echo ' http = true' >> buildkitd.toml
|
||||||
|
|
||||||
# 2. Tell buildx to create and use a builder with this rule
|
# 2. Tell buildx to create and use a builder with this rule
|
||||||
docker buildx create --use --name local-http-builder --config buildkitd.toml
|
docker buildx create --use --name local-http-builder --config buildkitd.toml
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Tells the action to use the local 'git' binary we just installed
|
# Tells the action to use the local 'git' binary we just installed
|
||||||
set-safe-directory: 'true'
|
set-safe-directory: 'true'
|
||||||
|
fetch-depth: 0
|
||||||
- name: Determine Version
|
- name: Determine Version
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
|
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
|
||||||
@@ -50,17 +46,18 @@ jobs:
|
|||||||
NEW_PATCH=$((PATCH + 1))
|
NEW_PATCH=$((PATCH + 1))
|
||||||
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
|
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
|
||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/arm64 \
|
--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 }} \
|
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
|
||||||
./frontend \
|
./frontend \
|
||||||
--push
|
--push
|
||||||
|
|
||||||
- name: Tag and Push Version
|
- name: Tag and Push Version
|
||||||
run: |
|
run: |
|
||||||
git tag $VERSION
|
git tag "$VERSION"
|
||||||
git push origin $VERSION
|
git push origin "$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# Created by https://www.toptal.com/developers/gitignore/api/node,python
|
# Created by https://www.toptal.com/developers/gitignore/api/node,python
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,python
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node,python
|
||||||
|
|
||||||
|
# Frontend generated files
|
||||||
|
*.js
|
||||||
|
*.jsx
|
||||||
|
|
||||||
|
.omo/
|
||||||
|
|
||||||
### Node ###
|
### Node ###
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
|
|||||||
|
|
||||||
## STRUCTURE
|
## STRUCTURE
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
./
|
./
|
||||||
├── frontend/ # React 19 SPA
|
├── frontend/ # React 19 SPA
|
||||||
@@ -37,6 +36,9 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
|
|||||||
| UI components | `frontend/src/components/` | tracker/, stats/ subdirs |
|
| UI components | `frontend/src/components/` | tracker/, stats/ subdirs |
|
||||||
| Backend models | `backend/app/models.py` | SQLModel definitions |
|
| Backend models | `backend/app/models.py` | SQLModel definitions |
|
||||||
| API schemas | `backend/app/schemas.py` | Pydantic request/response |
|
| 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
|
## CONVENTIONS
|
||||||
|
|
||||||
@@ -54,6 +56,12 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
|
|||||||
- **Frontend JS Slop**: NEVER create `.js` or `.jsx` files for the frontend. Use only TypeScript (`.ts`, `.tsx`). Write clean, type-safe code.
|
- **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.
|
- **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
|
## COMMANDS
|
||||||
|
|
||||||
### Backend Development
|
### Backend Development
|
||||||
@@ -61,8 +69,13 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
uv sync
|
uv sync
|
||||||
uv run uvicorn app.main:app --reload
|
uv run uvicorn app.main:app --reload # runs on http://localhost:8000
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
|
||||||
|
# Run a specific test file (preferred during debugging)
|
||||||
|
uv run pytest tests/test_stats.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
@@ -70,35 +83,59 @@ uv run pytest
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev # runs on http://localhost:5173
|
||||||
|
|
||||||
# WARNING: 'npm test' may hang if run globally. Use specific file paths or vitest flags.
|
# Unit tests (Vitest)
|
||||||
npm run test
|
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
|
## 🛑 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.
|
**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 `ls -R`:** This command is strictly forbidden in this repository.
|
||||||
* **Primary Discovery:** To map out files or explore the project tree, **ALWAYS use `git ls-files**`. This is your default command and ensures you only interact with human-written source code.
|
* **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:
|
* **Structural Context:** If you explicitly need a visual directory layout to understand architecture, use `tree` appended with the mandatory ignore flags:
|
||||||
```bash
|
```bash
|
||||||
tree -I 'node_modules|.git|dist|build|__pycache__|venv|.venv|env|.pytest_cache|.mypy_cache|.ruff_cache|*.egg-info'
|
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.
|
||||||
|
|
||||||
|
|
||||||
* **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
|
## 🧠 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.
|
**Context:** The main OpenCode agent context window is our most critical resource. Executing complex tasks, debugging cross-file issues, or writing massive features directly in the main thread causes severe context bloat, leading to code hallucinations and dropped instructions.
|
||||||
|
|
||||||
* **Delegate Aggressively:** For any complex implementation, structural change, or task larger than a single file edit, you **MUST spawn a sub-agent**. Act strictly as the architectural orchestrator; let sub-agents handle the iterative coding labor.
|
* **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 sub-agents highly specific, isolated, and atomic objectives (e.g., *"Read `backend/app/routers/auth.py` and summarize endpoints"* or *"Write a unit test for the frontend sleep tracker component"*).
|
* **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:** Sub-agents are **STRICTLY FORBIDDEN** from passing raw terminal dumps, unedited file blocks, or their internal step-by-step logs back to the main thread.
|
* **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:** Sub-agents must return *only* clear, actionable results back to the orchestrator: a concise summary of findings, the exact lines of code that require modification, or direct confirmation of a successful execution.
|
* **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
|
## NOTES
|
||||||
|
|
||||||
|
|||||||
+23
-18
@@ -113,22 +113,27 @@ async def update_session(
|
|||||||
return sleep_session
|
return sleep_session
|
||||||
|
|
||||||
|
|
||||||
async def get_all_time_stats(session: AsyncSession) -> tuple[int, float]:
|
async def get_sleep_counts_by_start_date(
|
||||||
statement = select(func.count(), func.min(SleepSession.start_time))
|
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)
|
result = await session.exec(statement)
|
||||||
total_sessions, first_session_time = result.one()
|
return list(result.all())
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -9,6 +9,5 @@ class SleepSession(SQLModel, table=True):
|
|||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
start_time: datetime = Field(nullable=False)
|
start_time: datetime = Field(nullable=False)
|
||||||
end_time: Optional[datetime] = Field(default=None, nullable=True)
|
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))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import List, Optional
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from app.database import get_session
|
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.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse
|
||||||
from app.utils.time_utils import get_day_night_splits
|
from app.utils.time_utils import get_day_night_splits
|
||||||
|
|
||||||
@@ -16,23 +16,31 @@ async def get_daily_summary(
|
|||||||
to_date: datetime,
|
to_date: datetime,
|
||||||
day_start: str = Query("07:00"),
|
day_start: str = Query("07:00"),
|
||||||
day_end: str = Query("20:00"),
|
day_end: str = Query("20:00"),
|
||||||
|
timezone_offset: int = Query(0),
|
||||||
session: AsyncSession = Depends(get_session),
|
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:
|
try:
|
||||||
day_start_time = time.fromisoformat(day_start)
|
day_start_time = time.fromisoformat(day_start)
|
||||||
day_end_time = time.fromisoformat(day_end)
|
day_end_time = time.fromisoformat(day_end)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {"days": []}
|
return {"days": []}
|
||||||
|
|
||||||
adj_from_date = from_date - timedelta(days=1)
|
utc_from_date = from_date + timedelta(minutes=timezone_offset)
|
||||||
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
|
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 = {}
|
summary_map = {}
|
||||||
|
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
if not s.end_time:
|
if not s.end_time:
|
||||||
continue
|
continue
|
||||||
|
local_start = s.start_time - timedelta(minutes=timezone_offset)
|
||||||
|
local_end = s.end_time - timedelta(minutes=timezone_offset)
|
||||||
segments = get_day_night_splits(
|
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:
|
for seg in segments:
|
||||||
if not (from_date.date() <= seg["start"].date() <= to_date.date()):
|
if not (from_date.date() <= seg["start"].date() <= to_date.date()):
|
||||||
@@ -66,26 +74,37 @@ async def get_timetable(
|
|||||||
to_date: datetime,
|
to_date: datetime,
|
||||||
day_start: str = Query("07:00"),
|
day_start: str = Query("07:00"),
|
||||||
day_end: str = Query("20:00"),
|
day_end: str = Query("20:00"),
|
||||||
|
timezone_offset: int = Query(0),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
adj_from_date = from_date - timedelta(days=1)
|
if to_date.hour == 0 and to_date.minute == 0:
|
||||||
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
|
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 = []
|
slots = []
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
current_date = from_date.date()
|
current_date = from_date.date()
|
||||||
while current_date <= to_date.date():
|
while current_date <= to_date.date():
|
||||||
date_str = current_date.strftime("%Y-%m-%d")
|
date_str = current_date.strftime("%Y-%m-%d")
|
||||||
for i in range(96):
|
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
|
minutes=i * 15
|
||||||
)
|
)
|
||||||
slot_end_time = slot_start_time + timedelta(minutes=15)
|
slot_end_local = slot_start_local + timedelta(minutes=15)
|
||||||
slot_start_str = slot_start_time.strftime("%H:%M")
|
slot_start_str = slot_start_local.strftime("%H:%M")
|
||||||
slot_end_str = slot_end_time.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
|
is_sleeping = False
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
effective_end_time = s.end_time if s.end_time is not None else now
|
effective_end_time_utc = s.end_time if s.end_time is not None else now_utc
|
||||||
if s.start_time <= slot_end_time and effective_end_time >= slot_start_time:
|
if s.start_time <= slot_end_utc and effective_end_time_utc >= slot_start_utc:
|
||||||
is_sleeping = True
|
is_sleeping = True
|
||||||
break
|
break
|
||||||
slots.append(
|
slots.append(
|
||||||
@@ -106,13 +125,25 @@ async def get_summary(
|
|||||||
to_date: datetime,
|
to_date: datetime,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
|
# Period Stats
|
||||||
total_sleeps = len(sessions)
|
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
|
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
|
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 {
|
return {
|
||||||
"total_sleeps": total_sleeps,
|
"total_sleeps": total_sleeps,
|
||||||
@@ -128,17 +159,22 @@ async def get_wake_sleep_times(
|
|||||||
to_date: datetime,
|
to_date: datetime,
|
||||||
wakeup_ref: str = Query("04:00"),
|
wakeup_ref: str = Query("04:00"),
|
||||||
bedtime_ref: str = Query("19:00"),
|
bedtime_ref: str = Query("19:00"),
|
||||||
|
timezone_offset: int = Query(0),
|
||||||
session: AsyncSession = Depends(get_session),
|
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:
|
try:
|
||||||
w_ref_time = time.fromisoformat(wakeup_ref)
|
w_ref_time = time.fromisoformat(wakeup_ref)
|
||||||
b_ref_time = time.fromisoformat(bedtime_ref)
|
b_ref_time = time.fromisoformat(bedtime_ref)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {"days": []}
|
return {"days": []}
|
||||||
|
|
||||||
# Fetch sessions starting from one day before to catch night sleep ending on from_date
|
utc_from_date = from_date + timedelta(minutes=timezone_offset)
|
||||||
adj_from_date = from_date - timedelta(days=1)
|
utc_to_date = to_date + timedelta(minutes=timezone_offset)
|
||||||
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
|
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 = []
|
days_data = []
|
||||||
current_date = from_date.date()
|
current_date = from_date.date()
|
||||||
while current_date <= to_date.date():
|
while current_date <= to_date.date():
|
||||||
@@ -150,20 +186,24 @@ async def get_wake_sleep_times(
|
|||||||
|
|
||||||
night_session = None
|
night_session = None
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
# A session that runs over midnight into current_date
|
# Convert to local for comparison
|
||||||
if s.start_time < midnight_today and s.end_time and s.end_time >= midnight_today:
|
s_start_local = s.start_time - timedelta(minutes=timezone_offset)
|
||||||
night_session = s
|
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
|
break
|
||||||
|
|
||||||
if night_session:
|
if night_session:
|
||||||
# Bedtime offset from b_ref_time on the day it started
|
s, s_start_local, s_end_local = night_session
|
||||||
s_start_date = night_session.start_time.date()
|
# 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)
|
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)
|
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(
|
days_data.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,22 +6,17 @@ from pydantic import BaseModel
|
|||||||
class SleepSessionCreate(BaseModel):
|
class SleepSessionCreate(BaseModel):
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
|
|
||||||
|
|
||||||
class SleepSessionUpdate(BaseModel):
|
class SleepSessionUpdate(BaseModel):
|
||||||
start_time: Optional[datetime] = None
|
start_time: Optional[datetime] = None
|
||||||
end_time: Optional[datetime] = None
|
end_time: Optional[datetime] = None
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SleepSessionRead(BaseModel):
|
class SleepSessionRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
end_time: Optional[datetime]
|
end_time: Optional[datetime]
|
||||||
notes: Optional[str]
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class SessionListResponse(BaseModel):
|
class SessionListResponse(BaseModel):
|
||||||
sessions: List[SleepSessionRead]
|
sessions: List[SleepSessionRead]
|
||||||
total: int
|
total: int
|
||||||
|
|||||||
@@ -1,69 +1,44 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from httpx import AsyncClient, ASGITransport
|
from app.models import SleepSession
|
||||||
from app.main import app
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from app.database import engine
|
from app.database import engine, get_session
|
||||||
from app.models import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.mark.asyncio
|
||||||
async def setup_db():
|
async def test_summary_double_counting_midnight(api_client):
|
||||||
|
# Setup: Fresh database for this test
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(SQLModel.metadata.drop_all)
|
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||||
await conn.run_sync(SQLModel.metadata.create_all)
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
# Use a fresh session for insertion
|
||||||
async def test_list_sessions_date_only_filter(setup_db):
|
async for db_session in get_session():
|
||||||
# Test that passing a date (midnight) also works and includes the whole day
|
# 1. Setup: Insert a sleep session that spans across midnight
|
||||||
target_date = datetime(2023, 10, 1, tzinfo=timezone.utc)
|
start_time = datetime(2024, 1, 1, 23, 0)
|
||||||
other_date = datetime(2023, 10, 2, tzinfo=timezone.utc)
|
end_time = datetime(2024, 1, 2, 7, 0)
|
||||||
|
|
||||||
async with AsyncClient(
|
new_session = SleepSession(start_time=start_time, end_time=end_time)
|
||||||
transport=ASGITransport(app=app), base_url="http://test"
|
db_session.add(new_session)
|
||||||
) as ac:
|
await db_session.commit()
|
||||||
# 1. Create a session that starts LATER in the day on OTHER date (should be excluded)
|
break # Exit after one iteration
|
||||||
# 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)
|
# 2. Call the stats endpoint for a period that includes this session
|
||||||
start_time_target = target_date.replace(hour=20, minute=0)
|
from_date = "2024-01-01T00:00:00"
|
||||||
resp_target = await ac.post("/api/sessions", json={"start_time": start_time_target.isoformat()})
|
to_date = "2024-01-02T23:59:59"
|
||||||
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)
|
response = await api_client.get(f"/api/stats/summary?from_date={from_date}&to_date={to_date}")
|
||||||
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()})
|
|
||||||
|
|
||||||
# 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
|
assert response.status_code == 200
|
||||||
sessions = response.json()["sessions"]
|
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']}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
FROM node:22-alpine AS build
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files first to leverage Docker cache
|
# Copy package files first to leverage Docker cache
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color",
|
"test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color",
|
||||||
"test:all": "vitest run && npx playwright test",
|
"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": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.100.5",
|
"@tanstack/react-query": "^5.100.5",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import TrackerPage from './components/tracker/TrackerPage';
|
|||||||
import StatsPage from './components/stats/StatsPage';
|
import StatsPage from './components/stats/StatsPage';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Playground from './components/Playground';
|
import Playground from './components/Playground';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -17,6 +19,7 @@ function App() {
|
|||||||
<Route path="/playground" element={<Playground />} />
|
<Route path="/playground" element={<Playground />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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> {
|
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 params = new URLSearchParams({ from_date, to_date, ...extraParams });
|
||||||
const toDT = new Date(`${to_date}T23:59:59`).toISOString();
|
|
||||||
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, ...extraParams });
|
|
||||||
const response = await fetch(`${BASE_URL}/api/stats/${endpoint}?${params.toString()}`);
|
const response = await fetch(`${BASE_URL}/api/stats/${endpoint}?${params.toString()}`);
|
||||||
if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`);
|
if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`);
|
||||||
return response.json();
|
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);
|
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> {
|
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 });
|
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> {
|
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 });
|
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, {
|
return fetchStats('wake-sleep-times', from_date, to_date, {
|
||||||
wakeup_ref: localHHMMToUTCHHMM(wakeup_ref),
|
wakeup_ref: localHHMMToUTCHHMM(wakeup_ref),
|
||||||
bedtime_ref: localHHMMToUTCHHMM(bedtime_ref)
|
bedtime_ref: localHHMMToUTCHHMM(bedtime_ref),
|
||||||
|
timezone_offset: timezone_offset.toString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -51,8 +51,13 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
|||||||
const min = Math.min(...validMinutes);
|
const min = Math.min(...validMinutes);
|
||||||
const max = Math.max(...validMinutes);
|
const max = Math.max(...validMinutes);
|
||||||
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
|
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]);
|
}, [wakeupBedtime]);
|
||||||
|
|
||||||
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
||||||
@@ -80,8 +85,8 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
|||||||
domain={['dataMin - 30', 'dataMax + 30']}
|
domain={['dataMin - 30', 'dataMax + 30']}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => [
|
formatter={(value: unknown) => [
|
||||||
formatMinutesAfter(value, bedtime_ref),
|
typeof value === 'number' ? formatMinutesAfter(value, bedtime_ref) : String(value ?? ''),
|
||||||
'Bedtime',
|
'Bedtime',
|
||||||
]}
|
]}
|
||||||
labelFormatter={(label) => label}
|
labelFormatter={(label) => label}
|
||||||
@@ -97,7 +102,7 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
{stats && (
|
{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>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</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>
|
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, bedtime_ref)}</p>
|
||||||
@@ -110,6 +115,10 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
|||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p>
|
<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-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), bedtime_ref)}</p>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_sta
|
|||||||
width={60}
|
width={60}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: any, name: any) => [
|
formatter={(value: number | string | readonly (string | number)[] | undefined, name: string | number | undefined) => [
|
||||||
formatMinutes(Math.round(Number(value ?? 0) * 60)),
|
formatMinutes(Math.round(Number((Array.isArray(value) ? value[0] : value ?? 0) * 60))),
|
||||||
name === 'daySleep' ? 'Day' : 'Night',
|
name === 'daySleep' ? 'Day' : 'Night',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSettings } from '../../hooks/useSettings';
|
|||||||
import { Pagination } from './Pagination';
|
import { Pagination } from './Pagination';
|
||||||
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
||||||
import type { SleepSessionRead } from '../../types';
|
import type { SleepSessionRead } from '../../types';
|
||||||
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes, toUTCFromLocalInput, parseBackendDateTime } from '../../utils/time';
|
||||||
|
|
||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
||||||
@@ -32,9 +32,9 @@ export function SessionList() {
|
|||||||
await updateSession({
|
await updateSession({
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
payload: {
|
payload: {
|
||||||
start_time: new Date(editStart).toISOString(),
|
start_time: toUTCFromLocalInput(editStart),
|
||||||
end_time: editEnd ? new Date(editEnd).toISOString() : undefined,
|
end_time: editEnd ? toUTCFromLocalInput(editEnd) : undefined,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -57,8 +57,8 @@ export function SessionList() {
|
|||||||
|
|
||||||
const startEditing = (s: SleepSessionRead) => {
|
const startEditing = (s: SleepSessionRead) => {
|
||||||
setEditingId(s.id);
|
setEditingId(s.id);
|
||||||
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
|
setEditStart(toLocalDatetimeInputValue(parseBackendDateTime(s.start_time)));
|
||||||
setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : '');
|
setEditEnd(s.end_time ? toLocalDatetimeInputValue(parseBackendDateTime(s.end_time)) : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState, useEffect, Fragment } from 'react';
|
import { useMemo, useState, useEffect, Fragment } from 'react';
|
||||||
import { useStats } from '../../hooks/useStats';
|
import { useStats } from '../../hooks/useStats';
|
||||||
import { timeToMinutes, adjustForTimezone } from '../../utils/time';
|
import { timeToMinutes } from '../../utils/time';
|
||||||
|
|
||||||
interface TimetableProps {
|
interface TimetableProps {
|
||||||
from_date: string;
|
from_date: string;
|
||||||
@@ -27,7 +27,8 @@ function formatTimeLabel(mins: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) => {
|
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 [selectedDayIndex, setSelectedDayIndex] = useState(0);
|
||||||
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
|
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(() => {
|
useEffect(() => {
|
||||||
if (days.length > 0) {
|
if (days.length > 0) {
|
||||||
setSelectedDayIndex(days.length - 1);
|
const newIndex = days.length - 1;
|
||||||
|
setSelectedDayIndex(prev => (prev !== newIndex ? newIndex : prev));
|
||||||
}
|
}
|
||||||
}, [days]);
|
}, [days]);
|
||||||
|
|
||||||
@@ -106,8 +108,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
|
|||||||
return timetable.slots.some((slot) => {
|
return timetable.slots.some((slot) => {
|
||||||
if (slot.date !== date || !slot.sleeping) return false;
|
if (slot.date !== date || !slot.sleeping) return false;
|
||||||
|
|
||||||
const sStart = adjustForTimezone(timeToMinutes(slot.slot_start));
|
const sStart = timeToMinutes(slot.slot_start);
|
||||||
const sEnd = adjustForTimezone(timeToMinutes(slot.slot_end));
|
const sEnd = timeToMinutes(slot.slot_end);
|
||||||
|
|
||||||
if (sEnd <= sStart) {
|
if (sEnd <= sStart) {
|
||||||
const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
|
const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
|
||||||
@@ -203,6 +205,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${day}-${step}`}
|
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'}`}
|
className={`h-2 w-full border border-gray-100 ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
|||||||
const max = Math.max(...validMinutes);
|
const max = Math.max(...validMinutes);
|
||||||
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
|
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
|
||||||
|
|
||||||
return { min, max, avg };
|
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]);
|
}, [wakeupBedtime]);
|
||||||
|
|
||||||
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
||||||
@@ -82,8 +88,8 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
|||||||
domain={['dataMin - 30', 'dataMax + 30']}
|
domain={['dataMin - 30', 'dataMax + 30']}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => [
|
formatter={(value: unknown) => [
|
||||||
formatMinutesAfter(value, wakeup_ref),
|
typeof value === 'number' ? formatMinutesAfter(value, wakeup_ref) : String(value ?? ''),
|
||||||
'Wakeup',
|
'Wakeup',
|
||||||
]}
|
]}
|
||||||
labelFormatter={(label) => label}
|
labelFormatter={(label) => label}
|
||||||
@@ -99,7 +105,7 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
{stats && (
|
{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>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</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, wakeup_ref)}</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-[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>
|
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), wakeup_ref)}</p>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
|
|||||||
const isSleeping = !!activeSession;
|
const isSleeping = !!activeSession;
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
|
if (!time) {
|
||||||
|
alert('Please select a valid time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (isSleeping && activeSession?.id) {
|
if (isSleeping && activeSession?.id) {
|
||||||
await endSession({ sessionId: activeSession.id, endTime: time });
|
await endSession({ sessionId: activeSession.id, endTime: time });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import { createContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
const STORAGE_KEY = 'baby-sleep-settings';
|
const STORAGE_KEY = 'baby-sleep-settings';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { SettingsContext, Settings } from './SettingsProvider';
|
import { SettingsContext } from './SettingsProvider';
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const context = useContext(SettingsContext);
|
const context = useContext(SettingsContext);
|
||||||
|
|||||||
@@ -15,21 +15,22 @@ export function useStats(
|
|||||||
day_start = '07:00',
|
day_start = '07:00',
|
||||||
day_end = '20:00',
|
day_end = '20:00',
|
||||||
wakeup_ref = '04:00',
|
wakeup_ref = '04:00',
|
||||||
bedtime_ref = '19:00'
|
bedtime_ref = '19:00',
|
||||||
|
timezoneOffset = 0
|
||||||
) {
|
) {
|
||||||
const dailySummaryQuery = useQuery<DailySummaryResponse>({
|
const dailySummaryQuery = useQuery<DailySummaryResponse>({
|
||||||
queryKey: ['dailySummary', 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),
|
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end, timezoneOffset),
|
||||||
});
|
});
|
||||||
|
|
||||||
const timetableQuery = useQuery<TimetableResponse>({
|
const timetableQuery = useQuery<TimetableResponse>({
|
||||||
queryKey: ['timetable', 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),
|
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end, timezoneOffset),
|
||||||
});
|
});
|
||||||
|
|
||||||
const wakeupBedtimeQuery = useQuery<WakeupBedtimeResponse>({
|
const wakeupBedtimeQuery = useQuery<WakeupBedtimeResponse>({
|
||||||
queryKey: ['wakeSleepTimes', 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),
|
queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref, timezoneOffset),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
@@ -38,9 +38,15 @@ export function adjustForTimezone(utcMinutes: number): number {
|
|||||||
* @returns string in "YYYY-MM-DDTHH:MM" format
|
* @returns string in "YYYY-MM-DDTHH:MM" format
|
||||||
*/
|
*/
|
||||||
export function toLocalDatetimeInputValue(date: Date): string {
|
export function toLocalDatetimeInputValue(date: Date): string {
|
||||||
const offset = date.getTimezoneOffset() * 60 * 1000;
|
const formatter = new Intl.DateTimeFormat('sv-SE', {
|
||||||
const localDate = new Date(date.getTime() - offset);
|
year: 'numeric',
|
||||||
return localDate.toISOString().slice(0, 16);
|
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 {
|
export function formatElapsedTime(utcTimestamp: string): string {
|
||||||
@@ -90,6 +96,9 @@ export function formatDateTime(date: Date | string): string {
|
|||||||
*/
|
*/
|
||||||
export function toUTCFromLocalInput(inputValue: string): string {
|
export function toUTCFromLocalInput(inputValue: string): string {
|
||||||
const d = new Date(inputValue);
|
const d = new Date(inputValue);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,3 +198,15 @@ export function formatMinutes(totalMinutes: number): string {
|
|||||||
}
|
}
|
||||||
return `${mins}m`;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __APP_GITSHA__: string;
|
||||||
@@ -70,4 +70,20 @@ describe('SleepButton', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(endSessionMock).toHaveBeenCalled());
|
await waitFor(() => expect(endSessionMock).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows alert when time is invalid', async () => {
|
||||||
|
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||||
|
(useSleepSession as any).mockReturnValue({
|
||||||
|
activeSession: null,
|
||||||
|
isCreating: false,
|
||||||
|
isEnding: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<SleepButton time="" />);
|
||||||
|
const button = screen.getByRole('button', { name: /Start Sleep/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith('Please select a valid time.');
|
||||||
|
alertSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,4 +29,18 @@ describe('TimeAdjuster', () => {
|
|||||||
fireEvent.click(resetButton);
|
fireEvent.click(resetButton);
|
||||||
expect(handleChange).toHaveBeenCalled();
|
expect(handleChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles invalid date input gracefully (returns empty string)', async () => {
|
||||||
|
// Since we changed the implementation, it should no longer throw.
|
||||||
|
// We can't easily test the useEffect error anymore because it doesn't throw,
|
||||||
|
// but we can check if onTimeChange is called with "" for invalid input.
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { container } = render(<TimeAdjuster onTimeChange={handleChange} />);
|
||||||
|
const input = container.querySelector('input');
|
||||||
|
|
||||||
|
fireEvent.change(input!, { target: { value: '2026-06-0' } });
|
||||||
|
// We might need to wait for the effect
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(handleChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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 '@testing-library/jest-dom';
|
||||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
@@ -35,78 +35,46 @@ describe('SleepTimetable', () => {
|
|||||||
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
|
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders in desktop mode (chronological order)', () => {
|
it('shows sleeping status for intervals spanning midnight across days', () => {
|
||||||
(useStats as any).mockReturnValue({
|
const day1 = '2025-04-18';
|
||||||
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
|
const day2 = '2025-04-19';
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
(useStats as any).mockReturnValue({
|
||||||
timetable: {
|
timetable: {
|
||||||
slots: [
|
slots: [
|
||||||
{
|
{
|
||||||
date: '2025-04-18',
|
date: day1,
|
||||||
sleeping: true,
|
sleeping: true,
|
||||||
slot_start: '01:00',
|
slot_start: '23:00',
|
||||||
slot_end: '03:00', // Covers 01:00 and 02:00
|
slot_end: '23:45',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: day2,
|
||||||
|
sleeping: true,
|
||||||
|
slot_start: '00:00',
|
||||||
|
slot_end: '01:00',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
isLoading: false,
|
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.
|
vi.stubGlobal('innerWidth', 1024); // Desktop mode
|
||||||
// Since they don't have text, we look at their classes/backgrounds.
|
const { container } = render(<Timetable {...testProps} />);
|
||||||
|
|
||||||
const greenCells = container.querySelectorAll('.bg-green-500');
|
// Check Day 1: 23:00
|
||||||
expect(greenCells.length).toBeGreaterThan(0);
|
const cellD1_2300 = container.querySelector(`[data-testid="cell-${day1}-23:00"]`);
|
||||||
|
expect(cellD1_2300).toHaveClass('bg-green-500');
|
||||||
|
|
||||||
const grayCells = container.querySelectorAll('.bg-gray-50');
|
// Check Day 2: 00:15
|
||||||
expect(grayCells.length).toBeGreaterThan(0);
|
const cellD2_0015 = container.querySelector(`[data-testid="cell-${day2}-00:15"]`);
|
||||||
|
expect(cellD2_0015).toHaveClass('bg-green-500');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
adjustForTimezone,
|
adjustForTimezone,
|
||||||
getLocalOffsetFromReference,
|
getLocalOffsetFromReference,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
|
toUTCFromLocalInput,
|
||||||
|
parseBackendDateTime,
|
||||||
} from '../../src/utils/time';
|
} from '../../src/utils/time';
|
||||||
|
|
||||||
describe('formatMinutesAfter', () => {
|
describe('formatMinutesAfter', () => {
|
||||||
@@ -152,3 +154,43 @@ describe('getLocalOffsetFromReference', () => {
|
|||||||
expect(offset).toBe(localMins - refMins);
|
expect(offset).toBe(localMins - refMins);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toUTCFromLocalInput', () => {
|
||||||
|
it('converts valid datetime-local string to ISO UTC string', () => {
|
||||||
|
const input = '2024-01-15T10:00';
|
||||||
|
// This is tricky because it depends on local timezone.
|
||||||
|
// But we just want to see if it works for a valid input.
|
||||||
|
const result = toUTCFromLocalInput(input);
|
||||||
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string on invalid datetime-local string', () => {
|
||||||
|
expect(toUTCFromLocalInput('2026-06-0')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,29 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
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({
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||||
|
__APP_GITSHA__: JSON.stringify(APP_GITSHA),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
@@ -1,351 +1,38 @@
|
|||||||
# Proposed Implementation Plan
|
# Implementation Plan: Fix Sleep Timetable Timezone Issue
|
||||||
|
|
||||||
## Phase 1: Settings Update
|
## Overview
|
||||||
* **`frontend/src/hooks/useSettings.ts`**: Add `sessionsPerPage` to the settings state and define a default value (e.g., 10).
|
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.
|
||||||
* **`frontend/src/components/stats/SettingsPopover.tsx`**:
|
|
||||||
* Update the props and local state to include `sessionsPerPage`.
|
|
||||||
* Add a dropdown in the settings UI for selecting "Results per page" (e.g., 5, 10, 25, 50).
|
|
||||||
* **`frontend/src/components/stats/StatsPage.tsx`**: Update the `onSave` handler to persist the new `sessionsPerPage` setting.
|
|
||||||
|
|
||||||
## Phase 2: Pagination UI and Logic
|
## Detailed Steps
|
||||||
* **`frontend/src/components/stats/Pagination.tsx` (New Component)**: Implement a "Google-style" pagination component with `Previous`, page numbers, and `Next` buttons.
|
|
||||||
* **`frontend/src/components/stats/SessionList.tsx`**:
|
|
||||||
* Integrate `useSettings` to fetch the selected page size.
|
|
||||||
* Implement local pagination logic (managing `currentPage`).
|
|
||||||
* Slice the session data to show only the current page's items.
|
|
||||||
* Render the new `Pagination` component at the bottom of the list.
|
|
||||||
|
|
||||||
## Phase 3: Verification
|
### 1. Reproduction (Frontend Test)
|
||||||
* Verify that changing "Results per page" in Settings correctly updates the list.
|
- Create a new test file or add to `frontend/tests/unit/components/stats/SleepTimetable.test.tsx`.
|
||||||
* Confirm pagination navigation (Prev/Next/Numbers) works as expected and handles edge cases (e.g., first/last page).
|
- 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.
|
||||||
|
|
||||||
I will use multiple sub-agents to execute these tasks in parallel.
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The user wants to implement client-side pagination for "Sleep Sessions".
|
**Note to Developer:**
|
||||||
Plan:
|
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.
|
||||||
1. Update `useSettings` to include `sessionsPerPage`.
|
|
||||||
2. Update `SettingsPopover` to allow users to change `sessionsPerPage`.
|
|
||||||
3. Update `StatsPage`'s `onSave` in `SettingsPopover` to include the new setting.
|
|
||||||
4. Create a new `Pagination` component.
|
|
||||||
5. Update `SessionList` to use pagination logic and the new component.
|
|
||||||
6. Verify with Playwright and screenshots.
|
|
||||||
|
|
||||||
I'll start by using `todowrite` to track progress.
|
|
||||||
Then I will launch sub-agents.
|
|
||||||
|
|
||||||
Sub-agents:
|
|
||||||
1. `explore` (or `general`) for Phase 1 (Settings).
|
|
||||||
2. `general` for Phase 2 (Pagination component and SessionList logic).
|
|
||||||
3. `general` for Phase 3 (Playwright verification).
|
|
||||||
|
|
||||||
Wait, the user said "use as many sub-agents as possible". I'll split it.
|
|
||||||
|
|
||||||
Task 1: Update settings (useSettings, SettingsPopover, StatsPage).
|
|
||||||
Task 2: Implement Pagination component and integrate it into SessionList.
|
|
||||||
Task 3: Write Playwright tests to verify functionality and take screenshots.
|
|
||||||
|
|
||||||
Let's start with `todowrite`.
|
|
||||||
|
|
||||||
## TODOS
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"todos": [
|
|
||||||
{
|
|
||||||
"content": "Update useSettings to include sessionsPerPage",
|
|
||||||
"priority": "high",
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Update SettingsPopover to allow changing sessionsPerPage",
|
|
||||||
"priority": "high",
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Update StatsPage to persist sessionsPerPage in onSave",
|
|
||||||
"priority": "high",
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Create Pagination component in frontend/src/components/stats/Pagination.tsx",
|
|
||||||
"priority": "high",
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Update SessionList to implement client-side pagination logic and use Pagination component",
|
|
||||||
"priority": "high",
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Write Playwright tests to verify pagination and take screenshots",
|
|
||||||
"priority": "medium",
|
|
||||||
"status": "pending"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Current progress (git diff)
|
|
||||||
|
|
||||||
```
|
|
||||||
diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx
|
|
||||||
index 374e41c..2bd6ea7 100644
|
|
||||||
--- a/frontend/src/components/stats/SessionList.tsx
|
|
||||||
+++ b/frontend/src/components/stats/SessionList.tsx
|
|
||||||
@@ -1,15 +1,29 @@
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useSessions } from '../../hooks/useSleepSession';
|
|
||||||
+import { useSettings } from '../../hooks/useSettings';
|
|
||||||
+import { Pagination } from './Pagination';
|
|
||||||
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
|
||||||
import type { SleepSessionRead } from '../../types';
|
|
||||||
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
|
||||||
|
|
||||||
export function SessionList() {
|
|
||||||
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
|
||||||
+ const { settings } = useSettings();
|
|
||||||
+ const sessionsPerPage = settings.sessionsPerPage;
|
|
||||||
+
|
|
||||||
+ const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
|
||||||
const [editStart, setEditStart] = useState<string>('');
|
|
||||||
const [editEnd, setEditEnd] = useState<string>('');
|
|
||||||
|
|
||||||
+ const totalPages = Math.ceil(sessions.length / sessionsPerPage);
|
|
||||||
+ const clampedPage = Math.max(1, Math.min(currentPage, totalPages || 1));
|
|
||||||
+
|
|
||||||
+ const paginatedSessions = sessions.slice(
|
|
||||||
+ (clampedPage - 1) * sessionsPerPage,
|
|
||||||
+ clampedPage * sessionsPerPage
|
|
||||||
+ );
|
|
||||||
+
|
|
||||||
if (isLoading) return <div className="text-center p-4">Loading sessions...</div>;
|
|
||||||
if (!sessions.length) return <div className="text-center p-4 text-gray-500">No sessions recorded.</div>;
|
|
||||||
|
|
||||||
@@ -55,7 +69,7 @@ export function SessionList() {
|
|
||||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
- {sessions.map((s) => (
|
|
||||||
+ {paginatedSessions.map((s) => (
|
|
||||||
<div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
|
|
||||||
{editingId === s.id ? (
|
|
||||||
<div className="flex flex-col space-y-3 py-1">
|
|
||||||
@@ -140,6 +154,13 @@ export function SessionList() {
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
+ {sessions.length > sessionsPerPage && (
|
|
||||||
+ <Pagination
|
|
||||||
+ currentPage={clampedPage}
|
|
||||||
+ totalPages={totalPages}
|
|
||||||
+ onPageChange={setCurrentPage}
|
|
||||||
+ />
|
|
||||||
+ )}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
diff --git a/frontend/src/components/stats/SettingsPopover.tsx b/frontend/src/components/stats/SettingsPopover.tsx
|
|
||||||
index 005a302..8031392 100644
|
|
||||||
--- a/frontend/src/components/stats/SettingsPopover.tsx
|
|
||||||
+++ b/frontend/src/components/stats/SettingsPopover.tsx
|
|
||||||
@@ -6,15 +6,17 @@ interface SettingsPopoverProps {
|
|
||||||
dayEnd: string;
|
|
||||||
wakeupRef: string;
|
|
||||||
bedtimeRef: string;
|
|
||||||
- onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string }) => void;
|
|
||||||
+ sessionsPerPage: number;
|
|
||||||
+ onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string; sessionsPerPage: number }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
-export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }: SettingsPopoverProps) {
|
|
||||||
+export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage, onSave }: SettingsPopoverProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [localDayStart, setLocalDayStart] = useState(dayStart);
|
|
||||||
const [localDayEnd, setLocalDayEnd] = useState(dayEnd);
|
|
||||||
const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef);
|
|
||||||
const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef);
|
|
||||||
+ const [localSessionsPerPage, setLocalSessionsPerPage] = useState(sessionsPerPage);
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
@@ -22,7 +24,8 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
|
||||||
setLocalDayEnd(dayEnd);
|
|
||||||
setLocalWakeupRef(wakeupRef);
|
|
||||||
setLocalBedtimeRef(bedtimeRef);
|
|
||||||
- }, [dayStart, dayEnd, wakeupRef, bedtimeRef]);
|
|
||||||
+ setLocalSessionsPerPage(sessionsPerPage);
|
|
||||||
+ }, [dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
@@ -42,6 +45,7 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
|
||||||
dayEnd: localDayEnd,
|
|
||||||
wakeupRef: localWakeupRef,
|
|
||||||
bedtimeRef: localBedtimeRef,
|
|
||||||
+ sessionsPerPage: localSessionsPerPage,
|
|
||||||
});
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
@@ -65,44 +69,58 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
-<label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
|
||||||
-<input
|
|
||||||
- id="day-start"
|
|
||||||
- type="time"
|
|
||||||
- value={localDayStart}
|
|
||||||
- onChange={(e) => setLocalDayStart(e.target.value)}
|
|
||||||
- className="w-full p-2 border rounded-md text-sm"
|
|
||||||
-/>
|
|
||||||
+ <label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
|
||||||
+ <input
|
|
||||||
+ id="day-start"
|
|
||||||
+ type="time"
|
|
||||||
+ value={localDayStart}
|
|
||||||
+ onChange={(e) => setLocalDayStart(e.target.value)}
|
|
||||||
+ className="w-full p-2 border rounded-md text-sm"
|
|
||||||
+ />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
-<label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
|
||||||
-<input
|
|
||||||
- id="day-end"
|
|
||||||
- type="time"
|
|
||||||
- value={localDayEnd}
|
|
||||||
- onChange={(e) => setLocalDayEnd(e.target.value)}
|
|
||||||
- className="w-full p-2 border rounded-md text-sm"
|
|
||||||
-/>
|
|
||||||
+ <label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
|
||||||
+ <input
|
|
||||||
+ id="day-end"
|
|
||||||
+ type="time"
|
|
||||||
+ value={localDayEnd}
|
|
||||||
+ onChange={(e) => setLocalDayEnd(e.target.value)}
|
|
||||||
+ className="w-full p-2 border rounded-md text-sm"
|
|
||||||
+ />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
-<label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
|
||||||
-<input
|
|
||||||
- id="wakeup-ref"
|
|
||||||
- type="time"
|
|
||||||
- value={localWakeupRef}
|
|
||||||
- onChange={(e) => setLocalWakeupRef(e.target.value)}
|
|
||||||
- className="w-full p-2 border rounded-md text-sm"
|
|
||||||
-/>
|
|
||||||
+ <label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
|
||||||
+ <input
|
|
||||||
+ id="wakeup-ref"
|
|
||||||
+ type="time"
|
|
||||||
+ value={localWakeupRef}
|
|
||||||
+ onChange={(e) => setLocalWakeupRef(e.target.value)}
|
|
||||||
+ className="w-full p-2 border rounded-md text-sm"
|
|
||||||
+ />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
-<label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
|
||||||
-<input
|
|
||||||
- id="bedtime-ref"
|
|
||||||
- type="time"
|
|
||||||
- value={localBedtimeRef}
|
|
||||||
- onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
|
||||||
- className="w-full p-2 border rounded-md text-sm"
|
|
||||||
-/>
|
|
||||||
+ <label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
|
||||||
+ <input
|
|
||||||
+ id="bedtime-ref"
|
|
||||||
+ type="time"
|
|
||||||
+ value={localBedtimeRef}
|
|
||||||
+ onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
|
||||||
+ className="w-full p-2 border rounded-md text-sm"
|
|
||||||
+ />
|
|
||||||
+ </div>
|
|
||||||
+ <div>
|
|
||||||
+ <label htmlFor="sessions-per-page" className="block text-xs text-gray-500 mb-1">Results per page</label>
|
|
||||||
+ <select
|
|
||||||
+ id="sessions-per-page"
|
|
||||||
+ value={localSessionsPerPage}
|
|
||||||
+ onChange={(e) => setLocalSessionsPerPage(Number(e.target.value))}
|
|
||||||
+ className="w-full p-2 border rounded-md text-sm"
|
|
||||||
+ >
|
|
||||||
+ <option value={5}>5</option>
|
|
||||||
+ <option value={10}>10</option>
|
|
||||||
+ <option value={25}>25</option>
|
|
||||||
+ <option value={50}>50</option>
|
|
||||||
+ </select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx
|
|
||||||
index 2516d55..e3518c8 100644
|
|
||||||
--- a/frontend/src/components/stats/StatsPage.tsx
|
|
||||||
+++ b/frontend/src/components/stats/StatsPage.tsx
|
|
||||||
@@ -27,18 +27,20 @@ const StatsPage: React.FC = () => {
|
|
||||||
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Statistics</h2>
|
|
||||||
<p className="text-slate-500 text-sm mt-1">Overview of sleep patterns and trends</p>
|
|
||||||
</div>
|
|
||||||
- <SettingsPopover
|
|
||||||
- dayStart={settings.dayStart}
|
|
||||||
- dayEnd={settings.dayEnd}
|
|
||||||
- wakeupRef={settings.wakeupRef}
|
|
||||||
- bedtimeRef={settings.bedtimeRef}
|
|
||||||
- onSave={(s) => {
|
|
||||||
- update('dayStart', s.dayStart);
|
|
||||||
- update('dayEnd', s.dayEnd);
|
|
||||||
- update('wakeupRef', s.wakeupRef);
|
|
||||||
- update('bedtimeRef', s.bedtimeRef);
|
|
||||||
- }}
|
|
||||||
- />
|
|
||||||
+ <SettingsPopover
|
|
||||||
+ dayStart={settings.dayStart}
|
|
||||||
+ dayEnd={settings.dayEnd}
|
|
||||||
+ wakeupRef={settings.wakeupRef}
|
|
||||||
+ bedtimeRef={settings.bedtimeRef}
|
|
||||||
+ sessionsPerPage={settings.sessionsPerPage}
|
|
||||||
+ onSave={(s) => {
|
|
||||||
+ update('dayStart', s.dayStart);
|
|
||||||
+ update('dayEnd', s.dayEnd);
|
|
||||||
+ update('wakeupRef', s.wakeupRef);
|
|
||||||
+ update('bedtimeRef', s.bedtimeRef);
|
|
||||||
+ update('sessionsPerPage', s.sessionsPerPage);
|
|
||||||
+ }}
|
|
||||||
+ />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
|
|
||||||
diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts
|
|
||||||
index c587fb3..d95899b 100644
|
|
||||||
--- a/frontend/src/hooks/useSettings.ts
|
|
||||||
+++ b/frontend/src/hooks/useSettings.ts
|
|
||||||
@@ -8,6 +8,7 @@ interface Settings {
|
|
||||||
wakeupRef: string;
|
|
||||||
bedtimeRef: string;
|
|
||||||
statsInterval: number;
|
|
||||||
+ sessionsPerPage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = {
|
|
||||||
@@ -16,6 +17,7 @@ const DEFAULT_SETTINGS: Settings = {
|
|
||||||
wakeupRef: '04:00',
|
|
||||||
bedtimeRef: '19:00',
|
|
||||||
statsInterval: 7,
|
|
||||||
+ sessionsPerPage: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSettings() {
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Last command
|
|
||||||
|
|
||||||
The last command you ran was this:
|
|
||||||
|
|
||||||
```
|
|
||||||
npx playwright test frontend/tests/e2e/pagination.spec.ts --reporter=list
|
|
||||||
```
|
|
||||||
|
|
||||||
so please run this again and see the output, as this might help us know where to pick things up.
|
|
||||||
|
|||||||
@@ -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:~/
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"failedTests": []
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -3,4 +3,33 @@
|
|||||||
- [ ] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing
|
- [ ] 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%
|
- [ ] 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
|
- [ ] 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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user