16 Commits

Author SHA1 Message Date
jacobh2 5534a25ce5 Update build
Build Backend / build (push) Successful in 1m55s
Build Frontend / build (push) Failing after 2m48s
2026-05-31 10:40:50 +02:00
jacobh2 f05be2a5bf Add oci labels 2026-05-31 10:05:30 +02:00
jacobh2 7061e78305 Update stats
Build Backend / build (push) Successful in 1m59s
Build Frontend / build (push) Failing after 2m55s
2026-05-31 09:23:50 +02:00
jacobh2 7d9c22d5d3 Add stats 2026-05-31 08:42:25 +02:00
jacobh2 bd8d1b1936 Update migration script to handle offset 2026-05-30 19:08:25 +02:00
jacobh2 8852cf202e Fix timezones
Build Backend / build (push) Successful in 1m54s
Build Frontend / build (push) Successful in 3m43s
2026-05-30 16:55:09 +02:00
jacobh2 91184cfc96 Partly fixing timetable 2026-05-30 16:55:09 +02:00
jacobh2 6979f1685a Update .gitea/workflows/frontend.yml 2026-05-30 10:31:08 +02:00
jacobh2 733066ff29 Update .gitea/workflows/backend.yml 2026-05-30 10:16:16 +02:00
jacobh2 940e14eef5 Update .gitea/workflows/backend.yml 2026-05-30 10:08:10 +02:00
jacobh2 44ad363fcd Update .gitea/workflows/backend.yml 2026-05-30 09:16:12 +02:00
jacobh2 92becf413d Update .gitea/workflows/backend.yml 2026-05-29 16:59:51 +02:00
jacobh2 05f71283db Update .gitea/workflows/backend.yml 2026-05-29 16:31:57 +02:00
jacobh2 0ca64b9b5d Use buildx
Build Frontend / build (push) Has been cancelled
Build Backend / build (push) Has been cancelled
2026-05-29 16:21:40 +02:00
jacobh2 7c9593448c Trigger build
Build Backend / build (push) Successful in 8m33s
Build Frontend / build (push) Successful in 18m59s
2026-05-29 11:41:18 +02:00
jacobh2 c7bc5c4e93 Trigger build
Build Backend / build (push) Failing after 35s
2026-05-29 11:10:40 +02:00
30 changed files with 600 additions and 62 deletions
+41 -5
View File
@@ -1,4 +1,7 @@
name: Build Backend
env:
MAJOR: 1
MINOR: 0
defaults:
run:
@@ -8,13 +11,27 @@ on:
push:
paths:
- "backend/**"
workflow_dispatch: {}
jobs:
build:
runs-on: host
steps:
- name: Install Git
run: apk add --no-cache git
- 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
@@ -22,9 +39,28 @@ jobs:
# Tells the action to use the local 'git' binary we just installed
set-safe-directory: 'true'
- name: Determine Version
run: |
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0"
else
# Strip the 'v' if present and get the patch version
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3)
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build \
docker buildx build \
--platform linux/arm64 \
-t babymonitor-backend:latest \
./backend
-t gitea.lan/jacobh2/babymonitor-backend:${{ env.VERSION }} \
./backend \
--push
- name: Tag and Push Version
run: |
git tag $VERSION
git push origin $VERSION
+41 -5
View File
@@ -1,4 +1,7 @@
name: Build Frontend
env:
MAJOR: 1
MINOR: 0
defaults:
run:
@@ -8,13 +11,27 @@ on:
push:
paths:
- "frontend/**"
workflow_dispatch: {}
jobs:
build:
runs-on: host
steps:
- name: Install Git
run: apk add --no-cache git
- 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
@@ -22,9 +39,28 @@ jobs:
# Tells the action to use the local 'git' binary we just installed
set-safe-directory: 'true'
- name: Determine Version
run: |
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0"
else
# Strip the 'v' if present and get the patch version
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3)
NEW_PATCH=$((PATCH + 1))
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build \
docker buildx build \
--platform linux/arm64 \
-t babymonitor-frontend:latest \
./frontend
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
./frontend \
--push
- name: Tag and Push Version
run: |
git tag $VERSION
git push origin $VERSION
+1
View File
@@ -43,6 +43,7 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet)
- **Python**: >=3.14 required, Hatch for packaging
- **Scripts**: All scripts created in the `backend` folder must be placed in `backend/scripts/`.
- **No CI/CD**: No .github/workflows, no Makefiles yet
## ANTI-PATTERNS (THIS PROJECT)
+3
View File
@@ -8,6 +8,9 @@ COPY pyproject.toml README.md ./
RUN pip install --no-cache-dir .
FROM base
LABEL org.opencontainers.image.source="http://gitea.lan/jacobh2/babymonitor"
LABEL org.opencontainers.image.description="Babymonitor Backend"
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
+3
View File
@@ -73,3 +73,6 @@ npm run test
- `backend/`: FastAPI application, models, and API routes.
- `frontend/`: React application with components and hooks.
- `docker-compose.yml`: Orchestration for the full stack.
+21
View File
@@ -111,3 +111,24 @@ async def update_session(
await session.commit()
await session.refresh(sleep_session)
return sleep_session
async def get_all_time_stats(session: AsyncSession) -> tuple[int, float]:
statement = select(func.count(), func.min(SleepSession.start_time))
result = await session.exec(statement)
total_sessions, first_session_time = result.one()
if total_sessions == 0 or first_session_time is None:
return 0, 0.0
# DB has naive datetimes
first_session_time_naive = first_session_time.replace(tzinfo=None)
# Get current date (naive for comparison)
today_date = datetime.now().date()
first_date = first_session_time_naive.date()
num_days = (today_date - first_date).days + 1
avg_sleeps = total_sessions / num_days if num_days > 0 else 0.0
return total_sessions, avg_sleeps
+28 -6
View File
@@ -1,10 +1,10 @@
from datetime import datetime, time, timedelta
from datetime import datetime, time, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import get_session
from app.crud import get_sessions
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse
from app.crud import get_sessions, get_all_time_stats
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse
from app.utils.time_utils import get_day_night_splits
router = APIRouter(prefix="/api/stats", tags=["stats"])
@@ -71,6 +71,7 @@ async def get_timetable(
adj_from_date = from_date - timedelta(days=1)
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
slots = []
now = 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")
@@ -83,9 +84,8 @@ async def get_timetable(
slot_end_str = slot_end_time.strftime("%H:%M")
is_sleeping = False
for s in sessions:
if s.start_time <= slot_end_time and (
s.end_time is None or s.end_time >= slot_start_time
):
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:
is_sleeping = True
break
slots.append(
@@ -100,6 +100,28 @@ async def get_timetable(
return {"slots": slots}
@router.get("/summary", response_model=SummaryResponse)
async def get_summary(
from_date: datetime,
to_date: datetime,
session: AsyncSession = Depends(get_session),
):
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
total_sleeps = len(sessions)
num_days = (to_date.date() - from_date.date()).days + 1
avg_sleeps_per_day = total_sleeps / num_days if num_days > 0 else 0.0
total_sleeps_all_time, avg_sleeps_per_day_all_time = await get_all_time_stats(session)
return {
"total_sleeps": total_sleeps,
"avg_sleeps_per_day": avg_sleeps_per_day,
"total_sleeps_all_time": total_sleeps_all_time,
"avg_sleeps_per_day_all_time": avg_sleeps_per_day_all_time,
}
@router.get("/wake-sleep-times", response_model=WakeupBedtimeResponse)
async def get_wake_sleep_times(
from_date: datetime,
+8
View File
@@ -57,3 +57,11 @@ class WakeupBedtimeDay(BaseModel):
class WakeupBedtimeResponse(BaseModel):
days: List[WakeupBedtimeDay]
class SummaryResponse(BaseModel):
total_sleeps: int
avg_sleeps_per_day: float
total_sleeps_all_time: int
avg_sleeps_per_day_all_time: float
+12 -2
View File
@@ -1,10 +1,11 @@
import sqlite3
import argparse
from datetime import datetime, timedelta
SOURCE_DB = 'backend/data/upload.db'
DEST_DB = 'backend/data/sleep.db'
def migrate():
def migrate(offset_minutes: int = 0):
try:
src_conn = sqlite3.connect(SOURCE_DB)
src_cur = src_conn.cursor()
@@ -13,6 +14,8 @@ def migrate():
dest_cur = dest_conn.cursor()
print(f"Connecting to {SOURCE_DB} and {DEST_DB}...")
if offset_minutes != 0:
print(f"Applying {offset_minutes} minute offset to all timestamps.")
# 1. Truncate the destination table
print("Truncating sleep_session table...")
@@ -38,6 +41,9 @@ def migrate():
# Convert Unix timestamp to datetime object
try:
start_time = datetime.fromtimestamp(timestamp_seconds)
# Apply offset
if offset_minutes != 0:
start_time += timedelta(minutes=offset_minutes)
except Exception as e:
print(f"Error converting timestamp {timestamp_seconds}: {e}")
error_count += 1
@@ -105,4 +111,8 @@ def migrate():
if 'dest_conn' in locals(): dest_conn.close()
if __name__ == "__main__":
migrate()
parser = argparse.ArgumentParser(description="Migrate sleep data from source DB to destination DB.")
parser.add_argument("--offset", type=int, default=0, help="Minute offset to apply to all timestamps.")
args = parser.parse_args()
migrate(offset_minutes=args.offset)
+68
View File
@@ -0,0 +1,68 @@
import asyncio
from datetime import datetime, timezone
from typing import AsyncGenerator
from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import get_session
from app.crud import get_sessions, get_all_time_stats
from sqlmodel import select, func
from app.models import SleepSession
async def get_session_context() -> AsyncGenerator[AsyncSession, None]:
async for session in get_session():
yield session
async def verify_stats():
# Use the async generator correctly
gen = get_session_context()
session: AsyncSession = await gen.__anext__()
try:
import datetime as dt
to_date = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=999999)
from_date = (to_date - dt.timedelta(days=6)).replace(hour=0, minute=0, second=0, microsecond=0)
print(f"Verifying range: {from_date} to {to_date}")
# 1. Verify 'in period' stats via get_sessions (as used in router)
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
total_sleeps = len(sessions)
num_days = (to_date.date() - from_date.date()).days + 1
avg_sleeps_per_day = total_sleeps / num_days if num_days > 0 else 0.0
print(f"In-period: Total={total_sleeps}, Days={num_days}, Avg={avg_sleeps_per_day}")
# 2. Verify 'all time' stats via get_all_time_stats
total_sleeps_all_time, avg_sleeps_per_day_all_time = await get_all_time_stats(session)
print(f"All-time: Total={total_sleeps_all_time}, Avg={avg_sleeps_per_day_all_time}")
# 3. Direct DB check for raw session count
statement = select(func.count()).select_from(SleepSession)
raw_total_result = await session.exec(statement)
db_total = raw_total_result.one()
print(f"DB Raw Total: {db_total}")
# 4. Direct DB check for first session to see if it matches
first_stmt = select(SleepSession.start_time).order_by(SleepSession.start_time.asc()).limit(1)
first_result = await session.exec(first_stmt)
first_time = first_result.one()
print(f"First Session in DB: {first_time}")
if total_sleeps > 0 and total_sleeps_all_time == total_sleeps:
print("INFO: All sessions are within the period.")
elif total_sleeps < total_sleeps_all_time:
print(f"INFO: Found {total_sleeps_all_time - total_sleeps} sessions outside the period.")
if avg_sleeps_per_day > avg_sleeps_per_day_all_time and total_sleeps_all_time > 0:
print("!!! ALERT: In-period average is HIGHER than All-time average. This contradicts the user's observation if all sessions are in period.")
else:
print("SUCCESS: Average calculation seems logically consistent (in-period <= all-time).")
finally:
# Cleanup the generator
try:
await gen.__anext__()
except StopAsyncIteration:
pass
if __name__ == "__main__":
asyncio.run(verify_stats())
+36 -1
View File
@@ -69,5 +69,40 @@ async def test_timetable(setup_db):
f"/api/stats/timetable?from_date={from_date}&to_date={to_date}"
)
@pytest.mark.asyncio
async def test_summary(setup_db):
from datetime import date, timedelta, timezone
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
from app.database import get_session
from app.models import SleepSession
from sqlalchemy import select
async with AsyncSession(engine) as session:
# Create two sessions, one a few days ago and one today
start1 = datetime.now(timezone.utc) - timedelta(days=5)
end1 = start1 + timedelta(hours=8)
new_s1 = SleepSession(start_time=start1, end_time=end1)
session.add(new_s1)
start2 = datetime.now(timezone.utc) - timedelta(hours=2)
end2 = datetime.now(timezone.utc)
new_s2 = SleepSession(start_time=start2, end_time=end2)
session.add(new_s2)
await session.commit()
from_date = (date.today() - timedelta(days=7)).isoformat()
to_date = date.today().isoformat()
response = await ac.get(
f"/api/stats/summary?from_date={from_date}&to_date={to_date}"
)
assert response.status_code == 200
assert len(response.json()["slots"]) > 0
data = response.json()
assert data["total_sleeps"] == 2
assert data["total_sleeps_all_time"] == 2
assert data["avg_sleeps_per_day_all_time"] > 0
+3
View File
@@ -17,6 +17,9 @@ RUN npm run build
# Stage 2: Production stage
FROM nginx:1.27-alpine
LABEL org.opencontainers.image.source="http://gitea.lan/jacobh2/babymonitor"
LABEL org.opencontainers.image.description="Babymonitor Frontend"
# Copy the built assets from the build stage to Nginx's default HTML directory
COPY --from=build /app/dist /usr/share/nginx/html
+6 -1
View File
@@ -5,7 +5,8 @@ import type {
SessionListResponse,
DailySummaryResponse,
TimetableResponse,
WakeupBedtimeResponse
WakeupBedtimeResponse,
SummaryResponse
} from '@/types';
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
@@ -39,6 +40,10 @@ async function fetchStats<T>(endpoint: string, from_date: string, to_date: strin
return response.json();
}
export async function fetchSummary(from_date: string, to_date: string): Promise<SummaryResponse> {
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 });
}
+38 -7
View File
@@ -40,6 +40,21 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
}).reverse();
}, [wakeupBedtime, bedtime_ref]);
const stats = useMemo(() => {
if (!wakeupBedtime?.days) return null;
const validMinutes = wakeupBedtime.days
.map((day) => day.bedtime_minutes)
.filter((m): m is number => m !== null);
if (validMinutes.length === 0) return null;
const min = Math.min(...validMinutes);
const max = Math.max(...validMinutes);
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
return { min, max, avg };
}, [wakeupBedtime]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
if (!chartData.length) {
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
@@ -64,13 +79,13 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
tickFormatter={(value) => formatMinutesAfter(Number(value), bedtime_ref)}
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: any) => [
formatMinutesAfter(Number(value), bedtime_ref),
'Bedtime',
]}
labelFormatter={(label) => label}
/>
<Tooltip
formatter={(value: number) => [
formatMinutesAfter(value, bedtime_ref),
'Bedtime',
]}
labelFormatter={(label) => label}
/>
<Line
type="monotone"
dataKey="minutes"
@@ -81,6 +96,22 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
/>
</LineChart>
</ResponsiveContainer>
{stats && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-2 text-center">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, bedtime_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Latest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.max, bedtime_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), bedtime_ref)}</p>
</div>
</div>
)}
</div>
);
};
@@ -1,6 +1,6 @@
import { useMemo, useState, useEffect, Fragment } from 'react';
import { useStats } from '../../hooks/useStats';
import { timeToMinutes } from '../../utils/time';
import { timeToMinutes, adjustForTimezone } from '../../utils/time';
interface TimetableProps {
from_date: string;
@@ -106,8 +106,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 = timeToMinutes(slot.slot_start);
const sEnd = timeToMinutes(slot.slot_end);
const sStart = adjustForTimezone(timeToMinutes(slot.slot_start));
const sEnd = adjustForTimezone(timeToMinutes(slot.slot_end));
if (sEnd <= sStart) {
const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
+7 -2
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { IntervalPicker } from './IntervalPicker';
import { SettingsPopover } from './SettingsPopover';
import SummaryCards from './SummaryCards';
import DailySummary from './DailySummary';
import Timetable from './SleepTimetable';
import WakeupPlot from './WakeupPlot';
@@ -8,6 +9,7 @@ import BedtimePlot from './BedtimePlot';
import { SessionList } from './SessionList';
import { ErrorBoundary } from '../../components/ErrorBoundary';
import { useSettings } from '../../hooks/useSettings';
import { useSummary } from '../../hooks/useStats';
const StatsPage: React.FC = () => {
const { settings, update } = useSettings();
@@ -16,6 +18,7 @@ const StatsPage: React.FC = () => {
const to_date = now.toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
const from_date = new Date(now.setDate(now.getDate() - settings.statsInterval)).toLocaleDateString('en-CA');
const { data: summary } = useSummary(from_date, to_date);
return (
<div className="max-w-4xl mx-auto space-y-8 pb-12">
@@ -36,7 +39,7 @@ const StatsPage: React.FC = () => {
update('bedtimeRef', s.bedtimeRef);
}}
/>
</div >
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
<IntervalPicker
@@ -45,6 +48,8 @@ const StatsPage: React.FC = () => {
/>
</div>
<SummaryCards summary={summary} />
<ErrorBoundary>
<DailySummary
from_date={from_date}
@@ -71,7 +76,7 @@ const StatsPage: React.FC = () => {
bedtime_ref={settings.bedtimeRef}
/>
</ErrorBoundary>
</div >
</div>
<ErrorBoundary>
<Timetable
@@ -0,0 +1,59 @@
import React from 'react';
import { Moon, Calendar } from 'lucide-react';
import type { SummaryResponse } from '@/types';
interface SummaryCardsProps {
summary?: SummaryResponse;
}
const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
if (!summary) return null;
return (
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
<div className="grid grid-cols-2 gap-6">
<div className="flex items-center space-x-4">
<div className="p-3 bg-indigo-50 rounded-2xl text-indigo-600">
<Moon size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Total sleeps in period</p>
<p className="text-lg font-bold text-gray-900">{summary.total_sleeps}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="p-3 bg-amber-50 rounded-2xl text-amber-600">
<Calendar size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Avg sleeps per day in period</p>
<p className="text-lg font-bold text-gray-900">{summary.avg_sleeps_per_day.toFixed(1)}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="p-3 bg-indigo-100 rounded-2xl text-indigo-700">
<Moon size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Total sleeps (all time)</p>
<p className="text-lg font-bold text-gray-900">{summary.total_sleeps_all_time}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="p-3 bg-amber-100 rounded-2xl text-amber-700">
<Calendar size={24} />
</div>
<div>
<p className="text-xs font-medium text-gray-500">Avg sleeps per day (all time)</p>
<p className="text-lg font-bold text-gray-900">{summary.avg_sleeps_per_day_all_time.toFixed(1)}</p>
</div>
</div>
</div>
</div>
);
};
export default SummaryCards;
+34 -3
View File
@@ -42,6 +42,21 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
}).reverse();
}, [wakeupBedtime, wakeup_ref]);
const stats = useMemo(() => {
if (!wakeupBedtime?.days) return null;
const validMinutes = wakeupBedtime.days
.map((day) => day.wakeup_minutes)
.filter((m): m is number => m !== null);
if (validMinutes.length === 0) return null;
const min = Math.min(...validMinutes);
const max = Math.max(...validMinutes);
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
return { min, max, avg };
}, [wakeupBedtime]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
if (!chartData.length) {
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
@@ -67,8 +82,8 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
domain={['dataMin - 30', 'dataMax + 30']}
/>
<Tooltip
formatter={(value: any) => [
formatMinutesAfter(Number(value), wakeup_ref),
formatter={(value: number) => [
formatMinutesAfter(value, wakeup_ref),
'Wakeup',
]}
labelFormatter={(label) => label}
@@ -83,7 +98,23 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
/>
</LineChart>
</ResponsiveContainer>
</div >
{stats && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-2 text-center">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, wakeup_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Latest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.max, wakeup_ref)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), wakeup_ref)}</p>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,123 @@
import React, { useState, useRef } from 'react';
interface ClockPickerProps {
hour: number;
minute: number;
onChange: (hour: number, minute: number) => void;
}
interface NumberDialPickerProps {
mode: 'hour' | 'minute';
value: number;
onChange: (val: number) => void;
onModeChange?: (mode: 'hour' | 'minute') => void;
}
const NumberDialPicker: React.FC<NumberDialPickerProps> = ({ mode, value, onChange, onModeChange }) => {
const containerRef = useRef<HTMLDivElement>(null);
const totalItems = mode === 'hour' ? 24 : 60;
const items = Array.from({ length: totalItems }, (_, i) => i);
const handleDialClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const x = e.clientX - centerX;
const y = e.clientY - centerY;
let angle = Math.atan2(y, x) * (180 / Math.PI) + 90;
if (angle < 0) angle += 360;
const step = 360 / totalItems;
// Use a small epsilon or rounding to ensure clicking near boundaries works well
const selectedIndex = Math.round(angle / step) % totalItems;
onChange(selectedIndex);
if (mode === 'hour' && onModeChange) {
setTimeout(() => onModeChange('minute'), 150);
}
};
return (
<div className="flex flex-col items-center w-full">
<div className="flex bg-gray-100 p-1 rounded-full w-full max-w-[280px] mb-6">
<button
onClick={() => onModeChange?.('hour')}
className={`flex-1 py-2 text-sm font-medium rounded-full transition-all ${
mode === 'hour' ? 'bg-white shadow-sm text-indigo-600' : 'text-gray-500'
}`}
>
Hour
</button>
<button
onClick={() => onModeChange?.('minute')}
className={`flex-1 py-2 text-sm font-medium rounded-full transition-all ${
mode === 'minute' ? 'bg-white shadow-sm text-indigo-600' : 'text-gray-500'
}`}
>
Minute
</button>
</div >
<div
ref={containerRef}
onClick={handleDialClick}
className="relative w-64 h-64 rounded-full bg-gray-50 border-4 border-white shadow-inner cursor-pointer touch-none select-none overflow-hidden"
style={{ backgroundImage: 'radial-gradient(circle, #f3f4f6 1px, transparent 1px)', backgroundSize: '20px 20px' }}
>
<div className="absolute top-1/2 left-1/2 w-1 h-1 bg-indigo-600 -translate-x-1/2 -translate-y-1/2 z-30 opacity-50" />
{items.map((item) => {
const angle = (item * (360 / totalItems)) - 90;
return (
<div
key={item}
className={`absolute top-1/2 left-1/2 text-sm font-bold transition-all duration-200
${item === value ? 'text-indigo-600 scale-125' : 'text-gray-400'}
`}
style={{
transform: `translate(-50%, -50%) rotate(${angle}deg) translateY(-108px) rotate(${-angle}deg)`
}}
>
{item.toString().padStart(2, '0')}
</div >
);
})}
<div className="absolute top-1/2 left-1/2 w-12 h-12 bg-indigo-50 rounded-full -translate-x-1/2 -translate-y-1/2 z-0" />
</div >
</div >
);
};
const ClockPicker: React.FC<ClockPickerProps> = ({ hour, minute, onChange }) => {
const [mode, setMode] = useState<'hour' | 'minute'>('hour');
const handleTimeChange = (newHour: number, newMinute: number) => {
onChange(newHour, newMinute);
};
const handleNumberChange = (val: number) => {
if (mode === 'hour') {
handleTimeChange(val, minute);
} else {
handleTimeChange(hour, val);
}
};
return (
<NumberDialPicker
mode={mode}
value={mode === 'hour' ? hour : minute}
onChange={handleNumberChange}
onModeChange={setMode}
/>
);
};
export default ClockPicker;
+9 -2
View File
@@ -1,6 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client';
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse } from '../types';
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes, fetchSummary } from '../api/client';
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse } from '../types';
export function useSummary(from_date: string, to_date: string) {
return useQuery<SummaryResponse>({
queryKey: ['summary', from_date, to_date],
queryFn: () => fetchSummary(from_date, to_date),
});
}
export function useStats(
from_date: string,
+7
View File
@@ -1,3 +1,10 @@
export interface SummaryResponse {
total_sleeps: number;
avg_sleeps_per_day: number;
total_sleeps_all_time: number;
avg_sleeps_per_day_all_time: number;
}
export interface SleepSession {
id: number;
start_time: string;
+5 -1
View File
@@ -68,7 +68,11 @@ export function formatElapsedTime(utcTimestamp: string): string {
* @returns formatted datetime string
*/
export function formatDateTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
let dateToParse = date;
if (typeof date === 'string' && !date.endsWith('Z') && !date.includes('+')) {
dateToParse = date + 'Z';
}
const d = typeof dateToParse === 'string' ? new Date(dateToParse) : dateToParse;
return d.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
const BASE_URL = 'http://localhost:5173';
test('visual inspection of TimeAdjuster', async ({ page }) => {
await page.goto(`${BASE_URL}/playground`);
await page.waitForLoadState('networkidle');
// Select 'TimeAdjuster'
const select = page.locator('select#component-select');
await select.selectOption({ label: 'TimeAdjuster' });
// Wait for component to render
const mainArea = page.getByRole('main').last();
await expect(mainArea).toBeVisible();
// Take a clean, high-resolution screenshot of the component
const componentContainer = mainArea.locator('div').first(); // The wrapper in playground
await componentContainer.screenshot({ path: 'time_adjuster_component_clean.png' });
});
@@ -62,8 +62,8 @@ describe('SleepTimetable', () => {
fireEvent(window, new Event('resize'));
// Check for 3-day interval tabs
// In reversed order (25, 24, 23...), the first group is Fri - Wed
expect(screen.getByRole('button', { name: /Fri \d+ - Wed \d+/i })).toBeInTheDocument();
// 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)', () => {
+16
View File
@@ -11,6 +11,7 @@ import {
timeToMinutes,
adjustForTimezone,
getLocalOffsetFromReference,
formatDateTime,
} from '../../src/utils/time';
describe('formatMinutesAfter', () => {
@@ -125,6 +126,21 @@ describe('adjustForTimezone', () => {
});
});
describe('formatDateTime', () => {
it('treats string without Z as UTC', () => {
const dateStr = '2024-01-15T10:00:00';
const dateWithZ = '2024-01-15T10:00:00Z';
expect(formatDateTime(dateStr)).toBe(formatDateTime(dateWithZ));
});
it('formats a Date object correctly', () => {
const date = new Date('2024-01-15T10:00:00Z');
// The output depends on the local timezone, so we check for part of it
const result = formatDateTime(date);
expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
});
});
describe('getLocalOffsetFromReference', () => {
it('returns local offset from reference in minutes', () => {
const utcMins = 600; // 10:00 UTC
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

-22
View File
@@ -1,22 +0,0 @@
# Sleep Timetable Bug Fixes
## Identified Issues
1. **Pre-selection**: Component doesn't always pre-select the latest date across all tiers.
2. **Grid Layout (Medium Tier)**: Grid columns are hardcoded to 3, causing breakage when fewer than 3 days are displayed at the end of a range.
3. **Selector Information**: Tabs only show weekday names, making it hard to distinguish dates.
## Implementation Plan
### 1. Fix Pre-selection
- Add a `useEffect` to set `selectedDayIndex` to the last index of the `days` array whenever the `days` list is initialized or updated. This ensures "today" (the latest date) is selected by default.
### 2. Fix Grid Alignment
- Refactor the `gridTemplateColumns` logic for both the header and time grid.
- Instead of hardcoding tiers (e.g., `repeat(3, 1fr)`), use the actual number of days in the current view: `4rem repeat(${visibleDays.length}, 1fr)`. This allows the grid to gracefully handle 1, 2, or 3 columns.
### 3. Enhance Selector Labels
- Implement a `formatDayLabel` helper that combines the short weekday name and the day of the month (e.g., "FRI 24").
- Update `tabGroups` generation for both 'medium' (range) and 'small' (single day) tiers to use this descriptive label.
### 4. UI Compactness
- Adjust spacing (margins/paddings) in the mobile/medium view to reduce overall component height and keep selectors closer to the grid.
+6
View File
@@ -0,0 +1,6 @@
# Things I need to tell my AI to do
- [ ] 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)