19 Commits

Author SHA1 Message Date
jacobh2 a86bf2ded4 feat(frontend): remove 'v' prefix from footer and add dev mode versioning
Build Backend / build (push) Successful in 2m42s
Build Frontend / build (push) Successful in 11m0s
2026-06-07 20:31:30 +02:00
jacobh2 969b8e033f No idea what it is doing... very sad 2026-06-07 19:59:31 +02:00
jacobh2 cfc3156906 refactor: update sleep session logic, models, and agent protocols 2026-06-04 16:37:37 +02:00
jacobh2 81eadf618a Update gitignore
Build Frontend / build (push) Successful in 9m6s
2026-06-04 11:33:42 +02:00
jacobh2 6847886e7a chore: add vite environment type definitions 2026-06-04 11:33:21 +02:00
jacobh2 5d8a8b3c57 chore: update deployment and app structure 2026-06-04 11:31:44 +02:00
jacobh2 32d3e86d38 fix(frontend): resolve linting errors and component duplication 2026-06-04 11:30:18 +02:00
jacobh2 b763aa1e9d Small cleanup
Build Backend / build (push) Successful in 1m54s
2026-06-04 09:18:22 +02:00
jacobh2 df210c66a0 Update todo 2026-06-04 09:00:11 +02:00
jacobh2 560863d914 Remove linting jobs from Gitea workflows 2026-06-04 08:58:42 +02:00
jacobh2 ac637a6dae Update todo 2026-06-03 22:55:29 +02:00
jacobh2 32af0f4ba3 ci: update checkout version, fix shell quoting, and add actionlint job 2026-06-03 22:50:45 +02:00
jacobh2 a4852a574a docs: fix OpenCode subagent terminology in AGENTS.md
Replace "sub-agent" (hyphenated) with "subagent" and update "spawn a
sub-agent" to "invoke a subagent via the Task tool" to match OpenCode's
actual documented terminology.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:45:41 +02:00
jacobh2 ecaa40c523 Update todo 2026-06-03 22:38:02 +02:00
jacobh2 905f53c74e ci: fix tag versioning by fetching all tags in workflows 2026-06-03 22:37:46 +02:00
jacobh2 e39f930cf9 Update agents 2026-06-03 22:35:38 +02:00
jacobh2 852dd38add fix(stats): make API endpoints timezone-aware to fix sleep session attribution 2026-06-03 22:29:24 +02:00
jacobh2 7ff1c22edc Update todo 2026-06-03 22:22:16 +02:00
jacobh2 7d4e2a376c timetable maybe working, very cloase at least 2026-06-03 22:14:18 +02:00
35 changed files with 401 additions and 430 deletions
+6 -11
View File
@@ -19,26 +19,22 @@ jobs:
steps:
- name: Install Git and Docker CLI
run: apk add --no-cache git docker-cli docker-cli-buildx
- name: Log in to Gitea Container Registry
# This uses the automated token provided natively by Gitea Actions
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
- name: Create Custom Buildx Builder for HTTP
run: |
# 1. Write a configuration rule forcing plain HTTP for your local domain
echo '[registry."gitea.lan"]' > buildkitd.toml
echo ' http = true' >> buildkitd.toml
# 2. Tell buildx to create and use a builder with this rule
docker buildx create --use --name local-http-builder --config buildkitd.toml
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
# Tells the action to use the local 'git' binary we just installed
set-safe-directory: 'true'
fetch-depth: 0
- name: Determine Version
run: |
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
@@ -50,8 +46,7 @@ jobs:
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build Docker image
run: |
docker buildx build \
@@ -59,8 +54,8 @@ jobs:
-t gitea.lan/jacobh2/babymonitor-backend:${{ env.VERSION }} \
./backend \
--push
- name: Tag and Push Version
run: |
git tag $VERSION
git push origin $VERSION
git tag "$VERSION"
git push origin "$VERSION"
+8 -11
View File
@@ -19,26 +19,22 @@ jobs:
steps:
- name: Install Git and Docker CLI
run: apk add --no-cache git docker-cli docker-cli-buildx
- name: Log in to Gitea Container Registry
# This uses the automated token provided natively by Gitea Actions
run: echo "${{ secrets.babymonitor_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
- name: Create Custom Buildx Builder for HTTP
run: |
# 1. Write a configuration rule forcing plain HTTP for your local domain
echo '[registry."gitea.lan"]' > buildkitd.toml
echo ' http = true' >> buildkitd.toml
# 2. Tell buildx to create and use a builder with this rule
docker buildx create --use --name local-http-builder --config buildkitd.toml
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
# Tells the action to use the local 'git' binary we just installed
set-safe-directory: 'true'
fetch-depth: 0
- name: Determine Version
run: |
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
@@ -50,17 +46,18 @@ jobs:
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build Docker image
run: |
docker buildx build \
--platform linux/arm64 \
--build-arg VITE_APP_VERSION=$(git describe --tags) \
--build-arg VITE_APP_GITSHA=$(git rev-parse --short HEAD) \
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
./frontend \
--push
- name: Tag and Push Version
run: |
git tag $VERSION
git push origin $VERSION
git tag "$VERSION"
git push origin "$VERSION"
+2
View File
@@ -5,6 +5,8 @@
*.js
*.jsx
.omo/
### Node ###
# Logs
logs
+49 -12
View File
@@ -6,7 +6,6 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
## STRUCTURE
```
./
├── 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 |
| Backend models | `backend/app/models.py` | SQLModel definitions |
| API schemas | `backend/app/schemas.py` | Pydantic request/response |
| Settings / env vars | `backend/app/config.py` | Pydantic-settings, reads `.env` |
| Database init | `backend/app/database.py` | Engine setup, `init_db()` |
| Timezone logic | `backend/app/utils/time_utils.py` | Historical bug source — handle with care |
## CONVENTIONS
@@ -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.
- **Assumption Error**: NEVER assume the content of ANY files. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it.
## MANDATORY EXECUTION PROTOCOLS (INJECTABLE)
The following protocols must be strictly adhered to upon injection:
- **Read Before Edit**: ALWAYS read the entire target file before performing any `edit` operation.
- **Post-Edit Verification**: Immediately after an edit, verify that no lines have been duplicated.
- **Stability Check**: Following any code change, run both backend and frontend services AND the full test suite to ensure system stability.
## COMMANDS
### Backend Development
@@ -61,8 +69,13 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
```bash
cd backend
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
# Run a specific test file (preferred during debugging)
uv run pytest tests/test_stats.py -v
```
### Frontend Development
@@ -70,35 +83,59 @@ uv run pytest
```bash
cd frontend
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
# Unit + e2e tests (Vitest + Playwright)
npm run test:all
# Lint
npm run lint
```
### Docker (production-like, ARM64)
```bash
# Full stack — frontend served on http://localhost:8080
docker compose up --build
```
## ENVIRONMENT VARIABLES
Backend reads from `backend/.env` (optional — all have defaults):
| Variable | Default | Purpose |
|----------|---------|---------|
| `DATABASE_URL` | `sqlite+aiosqlite:///./data/sleep.db` | SQLite DB path |
| `CORS_ORIGINS` | `http://localhost:5173` | Allowed CORS origins (comma-separated) |
| `LOG_LEVEL` | `info` | Uvicorn log level |
In Docker the DB is persisted via a bind-mount at `./backend/data/`.
## 🛑 DIRECTORY NAVIGATION & FILE DISCOVERY
**Context:** This is a full-stack monorepo containing massive generated/dependency folders (`node_modules`, `dist`, `venv`, `.venv`, `__pycache__`, etc.). Unrestricted file listings will bloat your context window, crash your session, and cause severe token waste.
* **NEVER use `ls -R`:** This command is strictly forbidden in this repository.
* **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:
```bash
tree -I 'node_modules|.git|dist|build|__pycache__|venv|.venv|env|.pytest_cache|.mypy_cache|.ruff_cache|*.egg-info'
```
* **Searching Code:** If you need to search for text or code blocks across the repo, **ALWAYS use `git grep "search_term"`** instead of standard `grep -r` to avoid crawling ignored or minified assets.
* **Searching Code:** If you need to search for text or code blocks across the repo, **ALWAYS use `git grep "search_term"**` instead of standard `grep -r` to avoid crawling ignored or minified assets.
## 🧠 TASK DELEGATION & CONTEXT PRESERVATION
**Context:** The main OpenCode agent context window is our most critical resource. Executing complex tasks, debugging cross-file issues, or writing massive features directly in the main thread causes severe context bloat, leading to code hallucinations and dropped instructions.
* **Delegate Aggressively:** For any complex implementation, structural change, or task larger than a single file edit, you **MUST spawn a sub-agent**. Act strictly as the architectural orchestrator; let sub-agents handle the iterative coding labor.
* **Scope Narrowly:** Give sub-agents highly specific, isolated, and atomic objectives (e.g., *"Read `backend/app/routers/auth.py` and summarize endpoints"* or *"Write a unit test for the frontend sleep tracker component"*).
* **Strict Summarization:** Sub-agents are **STRICTLY FORBIDDEN** from passing raw terminal dumps, unedited file blocks, or their internal step-by-step logs back to the main thread.
* **Return Format:** Sub-agents must return *only* clear, actionable results back to the orchestrator: a concise summary of findings, the exact lines of code that require modification, or direct confirmation of a successful execution.
* **Delegate Aggressively:** For any complex implementation, structural change, or task larger than a single file edit, you **MUST invoke a subagent via the Task tool**. Act strictly as the architectural orchestrator; let subagents handle the iterative coding labor.
* **Scope Narrowly:** Give subagents highly specific, isolated, and atomic objectives (e.g., *"Read `backend/app/routers/auth.py` and summarize endpoints"* or *"Write a unit test for the frontend sleep tracker component"*).
* **Strict Summarization:** Subagents are **STRICTLY FORBIDDEN** from passing raw terminal dumps, unedited file blocks, or their internal step-by-step logs back to the main thread.
* **Return Format:** Subagents must return *only* clear, actionable results back to the orchestrator: a concise summary of findings, the exact lines of code that require modification, or direct confirmation of a successful execution.
## NOTES
-1
View File
@@ -9,6 +9,5 @@ class SleepSession(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
start_time: datetime = Field(nullable=False)
end_time: Optional[datetime] = Field(default=None, nullable=True)
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+51 -23
View File
@@ -16,23 +16,31 @@ async def get_daily_summary(
to_date: datetime,
day_start: str = Query("07:00"),
day_end: str = Query("20:00"),
timezone_offset: int = Query(0),
session: AsyncSession = Depends(get_session),
):
if to_date.hour == 0 and to_date.minute == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
try:
day_start_time = time.fromisoformat(day_start)
day_end_time = time.fromisoformat(day_end)
except ValueError:
return {"days": []}
adj_from_date = from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
utc_from_date = from_date + timedelta(minutes=timezone_offset)
utc_to_date = to_date + timedelta(minutes=timezone_offset)
adj_utc_from_date = utc_from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_utc_from_date, to_date=utc_to_date)
summary_map = {}
for s in sessions:
if not s.end_time:
continue
local_start = s.start_time - timedelta(minutes=timezone_offset)
local_end = s.end_time - timedelta(minutes=timezone_offset)
segments = get_day_night_splits(
s.start_time, s.end_time, day_start_time, day_end_time
local_start, local_end, day_start_time, day_end_time
)
for seg in segments:
if not (from_date.date() <= seg["start"].date() <= to_date.date()):
@@ -66,26 +74,37 @@ async def get_timetable(
to_date: datetime,
day_start: str = Query("07:00"),
day_end: str = Query("20:00"),
timezone_offset: int = Query(0),
session: AsyncSession = Depends(get_session),
):
adj_from_date = from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
if to_date.hour == 0 and to_date.minute == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
utc_from_date = from_date + timedelta(minutes=timezone_offset)
utc_to_date = to_date + timedelta(minutes=timezone_offset)
adj_utc_from_date = utc_from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_utc_from_date, to_date=utc_to_date)
slots = []
now = datetime.now(timezone.utc).replace(tzinfo=None)
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
current_date = from_date.date()
while current_date <= to_date.date():
date_str = current_date.strftime("%Y-%m-%d")
for i in range(96):
slot_start_time = datetime.combine(current_date, time(0, 0)) + timedelta(
slot_start_local = datetime.combine(current_date, time(0, 0)) + timedelta(
minutes=i * 15
)
slot_end_time = slot_start_time + timedelta(minutes=15)
slot_start_str = slot_start_time.strftime("%H:%M")
slot_end_str = slot_end_time.strftime("%H:%M")
slot_end_local = slot_start_local + timedelta(minutes=15)
slot_start_str = slot_start_local.strftime("%H:%M")
slot_end_str = slot_end_local.strftime("%H:%M")
# Convert local slots to UTC for comparison with DB sessions
slot_start_utc = slot_start_local + timedelta(minutes=timezone_offset)
slot_end_utc = slot_end_local + timedelta(minutes=timezone_offset)
is_sleeping = False
for s in sessions:
effective_end_time = s.end_time if s.end_time is not None else now
if s.start_time <= slot_end_time and effective_end_time >= slot_start_time:
effective_end_time_utc = s.end_time if s.end_time is not None else now_utc
if s.start_time <= slot_end_utc and effective_end_time_utc >= slot_start_utc:
is_sleeping = True
break
slots.append(
@@ -140,17 +159,22 @@ async def get_wake_sleep_times(
to_date: datetime,
wakeup_ref: str = Query("04:00"),
bedtime_ref: str = Query("19:00"),
timezone_offset: int = Query(0),
session: AsyncSession = Depends(get_session),
):
if to_date.hour == 0 and to_date.minute == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
try:
w_ref_time = time.fromisoformat(wakeup_ref)
b_ref_time = time.fromisoformat(bedtime_ref)
except ValueError:
return {"days": []}
# Fetch sessions starting from one day before to catch night sleep ending on from_date
adj_from_date = from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
utc_from_date = from_date + timedelta(minutes=timezone_offset)
utc_to_date = to_date + timedelta(minutes=timezone_offset)
adj_utc_from_date = utc_from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_utc_from_date, to_date=utc_to_date)
days_data = []
current_date = from_date.date()
while current_date <= to_date.date():
@@ -162,20 +186,24 @@ async def get_wake_sleep_times(
night_session = None
for s in sessions:
# A session that runs over midnight into current_date
if s.start_time < midnight_today and s.end_time and s.end_time >= midnight_today:
night_session = s
# Convert to local for comparison
s_start_local = s.start_time - timedelta(minutes=timezone_offset)
s_end_local = s.end_time - timedelta(minutes=timezone_offset) if s.end_time else None
if s_start_local < midnight_today and s_end_local and s_end_local >= midnight_today:
night_session = (s, s_start_local, s_end_local)
break
if night_session:
# Bedtime offset from b_ref_time on the day it started
s_start_date = night_session.start_time.date()
s, s_start_local, s_end_local = night_session
# Bedtime offset from b_ref_time on the day it started (local)
s_start_date = s_start_local.date()
ref_datetime_bedtime = datetime.combine(s_start_date, b_ref_time)
bedtime_min = int((night_session.start_time - ref_datetime_bedtime).total_seconds() / 60.0)
bedtime_min = int((s_start_local - ref_datetime_bedtime).total_seconds() / 60.0)
# Wakeup offset from w_ref_time on the current day
# Wakeup offset from w_ref_time on the current day (local)
ref_datetime_wakeup = datetime.combine(current_date, w_ref_time)
wakeup_min = int((night_session.end_time - ref_datetime_wakeup).total_seconds() / 60.0)
wakeup_min = int((s_end_local - ref_datetime_wakeup).total_seconds() / 60.0)
days_data.append(
{
-5
View File
@@ -6,22 +6,17 @@ from pydantic import BaseModel
class SleepSessionCreate(BaseModel):
start_time: datetime
class SleepSessionUpdate(BaseModel):
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
notes: Optional[str] = None
class SleepSessionRead(BaseModel):
id: int
start_time: datetime
end_time: Optional[datetime]
notes: Optional[str]
created_at: datetime
updated_at: datetime
class SessionListResponse(BaseModel):
sessions: List[SleepSessionRead]
total: int
-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
ARG VITE_APP_VERSION=unknown
ARG VITE_APP_GITSHA=unknown
ENV VITE_APP_VERSION=$VITE_APP_VERSION
ENV VITE_APP_GITSHA=$VITE_APP_GITSHA
WORKDIR /app
# Copy package files first to leverage Docker cache
+3
View File
@@ -4,6 +4,8 @@ import TrackerPage from './components/tracker/TrackerPage';
import StatsPage from './components/stats/StatsPage';
import Navbar from './components/Navbar';
import Playground from './components/Playground';
import Footer from './components/Footer';
function App() {
return (
@@ -17,6 +19,7 @@ function App() {
<Route path="/playground" element={<Playground />} />
</Routes>
</main>
<Footer />
</div>
</Router>
);
+8 -9
View File
@@ -32,9 +32,7 @@ export function localToUTC(localValue: string): string {
}
async function fetchStats<T>(endpoint: string, from_date: string, to_date: string, extraParams: Record<string, string> = {}): Promise<T> {
const fromDT = new Date(`${from_date}T00:00:00`).toISOString();
const toDT = new Date(`${to_date}T23:59:59`).toISOString();
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, ...extraParams });
const params = new URLSearchParams({ from_date, to_date, ...extraParams });
const response = await fetch(`${BASE_URL}/api/stats/${endpoint}?${params.toString()}`);
if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`);
return response.json();
@@ -44,18 +42,19 @@ export async function fetchSummary(from_date: string, to_date: string): Promise<
return fetchStats('summary', from_date, to_date);
}
export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string): Promise<DailySummaryResponse> {
return fetchStats('daily-summary', from_date, to_date, { day_start, day_end });
export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string, timezone_offset = 0): Promise<DailySummaryResponse> {
return fetchStats('daily-summary', from_date, to_date, { day_start, day_end, timezone_offset: timezone_offset.toString() });
}
export async function fetchTimetable(from_date: string, to_date: string, day_start: string, day_end: string): Promise<TimetableResponse> {
return fetchStats('timetable', from_date, to_date, { day_start, day_end });
export async function fetchTimetable(from_date: string, to_date: string, day_start: string, day_end: string, timezone_offset = 0): Promise<TimetableResponse> {
return fetchStats('timetable', from_date, to_date, { day_start, day_end, timezone_offset: timezone_offset.toString() });
}
export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise<WakeupBedtimeResponse> {
export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string, timezone_offset = 0): Promise<WakeupBedtimeResponse> {
return fetchStats('wake-sleep-times', from_date, to_date, {
wakeup_ref: localHHMMToUTCHHMM(wakeup_ref),
bedtime_ref: localHHMMToUTCHHMM(bedtime_ref)
bedtime_ref: localHHMMToUTCHHMM(bedtime_ref),
timezone_offset: timezone_offset.toString()
});
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
const Footer: React.FC = () => {
return (
<footer className="py-4 text-center">
<p className="text-xs text-slate-500">
{__APP_VERSION__} ({__APP_GITSHA__})
</p>
</footer>
);
};
export default Footer;
@@ -100,8 +100,8 @@ const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_sta
width={60}
/>
<Tooltip
formatter={(value: any, name: any) => [
formatMinutes(Math.round(Number(value ?? 0) * 60)),
formatter={(value: number | string | readonly (string | number)[] | undefined, name: string | number | undefined) => [
formatMinutes(Math.round(Number((Array.isArray(value) ? value[0] : value ?? 0) * 60))),
name === 'daySleep' ? 'Day' : 'Night',
]}
/>
+11 -11
View File
@@ -4,7 +4,7 @@ import { useSettings } from '../../hooks/useSettings';
import { Pagination } from './Pagination';
import { Edit2, Check, X, Trash2 } from 'lucide-react';
import type { SleepSessionRead } from '../../types';
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes, toUTCFromLocalInput, parseBackendDateTime } from '../../utils/time';
export function SessionList() {
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
@@ -29,13 +29,13 @@ export function SessionList() {
const handleSave = async (id: number) => {
try {
await updateSession({
sessionId: id,
payload: {
start_time: new Date(editStart).toISOString(),
end_time: editEnd ? new Date(editEnd).toISOString() : undefined,
}
});
await updateSession({
sessionId: id,
payload: {
start_time: toUTCFromLocalInput(editStart),
end_time: editEnd ? toUTCFromLocalInput(editEnd) : undefined,
},
});
setEditingId(null);
} catch (err) {
console.error(err);
@@ -57,8 +57,8 @@ export function SessionList() {
const startEditing = (s: SleepSessionRead) => {
setEditingId(s.id);
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : '');
setEditStart(toLocalDatetimeInputValue(parseBackendDateTime(s.start_time)));
setEditEnd(s.end_time ? toLocalDatetimeInputValue(parseBackendDateTime(s.end_time)) : '');
};
const handleCancel = () => {
@@ -161,6 +161,6 @@ export function SessionList() {
onPageChange={setCurrentPage}
/>
)}
</div>
</div >
);
}
@@ -1,6 +1,6 @@
import { useMemo, useState, useEffect, Fragment } from 'react';
import { useStats } from '../../hooks/useStats';
import { timeToMinutes, adjustForTimezone } from '../../utils/time';
import { timeToMinutes } from '../../utils/time';
interface TimetableProps {
from_date: string;
@@ -27,7 +27,8 @@ function formatTimeLabel(mins: number) {
}
const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) => {
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end);
const timezoneOffset = typeof window !== 'undefined' ? new Date().getTimezoneOffset() : 0;
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end, '04:00', '19:00', timezoneOffset);
const [selectedDayIndex, setSelectedDayIndex] = useState(0);
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
@@ -51,7 +52,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
useEffect(() => {
if (days.length > 0) {
setSelectedDayIndex(days.length - 1);
const newIndex = days.length - 1;
setSelectedDayIndex(prev => (prev !== newIndex ? newIndex : prev));
}
}, [days]);
@@ -106,8 +108,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
return timetable.slots.some((slot) => {
if (slot.date !== date || !slot.sleeping) return false;
const sStart = adjustForTimezone(timeToMinutes(slot.slot_start));
const sEnd = adjustForTimezone(timeToMinutes(slot.slot_end));
const sStart = timeToMinutes(slot.slot_start);
const sEnd = timeToMinutes(slot.slot_end);
if (sEnd <= sStart) {
const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
@@ -157,7 +159,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
</button>
);
})}
</div >
</div>
)}
{/* Grid Container */}
@@ -175,27 +177,27 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
{visibleDays.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
{getDayName(day)}
</div >
</div>
))}
</div >
</div>
{/* Time Grid Container */}
<div className="relative">
<div
className="grid"
style={{
gridTemplateColumns: tier === 'desktop'
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
: `4rem repeat(${visibleDays.length}, 1fr)`
}}
>
className="grid"
style={{
gridTemplateColumns: tier === 'desktop'
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
: `4rem repeat(${visibleDays.length}, 1fr)`
}}
>
{/* Time Rows */}
{timeSteps.map((step) => (
<Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
{step % 60 === 0 ? formatTimeLabel(step) : ''}
</div >
</div>
{/* Grid Cells */}
{visibleDays.map((day) => {
@@ -203,6 +205,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
return (
<div
key={`${day}-${step}`}
data-testid={`cell-${day}-${formatTimeLabel(step)}`}
className={`h-2 w-full border border-gray-100 ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
/>
);
@@ -218,11 +221,11 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
transform: 'translateY(-50%)'
}}
/>
</div >
</div >
</div >
</div >
</div>
</div>
</div>
</div>
);
};
export default Timetable;
export default Timetable;
+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';
+1 -1
View File
@@ -1,5 +1,5 @@
import { useContext } from 'react';
import { SettingsContext, Settings } from './SettingsProvider';
import { SettingsContext } from './SettingsProvider';
export function useSettings() {
const context = useContext(SettingsContext);
+8 -7
View File
@@ -15,21 +15,22 @@ export function useStats(
day_start = '07:00',
day_end = '20:00',
wakeup_ref = '04:00',
bedtime_ref = '19:00'
bedtime_ref = '19:00',
timezoneOffset = 0
) {
const dailySummaryQuery = useQuery<DailySummaryResponse>({
queryKey: ['dailySummary', from_date, to_date, day_start, day_end],
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end),
queryKey: ['dailySummary', from_date, to_date, day_start, day_end, timezoneOffset],
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end, timezoneOffset),
});
const timetableQuery = useQuery<TimetableResponse>({
queryKey: ['timetable', from_date, to_date, day_start, day_end],
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end),
queryKey: ['timetable', from_date, to_date, day_start, day_end, timezoneOffset],
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end, timezoneOffset),
});
const wakeupBedtimeQuery = useQuery<WakeupBedtimeResponse>({
queryKey: ['wakeSleepTimes', from_date, to_date, wakeup_ref, bedtime_ref],
queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref),
queryKey: ['wakeSleepTimes', from_date, to_date, wakeup_ref, bedtime_ref, timezoneOffset],
queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref, timezoneOffset),
});
return {
+21 -3
View File
@@ -38,9 +38,15 @@ export function adjustForTimezone(utcMinutes: number): number {
* @returns string in "YYYY-MM-DDTHH:MM" format
*/
export function toLocalDatetimeInputValue(date: Date): string {
const offset = date.getTimezoneOffset() * 60 * 1000;
const localDate = new Date(date.getTime() - offset);
return localDate.toISOString().slice(0, 16);
const formatter = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return formatter.format(date).replace(' ', 'T');
}
export function formatElapsedTime(utcTimestamp: string): string {
@@ -192,3 +198,15 @@ export function formatMinutes(totalMinutes: number): string {
}
return `${mins}m`;
}
/**
* Parses a backend timestamp string into a Date object,
* ensuring it is treated as UTC if no timezone info is present.
*/
export function parseBackendDateTime(time: string): Date {
if (typeof time === 'string' && !time.endsWith('Z') && !time.includes('+')) {
return new Date(time + 'Z');
}
// If it already has Z or offset, new Date() will handle it correctly.
return new Date(time);
}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_GITSHA__: string;
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { vi, describe, it, expect, beforeEach } from 'vitest';
@@ -35,78 +35,46 @@ describe('SleepTimetable', () => {
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
});
it('renders in desktop mode (chronological order)', () => {
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
isLoading: false,
});
it('shows sleeping status for intervals spanning midnight across days', () => {
const day1 = '2025-04-18';
const day2 = '2025-04-19';
vi.stubGlobal('innerWidth', 1024);
render(<Timetable {...defaultProps} />);
// Check for day headers in chronological order
const days = screen.getAllByText(/^[A-Z]{3}$/i); // SUN, MON, etc.
// 2025-04-18 is Friday (FRI)
expect(days[0]).toHaveTextContent(/FRI/i);
expect(days[1]).toHaveTextContent(/SAT/i);
});
it('renders in medium mode (3-day tabs and 3-column grid)', () => {
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
isLoading: false,
});
vi.stubGlobal('innerWidth', 600);
render(<Timetable {...defaultProps} />);
fireEvent(window, new Event('resize'));
// Check for 3-day interval tabs
// In reversed order (25, 24, 23...), the first group is Wed - Fri
expect(screen.getByRole('button', { name: /Wed \d+ - Fri \d+/i })).toBeInTheDocument();
});
it('renders in small mode (single-day tabs and 1-column grid)', () => {
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
isLoading: false,
});
vi.stubGlobal('innerWidth', 300);
render(<Timetable {...defaultProps} />);
fireEvent(window, new Event('resize'));
// Check for single-day tabs
const dayTabs = screen.getAllByRole('button');
// At least one tab should have the text FRI
const friTab = dayTabs.find(tab => tab.textContent?.toUpperCase().includes('FRI'));
expect(friTab).toBeInTheDocument();
});
it('colors sleeping intervals correctly', () => {
(useStats as any).mockReturnValue({
timetable: {
slots: [
{
date: '2025-04-18',
date: day1,
sleeping: true,
slot_start: '01:00',
slot_end: '03:00', // Covers 01:00 and 02:00
slot_start: '23:00',
slot_end: '23:45',
},
{
date: day2,
sleeping: true,
slot_start: '00:00',
slot_end: '01:00',
},
],
},
isLoading: false,
});
const { container } = render(<Timetable {...defaultProps} />);
const testProps = {
from_date: day1,
to_date: day2,
day_start: '22:00',
day_end: '08:00',
};
// We are looking for the grid cells.
// Since they don't have text, we look at their classes/backgrounds.
const greenCells = container.querySelectorAll('.bg-green-500');
expect(greenCells.length).toBeGreaterThan(0);
vi.stubGlobal('innerWidth', 1024); // Desktop mode
const { container } = render(<Timetable {...testProps} />);
const grayCells = container.querySelectorAll('.bg-gray-50');
expect(grayCells.length).toBeGreaterThan(0);
// Check Day 1: 23:00
const cellD1_2300 = container.querySelector(`[data-testid="cell-${day1}-23:00"]`);
expect(cellD1_2300).toHaveClass('bg-green-500');
// Check Day 2: 00:15
const cellD2_0015 = container.querySelector(`[data-testid="cell-${day2}-00:15"]`);
expect(cellD2_0015).toHaveClass('bg-green-500');
});
});
});
+26
View File
@@ -13,6 +13,7 @@ import {
getLocalOffsetFromReference,
formatDateTime,
toUTCFromLocalInput,
parseBackendDateTime,
} from '../../src/utils/time';
describe('formatMinutesAfter', () => {
@@ -167,4 +168,29 @@ describe('toUTCFromLocalInput', () => {
expect(toUTCFromLocalInput('2026-06-0')).toBe('');
});
});
describe('parseBackendDateTime', () => {
it('should parse a UTC string without Z as UTC', () => {
const time = '2026-06-04 13:58:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
it('should parse a UTC string with Z as UTC', () => {
const time = '2026-06-04T13:58:00Z';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
it('should parse a string with an offset correctly', () => {
const time = '2026-06-04T13:58:00+02:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T11:58:00.000Z');
});
it('should handle ISO format with T', () => {
const time = '2026-06-04T13:58:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { toLocalDatetimeInputValue } from '../../src/utils/time';
describe('toLocalDatetimeInputValue - Timezone Correctness', () => {
it('converts a UTC date to the correct local datetime-local input value (Stockholm)', () => {
// 11:58 UTC on June 4th, 2026 should be 13:58 in Stockholm (UTC+2)
const date = new Date('2026-06-04T11:58:00Z');
// We calculate the expected value using a deterministic formatter with explicit timezone
const expected = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Europe/Stockholm'
}).format(date).replace(' ', 'T');
const result = toLocalDatetimeInputValue(date);
// This assertion will fail if the function returns UTC instead of Stockholm local time
expect(result).toBe(expected);
});
it('converts a UTC date to the correct local datetime-local input value (other timezone)', () => {
// Testing with another timezone to ensure it's not hardcoded (though it shouldn't be)
const date = new Date('2026-06-04T11:58:00Z');
// Tokyo is UTC+9. 11:58 UTC -> 20:58 Tokyo
const expectedTokyo = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Asia/Tokyo'
}).format(date).replace(' ', 'T');
const result = toLocalDatetimeInputValue(date);
// NOTE: This second test might fail if the environment is NOT Tokyo/Stockholm,
// but it's useful to show that the current implementation depends on runtime TZ.
// However, for our specific bug fix, we only need to prove it's NOT necessarily local if the runner is UTC.
// Actually, let's just stick to one robust test that proves it doesn't match the intended local time if the runner is UTC.
});
});
+20 -1
View File
@@ -2,10 +2,29 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { execSync } from 'child_process';
const isDev = process.env.NODE_ENV === 'development' || process.env.vite === 'true';
let APP_VERSION = process.env.VITE_APP_VERSION || 'unknown';
let APP_GITSHA = process.env.VITE_APP_GITSHA || 'unknown';
if (isDev) {
APP_VERSION = 'DEV';
try {
APP_GITSHA = execSync('git rev-parse --short HEAD').toString().trim();
} catch (e) {
APP_GITSHA = 'unknown';
}
}
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(APP_VERSION),
__APP_GITSHA__: JSON.stringify(APP_GITSHA),
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -19,4 +38,4 @@ export default defineConfig({
},
},
},
})
})
+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
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`
- **Sub-task 1.1: Update Statistics Calculation**
- Locate the `stats` `useMemo` block.
- Calculate the median of `validMinutes`.
- *Note*: To calculate the median, sort the array and pick the middle element (or average of two middle elements if even).
- Include `median` in the returned object: `{ min, max, avg, median }`.
- **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)`.
### 1. Reproduction (Frontend Test)
- Create a new test file or add to `frontend/tests/unit/components/stats/SleepTimetable.test.tsx`.
- Mock `useStats` to return a timetable containing a session that spans midnight (e.g., Wed 23:00 - Thu 06:00).
- The mock data should reflect what the backend currently returns (which is incorrectly attributed to the wrong day due to timezone mismatch).
- Assert that for a user in a non-UTC timezone, the green slots for 00:00-02:00 are on the correct day (Thu).
- This test is expected to fail initially because the current implementation attributes them to Wed.
### 2. Update `frontend/src/components/stats/BedtimePlot.tsx`
- **Sub-task 2.1: Update Statistics Calculation**
- Locate the `stats` `useMemo` block.
- Calculate the median of `validMinutes`.
- Include `median` in the returned object: `{ min, max, avg, median }`.
- **Sub-task 2.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 2.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), bedtime_ref)`.
### 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.
## Verification Plan
1. **Visual Inspection**: Ensure the four statistics are evenly spaced and aligned in a single row below each graph.
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.
### 3. Frontend Implementation (Integration)
- **Update Hook**: Modify `frontend/src/hooks/useStats.ts` to accept `timezoneOffset` and pass it to the API calls (`fetchTimetable`, etc.).
- **Update Client**: Modify `frontend/src/api/client.ts` to include the offset in request parameters.
- **Update Component**: In `frontend/src/components/stats/SleepTimetable.tsx`, ensure the component retrieves the offset via `new Date().getTimezoneOffset()` and passes it to `useStats`.
- **Cleanup**: Remove the manual `adjustForTimezone` calls in `SleepTimetable.tsx` since the backend will now provide correctly adjusted local times.
### 4. Verification
- Run the frontend unit tests.
- Verify the fix against the reported issue using a manual test or e2e test if possible.
---
**Note to Developer:**
To maintain a high-quality context window and avoid hallucinations, you **MUST** delegate complex tasks to sub-agents. Break down each phase above into smaller, atomic objectives for sub-agents (e.g., one agent for backend API changes, another for frontend hook updates). Do not attempt to execute the entire plan in a single thread.
-5
View File
@@ -1,5 +0,0 @@
podman save babymonitor-frontend:latest -o frontend.tar
podman save babymonitor-backend:latest -o backend.tar
scp frontend.tar backend.tar jacobhagstedt@raspberrypi.local:~/
-4
View File
@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}
-33
View File
@@ -1,33 +0,0 @@
const adjustForTimezone = (utcMinutes) => {
const offsetInMinutes = -120; // Simulate UTC+2
return utcMinutes - offsetInMinutes;
};
const formatMinutesAfter = (minutes, reference = "04:00") => {
const [refHours, refMinutes] = reference.split(":").map(Number);
const totalRefMinutes = refHours * 60 + refMinutes;
const targetMinutes = totalRefMinutes + minutes;
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
};
// Case 1: Backend sends offset from 04:00 UTC (-5 minutes)
const backendOffset = -5;
const adjusted = adjustForTimezone(backendOffset);
console.log("Adjusted offset:", adjusted); // Should be 115
console.log("Formatted time:", formatMinutesAfter(adjusted, "04:00")); // Should be 05:55
// Case 2: Backend sends minutes from UTC midnight (235 minutes)
const backendMidnightMins = 235;
const adjusted2 = adjustForTimezone(backendMidnightMins);
console.log("Adjusted midnight mins:", adjusted2); // Should be 355
// If we use it as an offset from reference:
console.log("Formatted time (as offset):", formatMinutesAfter(adjusted2, "04:00")); // Should be 09:55 (WRONG if it was supposed to be 05:55)
// If we want the offset from reference for Case 2:
const refMins = 4 * 60;
const correctOffset = adjusted2 - refMins; // 355 - 240 = 115
console.log("Corrected offset (from midnight):", correctOffset); // 115
console.log("Formatted time (corrected):", formatMinutesAfter(correctOffset, "04:00")); // 05:55
+30 -1
View File
@@ -3,4 +3,33 @@
- [ ] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing
- [ ] Add a test coverage metric to both frontend and backend so that we can check how much of the code is tested. I would like >=90%
- [ ] Go over Claude's slop-cleanup-stuff
- [ ] Update the "Sleep Sessions" by adding a fake pagination view in the frontend so that I don't have to scroll so much. Instead, I want to simply click "next" and "previous" (with like numbers in between like a google search)
- [x] Update the "Sleep Sessions" by adding a fake pagination view in the frontend so that I don't have to scroll so much. Instead, I want to simply click "next" and "previous" (with like numbers in between like a google search)
- [x] Bug with gitea workflow:
```
Tag and Push Version
2s
Run git tag $VERSION
To http://gitea:3001/jacobh2/babymonitor
! [rejected] v1.0.0 -> v1.0.0 (already exists)
error: failed to push some refs to 'http://gitea:3001/jacobh2/babymonitor';
hint: Updates were rejected because the tag already exists in the remote.
❌ Failure - Main Tag and Push Version
exitcode '1': failure
```
- [ ] Editing a sleep session in "sleep sessions", when I click the edit button the times show up as UTC but should show as my local timezone
- [x] We should drop the "notes" column from the sleep_session table
- [x] Switch over to oh-my-opencode and try it out
- [x] Adda footer at the bottom of the app that shows the version (aka git tag) of the app
- [x] Fix race condition of gitea workflows
If both workflows run at the same time, a race condition occurs:
1. Collision: Both workflows will query the latest tag (e.g., v1.0.5) and calculate the same next version (v1.0.6).
2. The Winner: The first workflow to finish its push will successfully create the v1.0.6 tag on the remote.
3. The Loser: The second workflow will attempt to push v1.0.6, but the remote will reject it because that tag already exists, causing the workflow to fail.
This happens because both workflows share the same global tag namespace (v1.0.x). To fix this, you should differentiate them by using unique prefixes, such as backend-v1.0.x and frontend-v1.0.x.
--> Invalid. the containers are named differently.
- [x] remove the lint form the gitea workflows added by the agent. makes no sense there
-58
View File
@@ -1,58 +0,0 @@
import datetime
def verify_real_wakeup_with_mock_api():
# --- DATA FROM DATABASE (REAL) ---
# session id 1 wakeup: 2026-05-27 03:17:00 UTC
wakeup_utc = datetime.datetime(2026, 5, 27, 3, 17)
wakeup_utc_mins = wakeup_utc.hour * 60 + wakeup_utc.minute
# --- SETUP PARAMETERS (CLIENT SIDE) ---
# User is in Stockholm (+2)
offset_in_minutes = -120
local_ref_str = "04:00"
local_ref_mins = 4 * 60
print(f"1. Database Wakeup (UTC): {wakeup_utc} ({wakeup_utc_mins}m)")
print(f"2. Client Local Reference: {local_ref_str} ({local_ref_mins}m)")
print(f"3. Client Timezone Offset: {offset_in_minutes}m")
# --- STEP 1: SIMULATE API CALL (CLIENT SIDE) ---
def localHHMMToUTCHHMM(hhmm, offset):
[h, m] = map(int, hhmm.split(":"))
total_mins_utc = (h * 60 + m) + offset
norm = ((total_mins_utc % 1440) + 1440) % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
utc_ref = localHHMMToUTCHHMM(local_ref_str, offset_in_minutes)
print(f"4. Simulated API call with wakeup_ref (converted to UTC): {utc_ref}")
# --- STEP 2: SIMULATE BACKEND (SERVER SIDE) ---
# Backend receives utc_ref and calculates delta: wakeup_utc - utc_ref
utc_ref_mins = (int(utc_ref.split(":")[0]) * 60 + int(utc_ref.split(":")[1]))
backend_delta = wakeup_utc_mins - utc_ref_mins
print(f"5. Backend returns delta: {backend_delta}")
# --- STEP 3: SIMULATE COMPONENT RENDERING (CLIENT SIDE) ---
# Component receives delta and uses it directly as offsetFromRef.
offset_from_ref = backend_delta
def formatMinutesAfter(minutes, ref):
[rh, rm] = map(int, ref.split(":"))
target = (rh * 60 + rm) + minutes
norm = ((target % 1440) + 1440) % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
displayed_time = formatMinutesAfter(offset_from_ref, local_ref_str)
print(f"6. Component renders time: {displayed_time}")
# --- ASSERTION ---
expected_local_time = "05:17"
print(f"\nASSERTION: Expected {expected_local_time}, Actual {displayed_time}")
if displayed_time == expected_local_time:
print("VERIFICATION SUCCESSFUL!")
else:
print("VERIFICATION FAILED!")
exit(1)
if __name__ == "__main__":
verify_real_wakeup_with_mock_api()
-57
View File
@@ -1,57 +0,0 @@
import datetime
def solve():
# Scenario: User in Stockholm (UTC+2)
# Local Wakeup Reference: 04:00
# Actual Wakeup Time (UTC): 03:17
offset_minutes = -120 # Stockholm UTC+2 -> getTimezoneOffset() is -120
wakeup_ref_local = "04:00"
wakeup_ref_mins_local = 4 * 60
# Let's assume the backend calculates: wakeup_minutes = (UTC_wakeup - UTC_ref)
# where it treats the provided ref as if it were in UTC.
# Case 1: BUGGY (Frontend sends Local ref "04:00" to backend)
# Backend treats 04:00 as UTC.
utc_wakeup = 3 * 60 + 17 # 03:17 UTC
wakeup_minutes_from_backend = utc_wakeup - wakeup_ref_mins_local # 197 - 240 = -43
print(f"BUGGY: Backend returns wakeup_minutes (assuming ref 04:00 is UTC): {wakeup_minutes_from_backend}")
# Frontend (current) tries to fix it with adjustForTimezone:
received_wakeup_minutes = wakeup_minutes_from_backend
# offsetFromRef = getLocalOffsetFromReference(received, "04:00")
# localMins = adjustForTimezone(received) = received - offset_minutes
# offsetFromRef = localMins - ref_mins_local
def adjustForTimezone(utc_min):
return utc_min - offset_minutes
def getLocalOffsetFromReference(utc_min, ref_mins):
local_m = adjustForTimezone(utc_min)
return local_m - ref_mins
offset_from_ref = getLocalOffsetFromReference(received_wakeup_minutes, wakeup_ref_mins_local)
print(f"BUGGY: Frontend calculates offsetFromRef: {offset_from_ref}")
# formatMinutesAfter(offset, ref) -> ref + offset
print(f"BUGGY: Displayed time (ref + offset): {wakeup_ref_mins_local + offset_from_ref}")
# Case 2: CORRECT (Frontend sends UTC ref "02:00" to backend)
# Stockholm +2 means 04:00 Local is 02:00 UTC.
utc_wakeup_ref = wakeup_ref_mins_local + offset_minutes # 240 - 120 = 120
wakeup_minutes_from_backend_correct = utc_wakeup - utc_wakeup_ref # 197 - 120 = 77
print(f"\nCORRECT: Backend returns wakeup_minutes (assuming ref 02:00 is UTC): {wakeup_minutes_from_backend_correct}")
# Frontend receives 77.
# If it's a delta, the component should use it directly as offsetFromRef.
# Or if we want to keep existing logic, we have to realize 77 is ALREADY the delta.
# But the component thinks it's "minutes from midnight".
# This is a mismatch.
print("---")
print(f"Target Local Wakeup: 05:17 ({3 * 60 + 17 + abs(offset_minutes)})")
print(f"Target Local Ref: 04:00 ({wakeup_ref_mins_local})")
print(f"Target Local Delta: {317 - 240} = 77")
solve()
-31
View File
@@ -1,31 +0,0 @@
# Plan: Fix Timezone Handling in Wakeup Times Module
## Problem Description
The "Wakeup Times" chart (in `frontend/src/components/stats/WakeupPlot.tsx`) displays wakeup times incorrectly due to a timezone mismatch.
- **Observation**: If a baby wakes up at `05:55` local time (e.g., in UTC+2), the chart shows `03:55`.
- **Cause**: The backend stores times in UTC. The frontend currently receives minutes relative to UTC midnight and renders them directly using `formatMinutesAfter` without applying the client's local timezone offset.
## Investigation Steps
1. **Backend Verification**:
- Examine `backend/app/routers/stats.py` (or equivalent) and the CRUD logic to confirm that `wakeup_minutes` is calculated based on UTC midnight.
- Verify how the backend handles the `wakeup_ref` and `bedtime_ref` parameters.
2. **Frontend Logic Audit**:
- Trace the data flow from `fetchWakeSleepTimes` -> `useStats` -> `WakeupPlot.tsx`.
- Confirm that `wakeup_minutes` is indeed a raw offset from UTC midnight.
## Implementation Plan
1. **Calculate Local Offset**: Use `new Date().getTimezoneOffset()` in the frontend to determine the difference between UTC and local time.
2. **Adjust Minutes**:
- In `WakeupPlot.tsx`, modify the mapping logic to adjust `day.wakeup_minutes` by the local timezone offset before it is passed to `formatMinutesAfter`.
- Formula: `local_minutes = utc_minutes - (timezone_offset_in_minutes)`.
*(Note: `getTimezoneOffset()` returns minutes positive for west of UTC, negative for east, so subtraction is correct to add offset to UTC).*
3. **Refactor Utilities (if needed)**: If the logic is used elsewhere, consider moving timezone-aware formatting to `frontend/src/utils/time.ts`.
## Verification Plan
1. **Manual Verification**:
- Mock a response from the backend with a known UTC time.
- Check if the rendered string matches the expected local time.
2. **Automated Testing**:
- Add/update a test case in `frontend/tests/e2e/stats.spec.ts` (or a unit test in `frontend/tests/`) that simulates different timezone offsets to ensure the display is correct.
3. **Regression Check**:
- Ensure `BedtimePlot.tsx` and other time-related components are not negatively impacted by changes to shared utilities.