16 Commits

Author SHA1 Message Date
jacobh2 81eadf618a Update gitignore
Build Frontend / build (push) Successful in 9m6s
2026-06-04 11:33:42 +02:00
jacobh2 6847886e7a chore: add vite environment type definitions 2026-06-04 11:33:21 +02:00
jacobh2 5d8a8b3c57 chore: update deployment and app structure 2026-06-04 11:31:44 +02:00
jacobh2 32d3e86d38 fix(frontend): resolve linting errors and component duplication 2026-06-04 11:30:18 +02:00
jacobh2 b763aa1e9d Small cleanup
Build Backend / build (push) Successful in 1m54s
2026-06-04 09:18:22 +02:00
jacobh2 df210c66a0 Update todo 2026-06-04 09:00:11 +02:00
jacobh2 560863d914 Remove linting jobs from Gitea workflows 2026-06-04 08:58:42 +02:00
jacobh2 ac637a6dae Update todo 2026-06-03 22:55:29 +02:00
jacobh2 32af0f4ba3 ci: update checkout version, fix shell quoting, and add actionlint job 2026-06-03 22:50:45 +02:00
jacobh2 a4852a574a docs: fix OpenCode subagent terminology in AGENTS.md
Replace "sub-agent" (hyphenated) with "subagent" and update "spawn a
sub-agent" to "invoke a subagent via the Task tool" to match OpenCode's
actual documented terminology.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:45:41 +02:00
jacobh2 ecaa40c523 Update todo 2026-06-03 22:38:02 +02:00
jacobh2 905f53c74e ci: fix tag versioning by fetching all tags in workflows 2026-06-03 22:37:46 +02:00
jacobh2 e39f930cf9 Update agents 2026-06-03 22:35:38 +02:00
jacobh2 852dd38add fix(stats): make API endpoints timezone-aware to fix sleep session attribution 2026-06-03 22:29:24 +02:00
jacobh2 7ff1c22edc Update todo 2026-06-03 22:22:16 +02:00
jacobh2 7d4e2a376c timetable maybe working, very cloase at least 2026-06-03 22:14:18 +02:00
29 changed files with 276 additions and 409 deletions
+6 -11
View File
@@ -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"
+8 -11
View File
@@ -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"
+2
View File
@@ -5,6 +5,8 @@
*.js *.js
*.jsx *.jsx
.omo/
### Node ### ### Node ###
# Logs # Logs
logs logs
+43 -12
View File
@@ -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
@@ -61,8 +63,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 +77,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
+51 -23
View File
@@ -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(
@@ -140,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():
@@ -162,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(
{ {
-14
View File
@@ -1,14 +0,0 @@
INFO: Will watch for changes in these directories: ['/Users/jhag/projects/babymonitor/backend']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [63655] using StatReload
INFO: Started server process [63657]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: StatReload detected changes in 'app/main.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [63657]
INFO: Started server process [71064]
INFO: Waiting for application startup.
INFO: Application startup complete.
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
docker buildx build \
--platform linux/arm64 \
-t babymonitor-frontend:latest \
-f frontend/Dockerfile \
--load \
frontend
docker buildx build \
--platform linux/arm64 \
-t babymonitor-backend:latest \
-f backend/Dockerfile \
--load \
backend
+4
View File
@@ -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
+3
View File
@@ -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>
); );
+8 -9
View File
@@ -32,9 +32,7 @@ export function localToUTC(localValue: string): string {
} }
async function fetchStats<T>(endpoint: string, from_date: string, to_date: string, extraParams: Record<string, string> = {}): Promise<T> { 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()
}); });
} }
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
const Footer: React.FC = () => {
return (
<footer className="py-4 text-center">
<p className="text-xs text-slate-500">
v{__APP_VERSION__} ({__APP_GITSHA__})
</p>
</footer>
);
};
export default Footer;
@@ -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',
]} ]}
/> />
@@ -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);
@@ -157,7 +159,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
</button> </button>
); );
})} })}
</div > </div>
)} )}
{/* Grid Container */} {/* Grid Container */}
@@ -175,27 +177,27 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
{visibleDays.map((day) => ( {visibleDays.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2"> <div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
{getDayName(day)} {getDayName(day)}
</div > </div>
))} ))}
</div > </div>
{/* Time Grid Container */} {/* Time Grid Container */}
<div className="relative"> <div className="relative">
<div <div
className="grid" className="grid"
style={{ style={{
gridTemplateColumns: tier === 'desktop' gridTemplateColumns: tier === 'desktop'
? `4rem repeat(${days.length}, minmax(40px, 1fr))` ? `4rem repeat(${days.length}, minmax(40px, 1fr))`
: `4rem repeat(${visibleDays.length}, 1fr)` : `4rem repeat(${visibleDays.length}, 1fr)`
}} }}
> >
{/* Time Rows */} {/* Time Rows */}
{timeSteps.map((step) => ( {timeSteps.map((step) => (
<Fragment key={step}> <Fragment key={step}>
{/* Time Label */} {/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2"> <div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
{step % 60 === 0 ? formatTimeLabel(step) : ''} {step % 60 === 0 ? formatTimeLabel(step) : ''}
</div > </div>
{/* Grid Cells */} {/* Grid Cells */}
{visibleDays.map((day) => { {visibleDays.map((day) => {
@@ -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'}`}
/> />
); );
@@ -218,10 +221,10 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
transform: 'translateY(-50%)' transform: 'translateY(-50%)'
}} }}
/> />
</div > </div>
</div > </div>
</div > </div>
</div > </div>
); );
}; };
+1 -1
View File
@@ -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 -1
View File
@@ -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);
+8 -7
View File
@@ -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 {
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_GITSHA__: string;
@@ -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');
}); });
}); });
+7
View File
@@ -3,9 +3,16 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
const APP_VERSION = process.env.VITE_APP_VERSION || 'unknown';
const APP_GITSHA = process.env.VITE_APP_GITSHA || 'unknown';
export default defineConfig({ export default defineConfig({
base: './', 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'),
+32 -31
View File
@@ -1,37 +1,38 @@
# Plan: Add Median Statistic to Wakeup and Bedtime Plots # Implementation Plan: Fix Sleep Timetable Timezone Issue
## Overview ## Overview
The goal is to add a fourth statistic, "Median", to the summary section below the Wakeup and Bedtime trend graphs in `WakeupPlot.tsx` and `BedtimePlot.tsx`. Currently, these components display "Earliest", "Latest", and "Average". The `SleepTimetable` component incorrectly attributes sleep segments spanning midnight to the wrong day because the backend calculates time-slots in UTC without awareness of the user's local timezone. This plan implements a timezone-aware API to ensure sleep sessions are correctly mapped to the user's local dates.
## Detailed Tasks ## Detailed Steps
### 1. Update `frontend/src/components/stats/WakeupPlot.tsx` ### 1. Reproduction (Frontend Test)
- **Sub-task 1.1: Update Statistics Calculation** - Create a new test file or add to `frontend/tests/unit/components/stats/SleepTimetable.test.tsx`.
- Locate the `stats` `useMemo` block. - Mock `useStats` to return a timetable containing a session that spans midnight (e.g., Wed 23:00 - Thu 06:00).
- Calculate the median of `validMinutes`. - The mock data should reflect what the backend currently returns (which is incorrectly attributed to the wrong day due to timezone mismatch).
- *Note*: To calculate the median, sort the array and pick the middle element (or average of two middle elements if even). - Assert that for a user in a non-UTC timezone, the green slots for 00:00-02:00 are on the correct day (Thu).
- Include `median` in the returned object: `{ min, max, avg, median }`. - This test is expected to fail initially because the current implementation attributes them to Wed.
- **Sub-task 1.2: Update UI Layout**
- Find the statistics container div (currently `grid grid-cols-3`).
- Change the Tailwind class from `grid-cols-3` to `grid-cols-4`.
- **Sub-task 1.3: Add Median Display**
- Add a new `div` for the "Median" statistic.
- Use identical styling to the existing three blocks.
- Display the value using `formatMinutesAfter(Math.round(stats.median), wakeup_ref)`.
### 2. Update `frontend/src/components/stats/BedtimePlot.tsx` ### 2. Backend Implementation (API & Logic)
- **Sub-task 2.1: Update Statistics Calculation** - **Modify API**: Update `backend/app/routers/stats.py`:
- Locate the `stats` `useMemo` block. - `get_timetable` to accept `timezone_offset: int = Query(0)`.
- Calculate the median of `validMinutes`. - `get_daily_summary` and `get_wake_sleep_times` to also accept `timezone_offset`.
- Include `median` in the returned object: `{ min, max, avg, median }`. - **Core Logic Change**: In `get_timetable`:
- **Sub-task 2.2: Update UI Layout** - Use `timezone_offset` to translate the requested `from_date`/`to_date` (local) into a UTC range for database queries.
- Find the statistics container div (currently `grid grid-cols-3`). - When generating slots, calculate the `slot_start_time` and `slot_end_time` in UTC for comparison with session times.
- Change the Tailwind class from `grid-cols-3` to `grid-cols-4`. - When creating the response object, convert those UTC times to Local Time and attach the correct Local Date.
- **Sub-task 2.3: Add Median Display** - This ensures that a session spanning midnight is correctly mapped to its local date segments.
- Add a new `div` for the "Median" statistic.
- Use identical styling to the existing three blocks.
- Display the value using `formatMinutesAfter(Math.round(stats.median), bedtime_ref)`.
## Verification Plan ### 3. Frontend Implementation (Integration)
1. **Visual Inspection**: Ensure the four statistics are evenly spaced and aligned in a single row below each graph. - **Update Hook**: Modify `frontend/src/hooks/useStats.ts` to accept `timezoneOffset` and pass it to the API calls (`fetchTimetable`, etc.).
2. **Logic Check**: Manually verify with a small set of known numbers (e.g., [10, 20, 30] -> median 20; [10, 20, 30, 40] -> median 25) that the calculation is correct. - **Update Client**: Modify `frontend/src/api/client.ts` to include the offset in request parameters.
- **Update Component**: In `frontend/src/components/stats/SleepTimetable.tsx`, ensure the component retrieves the offset via `new Date().getTimezoneOffset()` and passes it to `useStats`.
- **Cleanup**: Remove the manual `adjustForTimezone` calls in `SleepTimetable.tsx` since the backend will now provide correctly adjusted local times.
### 4. Verification
- Run the frontend unit tests.
- Verify the fix against the reported issue using a manual test or e2e test if possible.
---
**Note to Developer:**
To maintain a high-quality context window and avoid hallucinations, you **MUST** delegate complex tasks to sub-agents. Break down each phase above into smaller, atomic objectives for sub-agents (e.g., one agent for backend API changes, another for frontend hook updates). Do not attempt to execute the entire plan in a single thread.
-5
View File
@@ -1,5 +0,0 @@
podman save babymonitor-frontend:latest -o frontend.tar
podman save babymonitor-backend:latest -o backend.tar
scp frontend.tar backend.tar jacobhagstedt@raspberrypi.local:~/
-4
View File
@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}
-33
View File
@@ -1,33 +0,0 @@
const adjustForTimezone = (utcMinutes) => {
const offsetInMinutes = -120; // Simulate UTC+2
return utcMinutes - offsetInMinutes;
};
const formatMinutesAfter = (minutes, reference = "04:00") => {
const [refHours, refMinutes] = reference.split(":").map(Number);
const totalRefMinutes = refHours * 60 + refMinutes;
const targetMinutes = totalRefMinutes + minutes;
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
};
// Case 1: Backend sends offset from 04:00 UTC (-5 minutes)
const backendOffset = -5;
const adjusted = adjustForTimezone(backendOffset);
console.log("Adjusted offset:", adjusted); // Should be 115
console.log("Formatted time:", formatMinutesAfter(adjusted, "04:00")); // Should be 05:55
// Case 2: Backend sends minutes from UTC midnight (235 minutes)
const backendMidnightMins = 235;
const adjusted2 = adjustForTimezone(backendMidnightMins);
console.log("Adjusted midnight mins:", adjusted2); // Should be 355
// If we use it as an offset from reference:
console.log("Formatted time (as offset):", formatMinutesAfter(adjusted2, "04:00")); // Should be 09:55 (WRONG if it was supposed to be 05:55)
// If we want the offset from reference for Case 2:
const refMins = 4 * 60;
const correctOffset = adjusted2 - refMins; // 355 - 240 = 115
console.log("Corrected offset (from midnight):", correctOffset); // 115
console.log("Formatted time (corrected):", formatMinutesAfter(correctOffset, "04:00")); // 05:55
+30 -1
View File
@@ -3,4 +3,33 @@
- [ ] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing - [ ] 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
- [/] 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
- [-] Fix race condition of gitea workflows
If both workflows run at the same time, a race condition occurs:
1. Collision: Both workflows will query the latest tag (e.g., v1.0.5) and calculate the same next version (v1.0.6).
2. The Winner: The first workflow to finish its push will successfully create the v1.0.6 tag on the remote.
3. The Loser: The second workflow will attempt to push v1.0.6, but the remote will reject it because that tag already exists, causing the workflow to fail.
This happens because both workflows share the same global tag namespace (v1.0.x). To fix this, you should differentiate them by using unique prefixes, such as backend-v1.0.x and frontend-v1.0.x.
--> Invalid. the containers are named differently.
- [x] remove the lint form the gitea workflows added by the agent. makes no sense there
-58
View File
@@ -1,58 +0,0 @@
import datetime
def verify_real_wakeup_with_mock_api():
# --- DATA FROM DATABASE (REAL) ---
# session id 1 wakeup: 2026-05-27 03:17:00 UTC
wakeup_utc = datetime.datetime(2026, 5, 27, 3, 17)
wakeup_utc_mins = wakeup_utc.hour * 60 + wakeup_utc.minute
# --- SETUP PARAMETERS (CLIENT SIDE) ---
# User is in Stockholm (+2)
offset_in_minutes = -120
local_ref_str = "04:00"
local_ref_mins = 4 * 60
print(f"1. Database Wakeup (UTC): {wakeup_utc} ({wakeup_utc_mins}m)")
print(f"2. Client Local Reference: {local_ref_str} ({local_ref_mins}m)")
print(f"3. Client Timezone Offset: {offset_in_minutes}m")
# --- STEP 1: SIMULATE API CALL (CLIENT SIDE) ---
def localHHMMToUTCHHMM(hhmm, offset):
[h, m] = map(int, hhmm.split(":"))
total_mins_utc = (h * 60 + m) + offset
norm = ((total_mins_utc % 1440) + 1440) % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
utc_ref = localHHMMToUTCHHMM(local_ref_str, offset_in_minutes)
print(f"4. Simulated API call with wakeup_ref (converted to UTC): {utc_ref}")
# --- STEP 2: SIMULATE BACKEND (SERVER SIDE) ---
# Backend receives utc_ref and calculates delta: wakeup_utc - utc_ref
utc_ref_mins = (int(utc_ref.split(":")[0]) * 60 + int(utc_ref.split(":")[1]))
backend_delta = wakeup_utc_mins - utc_ref_mins
print(f"5. Backend returns delta: {backend_delta}")
# --- STEP 3: SIMULATE COMPONENT RENDERING (CLIENT SIDE) ---
# Component receives delta and uses it directly as offsetFromRef.
offset_from_ref = backend_delta
def formatMinutesAfter(minutes, ref):
[rh, rm] = map(int, ref.split(":"))
target = (rh * 60 + rm) + minutes
norm = ((target % 1440) + 1440) % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
displayed_time = formatMinutesAfter(offset_from_ref, local_ref_str)
print(f"6. Component renders time: {displayed_time}")
# --- ASSERTION ---
expected_local_time = "05:17"
print(f"\nASSERTION: Expected {expected_local_time}, Actual {displayed_time}")
if displayed_time == expected_local_time:
print("VERIFICATION SUCCESSFUL!")
else:
print("VERIFICATION FAILED!")
exit(1)
if __name__ == "__main__":
verify_real_wakeup_with_mock_api()
-57
View File
@@ -1,57 +0,0 @@
import datetime
def solve():
# Scenario: User in Stockholm (UTC+2)
# Local Wakeup Reference: 04:00
# Actual Wakeup Time (UTC): 03:17
offset_minutes = -120 # Stockholm UTC+2 -> getTimezoneOffset() is -120
wakeup_ref_local = "04:00"
wakeup_ref_mins_local = 4 * 60
# Let's assume the backend calculates: wakeup_minutes = (UTC_wakeup - UTC_ref)
# where it treats the provided ref as if it were in UTC.
# Case 1: BUGGY (Frontend sends Local ref "04:00" to backend)
# Backend treats 04:00 as UTC.
utc_wakeup = 3 * 60 + 17 # 03:17 UTC
wakeup_minutes_from_backend = utc_wakeup - wakeup_ref_mins_local # 197 - 240 = -43
print(f"BUGGY: Backend returns wakeup_minutes (assuming ref 04:00 is UTC): {wakeup_minutes_from_backend}")
# Frontend (current) tries to fix it with adjustForTimezone:
received_wakeup_minutes = wakeup_minutes_from_backend
# offsetFromRef = getLocalOffsetFromReference(received, "04:00")
# localMins = adjustForTimezone(received) = received - offset_minutes
# offsetFromRef = localMins - ref_mins_local
def adjustForTimezone(utc_min):
return utc_min - offset_minutes
def getLocalOffsetFromReference(utc_min, ref_mins):
local_m = adjustForTimezone(utc_min)
return local_m - ref_mins
offset_from_ref = getLocalOffsetFromReference(received_wakeup_minutes, wakeup_ref_mins_local)
print(f"BUGGY: Frontend calculates offsetFromRef: {offset_from_ref}")
# formatMinutesAfter(offset, ref) -> ref + offset
print(f"BUGGY: Displayed time (ref + offset): {wakeup_ref_mins_local + offset_from_ref}")
# Case 2: CORRECT (Frontend sends UTC ref "02:00" to backend)
# Stockholm +2 means 04:00 Local is 02:00 UTC.
utc_wakeup_ref = wakeup_ref_mins_local + offset_minutes # 240 - 120 = 120
wakeup_minutes_from_backend_correct = utc_wakeup - utc_wakeup_ref # 197 - 120 = 77
print(f"\nCORRECT: Backend returns wakeup_minutes (assuming ref 02:00 is UTC): {wakeup_minutes_from_backend_correct}")
# Frontend receives 77.
# If it's a delta, the component should use it directly as offsetFromRef.
# Or if we want to keep existing logic, we have to realize 77 is ALREADY the delta.
# But the component thinks it's "minutes from midnight".
# This is a mismatch.
print("---")
print(f"Target Local Wakeup: 05:17 ({3 * 60 + 17 + abs(offset_minutes)})")
print(f"Target Local Ref: 04:00 ({wakeup_ref_mins_local})")
print(f"Target Local Delta: {317 - 240} = 77")
solve()
-31
View File
@@ -1,31 +0,0 @@
# Plan: Fix Timezone Handling in Wakeup Times Module
## Problem Description
The "Wakeup Times" chart (in `frontend/src/components/stats/WakeupPlot.tsx`) displays wakeup times incorrectly due to a timezone mismatch.
- **Observation**: If a baby wakes up at `05:55` local time (e.g., in UTC+2), the chart shows `03:55`.
- **Cause**: The backend stores times in UTC. The frontend currently receives minutes relative to UTC midnight and renders them directly using `formatMinutesAfter` without applying the client's local timezone offset.
## Investigation Steps
1. **Backend Verification**:
- Examine `backend/app/routers/stats.py` (or equivalent) and the CRUD logic to confirm that `wakeup_minutes` is calculated based on UTC midnight.
- Verify how the backend handles the `wakeup_ref` and `bedtime_ref` parameters.
2. **Frontend Logic Audit**:
- Trace the data flow from `fetchWakeSleepTimes` -> `useStats` -> `WakeupPlot.tsx`.
- Confirm that `wakeup_minutes` is indeed a raw offset from UTC midnight.
## Implementation Plan
1. **Calculate Local Offset**: Use `new Date().getTimezoneOffset()` in the frontend to determine the difference between UTC and local time.
2. **Adjust Minutes**:
- In `WakeupPlot.tsx`, modify the mapping logic to adjust `day.wakeup_minutes` by the local timezone offset before it is passed to `formatMinutesAfter`.
- Formula: `local_minutes = utc_minutes - (timezone_offset_in_minutes)`.
*(Note: `getTimezoneOffset()` returns minutes positive for west of UTC, negative for east, so subtraction is correct to add offset to UTC).*
3. **Refactor Utilities (if needed)**: If the logic is used elsewhere, consider moving timezone-aware formatting to `frontend/src/utils/time.ts`.
## Verification Plan
1. **Manual Verification**:
- Mock a response from the backend with a known UTC time.
- Check if the rendered string matches the expected local time.
2. **Automated Testing**:
- Add/update a test case in `frontend/tests/e2e/stats.spec.ts` (or a unit test in `frontend/tests/`) that simulates different timezone offsets to ensure the display is correct.
3. **Regression Check**:
- Ensure `BedtimePlot.tsx` and other time-related components are not negatively impacted by changes to shared utilities.