Compare commits
7 Commits
6979f1685a
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5534a25ce5 | |||
| f05be2a5bf | |||
| 7061e78305 | |||
| 7d9c22d5d3 | |||
| bd8d1b1936 | |||
| 8852cf202e | |||
| 91184cfc96 |
@@ -1,4 +1,7 @@
|
|||||||
name: Build Backend
|
name: Build Backend
|
||||||
|
env:
|
||||||
|
MAJOR: 1
|
||||||
|
MINOR: 0
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -36,10 +39,28 @@ jobs:
|
|||||||
# 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'
|
||||||
|
|
||||||
|
- 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
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
-t gitea.lan/jacobh2/babymonitor:backend-latest \
|
-t gitea.lan/jacobh2/babymonitor-backend:${{ env.VERSION }} \
|
||||||
./backend \
|
./backend \
|
||||||
--push
|
--push
|
||||||
|
|
||||||
|
- name: Tag and Push Version
|
||||||
|
run: |
|
||||||
|
git tag $VERSION
|
||||||
|
git push origin $VERSION
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
name: Build Frontend
|
name: Build Frontend
|
||||||
|
env:
|
||||||
|
MAJOR: 1
|
||||||
|
MINOR: 0
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -36,10 +39,28 @@ jobs:
|
|||||||
# 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'
|
||||||
|
|
||||||
|
- 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
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
-t gitea.lan/jacobh2/babymonitor:frontend-latest \
|
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
|
||||||
./frontend \
|
./frontend \
|
||||||
--push
|
--push
|
||||||
|
|
||||||
|
- name: Tag and Push Version
|
||||||
|
run: |
|
||||||
|
git tag $VERSION
|
||||||
|
git push origin $VERSION
|
||||||
|
|||||||
@@ -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
|
- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio
|
||||||
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet)
|
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet)
|
||||||
- **Python**: >=3.14 required, Hatch for packaging
|
- **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
|
- **No CI/CD**: No .github/workflows, no Makefiles yet
|
||||||
|
|
||||||
## ANTI-PATTERNS (THIS PROJECT)
|
## ANTI-PATTERNS (THIS PROJECT)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ COPY pyproject.toml README.md ./
|
|||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
FROM base
|
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 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/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
|
||||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|||||||
@@ -111,3 +111,24 @@ async def update_session(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(sleep_session)
|
await session.refresh(sleep_session)
|
||||||
return 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
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.crud import get_sessions
|
from app.crud import get_sessions, get_all_time_stats
|
||||||
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse
|
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse
|
||||||
from app.utils.time_utils import get_day_night_splits
|
from app.utils.time_utils import get_day_night_splits
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||||
@@ -71,6 +71,7 @@ async def get_timetable(
|
|||||||
adj_from_date = from_date - timedelta(days=1)
|
adj_from_date = from_date - timedelta(days=1)
|
||||||
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
|
sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date)
|
||||||
slots = []
|
slots = []
|
||||||
|
now = 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")
|
||||||
@@ -83,9 +84,8 @@ async def get_timetable(
|
|||||||
slot_end_str = slot_end_time.strftime("%H:%M")
|
slot_end_str = slot_end_time.strftime("%H:%M")
|
||||||
is_sleeping = False
|
is_sleeping = False
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
if s.start_time <= slot_end_time and (
|
effective_end_time = s.end_time if s.end_time is not None else now
|
||||||
s.end_time is None or s.end_time >= slot_start_time
|
if s.start_time <= slot_end_time and effective_end_time >= slot_start_time:
|
||||||
):
|
|
||||||
is_sleeping = True
|
is_sleeping = True
|
||||||
break
|
break
|
||||||
slots.append(
|
slots.append(
|
||||||
@@ -100,6 +100,28 @@ async def get_timetable(
|
|||||||
return {"slots": slots}
|
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)
|
@router.get("/wake-sleep-times", response_model=WakeupBedtimeResponse)
|
||||||
async def get_wake_sleep_times(
|
async def get_wake_sleep_times(
|
||||||
from_date: datetime,
|
from_date: datetime,
|
||||||
|
|||||||
@@ -57,3 +57,11 @@ class WakeupBedtimeDay(BaseModel):
|
|||||||
|
|
||||||
class WakeupBedtimeResponse(BaseModel):
|
class WakeupBedtimeResponse(BaseModel):
|
||||||
days: List[WakeupBedtimeDay]
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
import argparse
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
SOURCE_DB = 'backend/data/upload.db'
|
SOURCE_DB = 'backend/data/upload.db'
|
||||||
DEST_DB = 'backend/data/sleep.db'
|
DEST_DB = 'backend/data/sleep.db'
|
||||||
|
|
||||||
def migrate():
|
def migrate(offset_minutes: int = 0):
|
||||||
try:
|
try:
|
||||||
src_conn = sqlite3.connect(SOURCE_DB)
|
src_conn = sqlite3.connect(SOURCE_DB)
|
||||||
src_cur = src_conn.cursor()
|
src_cur = src_conn.cursor()
|
||||||
@@ -13,6 +14,8 @@ def migrate():
|
|||||||
dest_cur = dest_conn.cursor()
|
dest_cur = dest_conn.cursor()
|
||||||
|
|
||||||
print(f"Connecting to {SOURCE_DB} and {DEST_DB}...")
|
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
|
# 1. Truncate the destination table
|
||||||
print("Truncating sleep_session table...")
|
print("Truncating sleep_session table...")
|
||||||
@@ -38,6 +41,9 @@ def migrate():
|
|||||||
# Convert Unix timestamp to datetime object
|
# Convert Unix timestamp to datetime object
|
||||||
try:
|
try:
|
||||||
start_time = datetime.fromtimestamp(timestamp_seconds)
|
start_time = datetime.fromtimestamp(timestamp_seconds)
|
||||||
|
# Apply offset
|
||||||
|
if offset_minutes != 0:
|
||||||
|
start_time += timedelta(minutes=offset_minutes)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error converting timestamp {timestamp_seconds}: {e}")
|
print(f"Error converting timestamp {timestamp_seconds}: {e}")
|
||||||
error_count += 1
|
error_count += 1
|
||||||
@@ -105,4 +111,8 @@ def migrate():
|
|||||||
if 'dest_conn' in locals(): dest_conn.close()
|
if 'dest_conn' in locals(): dest_conn.close()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -69,5 +69,40 @@ async def test_timetable(setup_db):
|
|||||||
f"/api/stats/timetable?from_date={from_date}&to_date={to_date}"
|
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 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
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ RUN npm run build
|
|||||||
# Stage 2: Production stage
|
# Stage 2: Production stage
|
||||||
FROM nginx:1.27-alpine
|
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 the built assets from the build stage to Nginx's default HTML directory
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type {
|
|||||||
SessionListResponse,
|
SessionListResponse,
|
||||||
DailySummaryResponse,
|
DailySummaryResponse,
|
||||||
TimetableResponse,
|
TimetableResponse,
|
||||||
WakeupBedtimeResponse
|
WakeupBedtimeResponse,
|
||||||
|
SummaryResponse
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
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();
|
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> {
|
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 });
|
return fetchStats('daily-summary', from_date, to_date, { day_start, day_end });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
|||||||
}).reverse();
|
}).reverse();
|
||||||
}, [wakeupBedtime, bedtime_ref]);
|
}, [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 (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
||||||
if (!chartData.length) {
|
if (!chartData.length) {
|
||||||
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
||||||
@@ -65,8 +80,8 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
|||||||
domain={['dataMin - 30', 'dataMax + 30']}
|
domain={['dataMin - 30', 'dataMax + 30']}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: any) => [
|
formatter={(value: number) => [
|
||||||
formatMinutesAfter(Number(value), bedtime_ref),
|
formatMinutesAfter(value, bedtime_ref),
|
||||||
'Bedtime',
|
'Bedtime',
|
||||||
]}
|
]}
|
||||||
labelFormatter={(label) => label}
|
labelFormatter={(label) => label}
|
||||||
@@ -81,6 +96,22 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
|||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } from '../../utils/time';
|
import { timeToMinutes, adjustForTimezone } from '../../utils/time';
|
||||||
|
|
||||||
interface TimetableProps {
|
interface TimetableProps {
|
||||||
from_date: string;
|
from_date: string;
|
||||||
@@ -106,8 +106,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 = timeToMinutes(slot.slot_start);
|
const sStart = adjustForTimezone(timeToMinutes(slot.slot_start));
|
||||||
const sEnd = timeToMinutes(slot.slot_end);
|
const sEnd = adjustForTimezone(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);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IntervalPicker } from './IntervalPicker';
|
import { IntervalPicker } from './IntervalPicker';
|
||||||
import { SettingsPopover } from './SettingsPopover';
|
import { SettingsPopover } from './SettingsPopover';
|
||||||
|
import SummaryCards from './SummaryCards';
|
||||||
import DailySummary from './DailySummary';
|
import DailySummary from './DailySummary';
|
||||||
import Timetable from './SleepTimetable';
|
import Timetable from './SleepTimetable';
|
||||||
import WakeupPlot from './WakeupPlot';
|
import WakeupPlot from './WakeupPlot';
|
||||||
@@ -8,6 +9,7 @@ import BedtimePlot from './BedtimePlot';
|
|||||||
import { SessionList } from './SessionList';
|
import { SessionList } from './SessionList';
|
||||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||||
import { useSettings } from '../../hooks/useSettings';
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
|
import { useSummary } from '../../hooks/useStats';
|
||||||
|
|
||||||
const StatsPage: React.FC = () => {
|
const StatsPage: React.FC = () => {
|
||||||
const { settings, update } = useSettings();
|
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 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 from_date = new Date(now.setDate(now.getDate() - settings.statsInterval)).toLocaleDateString('en-CA');
|
||||||
|
|
||||||
|
const { data: summary } = useSummary(from_date, to_date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
||||||
@@ -45,6 +48,8 @@ const StatsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SummaryCards summary={summary} />
|
||||||
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<DailySummary
|
<DailySummary
|
||||||
from_date={from_date}
|
from_date={from_date}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -42,6 +42,21 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
|||||||
}).reverse();
|
}).reverse();
|
||||||
}, [wakeupBedtime, wakeup_ref]);
|
}, [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 (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
||||||
if (!chartData.length) {
|
if (!chartData.length) {
|
||||||
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
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']}
|
domain={['dataMin - 30', 'dataMax + 30']}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: any) => [
|
formatter={(value: number) => [
|
||||||
formatMinutesAfter(Number(value), wakeup_ref),
|
formatMinutesAfter(value, wakeup_ref),
|
||||||
'Wakeup',
|
'Wakeup',
|
||||||
]}
|
]}
|
||||||
labelFormatter={(label) => label}
|
labelFormatter={(label) => label}
|
||||||
@@ -83,6 +98,22 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
|||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</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, 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>
|
</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;
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client';
|
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes, fetchSummary } from '../api/client';
|
||||||
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse } from '../types';
|
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(
|
export function useStats(
|
||||||
from_date: string,
|
from_date: string,
|
||||||
|
|||||||
@@ -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 {
|
export interface SleepSession {
|
||||||
id: number;
|
id: number;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ export function formatElapsedTime(utcTimestamp: string): string {
|
|||||||
* @returns formatted datetime string
|
* @returns formatted datetime string
|
||||||
*/
|
*/
|
||||||
export function formatDateTime(date: Date | string): 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', {
|
return d.toLocaleString('sv-SE', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
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'));
|
fireEvent(window, new Event('resize'));
|
||||||
|
|
||||||
// Check for 3-day interval tabs
|
// Check for 3-day interval tabs
|
||||||
// In reversed order (25, 24, 23...), the first group is Fri - Wed
|
// In reversed order (25, 24, 23...), the first group is Wed - Fri
|
||||||
expect(screen.getByRole('button', { name: /Fri \d+ - Wed \d+/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Wed \d+ - Fri \d+/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders in small mode (single-day tabs and 1-column grid)', () => {
|
it('renders in small mode (single-day tabs and 1-column grid)', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
timeToMinutes,
|
timeToMinutes,
|
||||||
adjustForTimezone,
|
adjustForTimezone,
|
||||||
getLocalOffsetFromReference,
|
getLocalOffsetFromReference,
|
||||||
|
formatDateTime,
|
||||||
} from '../../src/utils/time';
|
} from '../../src/utils/time';
|
||||||
|
|
||||||
describe('formatMinutesAfter', () => {
|
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', () => {
|
describe('getLocalOffsetFromReference', () => {
|
||||||
it('returns local offset from reference in minutes', () => {
|
it('returns local offset from reference in minutes', () => {
|
||||||
const utcMins = 600; // 10:00 UTC
|
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 |
@@ -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.
|
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user