6 Commits

10 changed files with 249 additions and 200 deletions
+29 -28
View File
@@ -33,34 +33,35 @@ jobs:
# 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@v3
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)
if [ -z "$LATEST_TAG" ]; then if [ -z "$LATEST_TAG" ]; then
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0" VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0"
else else
# Strip the 'v' if present and get the patch version # Strip the 'v' if present and get the patch version
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3) PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3)
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 \
-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
+29 -28
View File
@@ -33,34 +33,35 @@ jobs:
# 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@v3
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)
if [ -z "$LATEST_TAG" ]; then if [ -z "$LATEST_TAG" ]; then
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0" VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0"
else else
# Strip the 'v' if present and get the patch version # Strip the 'v' if present and get the patch version
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3) PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3)
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 \
-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
+39 -8
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,27 +77,51 @@ 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.
+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(
{ {
+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()
}); });
} }
@@ -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);
@@ -106,8 +107,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
return timetable.slots.some((slot) => { return timetable.slots.some((slot) => {
if (slot.date !== date || !slot.sleeping) return false; if (slot.date !== date || !slot.sleeping) return false;
const sStart = adjustForTimezone(timeToMinutes(slot.slot_start)); const sStart = timeToMinutes(slot.slot_start);
const sEnd = adjustForTimezone(timeToMinutes(slot.slot_end)); const sEnd = timeToMinutes(slot.slot_end);
if (sEnd <= sStart) { if (sEnd <= sStart) {
const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd); const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
@@ -203,6 +204,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'}`}
/> />
); );
+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 {
@@ -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');
}); });
}); });
+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.
+18 -1
View File
@@ -3,4 +3,21 @@
- [ ] 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
- [ ] Switch over to oh-my-opencode and try it out