From b361fc5172540e1df2a095407484d33030ad9ed4 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 20 May 2026 17:30:13 +0200 Subject: [PATCH] Working on fixing sleep timetable & add gitea workflows --- .gitea/workflows/backend.yml | 20 ++++ .gitea/workflows/frontend.yml | 20 ++++ backend/app/routers/stats.py | 8 +- frontend/src/api/client.ts | 92 ++--------------- .../src/components/stats/SleepTimetable.js | 61 ------------ .../components/stats/SleepTimetable.test.tsx | 10 +- .../src/components/stats/SleepTimetable.tsx | 37 +++---- frontend/src/components/stats/StatsPage.tsx | 6 +- frontend/tests/e2e/timetable_timezone.spec.ts | 99 +++++++++++++++++++ 9 files changed, 182 insertions(+), 171 deletions(-) create mode 100644 .gitea/workflows/backend.yml create mode 100644 .gitea/workflows/frontend.yml delete mode 100644 frontend/src/components/stats/SleepTimetable.js create mode 100644 frontend/tests/e2e/timetable_timezone.spec.ts diff --git a/.gitea/workflows/backend.yml b/.gitea/workflows/backend.yml new file mode 100644 index 0000000..4a83969 --- /dev/null +++ b/.gitea/workflows/backend.yml @@ -0,0 +1,20 @@ +name: Build Backend + +on: + push: + paths: + - "backend/**" + +jobs: + build: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Build Docker image + run: | + docker build \ + --platform linux/arm64 \ + -t babymonitor-backend:latest \ + ./backend diff --git a/.gitea/workflows/frontend.yml b/.gitea/workflows/frontend.yml new file mode 100644 index 0000000..c4f0cbd --- /dev/null +++ b/.gitea/workflows/frontend.yml @@ -0,0 +1,20 @@ +name: Build Frontend + +on: + push: + paths: + - "frontend/**" + +jobs: + build: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Build Docker image + run: | + docker build \ + --platform linux/arm64 \ + -t babymonitor-frontend:latest \ + ./frontend diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index f7b6b99..5d4e0b4 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -24,7 +24,8 @@ async def get_daily_summary( except ValueError: return {"days": []} - sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date) + adj_from_date = from_date - timedelta(days=1) + sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date) summary_map = {} for s in sessions: @@ -34,6 +35,8 @@ async def get_daily_summary( s.start_time, s.end_time, day_start_time, day_end_time ) for seg in segments: + if not (from_date.date() <= seg["start"].date() <= to_date.date()): + continue date_str = seg["start"].strftime("%Y-%m-%d") if date_str not in summary_map: summary_map[date_str] = {"day": 0.0, "night": 0.0} @@ -65,7 +68,8 @@ async def get_timetable( day_end: str = Query("20:00"), session: AsyncSession = Depends(get_session), ): - sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date) + adj_from_date = from_date - timedelta(days=1) + sessions, _ = await get_sessions(session, from_date=adj_from_date, to_date=to_date) slots = [] current_date = from_date.date() while current_date <= to_date.date(): diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 840d120..5b04148 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,98 +3,26 @@ import type { SleepSessionCreate, SleepSessionUpdate, SleepSessionRead, SessionL const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; export function localToUTC(localValue: string): string { - return localValue; + return new Date(localValue).toISOString(); } -export async function fetchActiveSession(): Promise { - const response = await fetch(`${BASE_URL}/api/sessions/active`); - if (!response.ok) throw new Error('Failed to fetch active session'); - const data = await response.json(); - return data.session; -} - -export async function fetchSessions(from_date?: string, to_date?: string, limit: number = 200): Promise { - const params = new URLSearchParams(); - if (from_date) params.append('from_date', from_date); - if (to_date) params.append('to_date', to_date); - if (limit) params.append('limit', limit.toString()); - - const response = await fetch(`${BASE_URL}/api/sessions?${params.toString()}`); - if (!response.ok) throw new Error('Failed to fetch sessions'); +async function fetchStats(endpoint: string, from_date: string, to_date: string, extraParams: Record = {}): Promise { + const fromDT = new Date(`${from_date}T00:00:00`).toISOString(); + const toDT = new Date(`${to_date}T23:59:59`).toISOString(); + const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, ...extraParams }); + const response = await fetch(`${BASE_URL}/api/stats/${endpoint}?${params.toString()}`); + if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`); return response.json(); } -export async function createSession(payload: SleepSessionCreate): Promise { - const utcTime = localToUTC(payload.start_time); - const response = await fetch(`${BASE_URL}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ start_time: utcTime }), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Failed to create session'); - } - return response.json(); -} - -export async function endSession(sessionId: number, payload: SleepSessionUpdate): Promise { - const utcTime = payload.end_time ? localToUTC(payload.end_time) : undefined; - const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}/end`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ end_time: utcTime }), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Failed to end session'); - } - return response.json(); -} - -export async function updateSession(sessionId: number, payload: SleepSessionUpdate): Promise { - const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Failed to update session'); - } - return response.json(); -} - -export async function deleteSession(sessionId: number): Promise { - const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { - method: 'DELETE', - }); - if (!response.ok) throw new Error('Failed to delete session'); -} - export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string): Promise { - const fromDT = from_date + 'T00:00:00'; - const toDT = to_date + 'T23:59:59'; - const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end }); - const response = await fetch(`${BASE_URL}/api/stats/daily-summary?${params.toString()}`); - if (!response.ok) throw new Error('Failed to fetch daily summary'); - return response.json(); + return fetchStats('daily-summary', from_date, to_date, { day_start, day_end }); } export async function fetchTimetable(from_date: string, to_date: string, day_start: string, day_end: string): Promise { - const fromDT = from_date + 'T00:00:00'; - const toDT = to_date + 'T23:59:59'; - const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end }); - const response = await fetch(`${BASE_URL}/api/stats/timetable?${params.toString()}`); - if (!response.ok) throw new Error('Failed to fetch timetable'); - return response.json(); + return fetchStats('timetable', from_date, to_date, { day_start, day_end }); } export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise { - const fromDT = from_date + 'T00:00:00'; - const toDT = to_date + 'T23:59:59'; - const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, wakeup_ref, bedtime_ref }); - const response = await fetch(`${BASE_URL}/api/stats/wake-sleep-times?${params.toString()}`); - if (!response.ok) throw new Error('Failed to fetch wake/sleep times'); - return response.json(); + return fetchStats('wake-sleep-times', from_date, to_date, { wakeup_ref, bedtime_ref }); } diff --git a/frontend/src/components/stats/SleepTimetable.js b/frontend/src/components/stats/SleepTimetable.js deleted file mode 100644 index f944c74..0000000 --- a/frontend/src/components/stats/SleepTimetable.js +++ /dev/null @@ -1,61 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import React, { useMemo } from 'react'; -import { useStats } from '../../hooks/useStats'; -import { timeToMinutes } from '../../utils/time'; -const Timetable = ({ from_date, to_date, day_start, day_end }) => { - const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end); - const days = useMemo(() => { - const start = new Date(from_date); - const end = new Date(to_date); - const result = []; - let current = new Date(start); - while (current <= end) { - result.push(current.toISOString().split('T')[0]); - current.setDate(current.getDate() + 1); - } - return result; - }, [from_date, to_date]); - const timeSteps = useMemo(() => { - return Array.from({ length: 24 }, (_, i) => i * 60); - }, []); - if (isLoading) - return _jsx("div", { className: "text-center p-4", children: "Loading timetable..." }); - if (!timetable?.slots || timetable.slots.length === 0) { - return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." }); - } - const getDayName = (dateStr) => { - return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' }); - }; - const formatTimeLabel = (mins) => { - const hours = Math.floor(mins / 60); - const minutes = mins % 60; - return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; - }; - const isSleepingInInterval = (date, intervalStart, intervalEnd) => { - return timetable.slots.some((slot) => { - if (slot.date !== date || !slot.sleeping) - return false; - let sStart = timeToMinutes(slot.slot_start); - let sEnd = timeToMinutes(slot.slot_end); - if (sEnd <= sStart) { - const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd); - const overlap2 = Math.max(0, intervalStart) < Math.min(sEnd, intervalEnd); - return overlap1 || overlap2; - } - return Math.max(sStart, intervalStart) < Math.min(sEnd, intervalEnd); - }); - }; - const currentMins = new Date().getHours() * 60 + new Date().getMinutes(); - return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Sleep Timetable" }), _jsxs("div", { className: "min-w-[600px] flex flex-col gap-px", children: [_jsxs("div", { className: "grid", style: { - gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))` - }, children: [_jsx("div", { className: "h-8" }), " ", days.map((day) => (_jsx("div", { className: "text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8", children: getDayName(day) }, day)))] }), _jsx("div", { className: "relative", children: _jsxs("div", { className: "grid", style: { - gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))` - }, children: [timeSteps.map((step) => (_jsxs(React.Fragment, { children: [_jsx("div", { className: "text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8", children: step % 120 === 0 ? formatTimeLabel(step) : '' }), days.map((day) => { - const sleeping = isSleepingInInterval(day, step, step + 60); - return (_jsx("div", { className: `h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}` }, `${day}-${step}`)); - })] }, step))), _jsx("div", { className: "absolute left-[4rem] right-0 h-0.5 bg-red-500 pointer-events-none z-10", style: { - top: `${(currentMins / 1440) * 100}%`, - transform: 'translateY(-50%)' - } })] }) })] })] })); -}; -export default Timetable; diff --git a/frontend/src/components/stats/SleepTimetable.test.tsx b/frontend/src/components/stats/SleepTimetable.test.tsx index c18bbf0..c9f5dda 100644 --- a/frontend/src/components/stats/SleepTimetable.test.tsx +++ b/frontend/src/components/stats/SleepTimetable.test.tsx @@ -49,16 +49,14 @@ describe('SleepTimetable', () => { expect(screen.getByText(/SUN/i)).toBeInTheDocument(); // 2025-04-20 is Sunday expect(screen.getByText(/MON/i)).toBeInTheDocument(); // 2025-04-21 is Monday - // Check for time labels (every 2 hours: 00, 02, 04...) + // Check for time labels (every 1 hour: 00, 01, 02...) expect(screen.getByText('00:00')).toBeInTheDocument(); + expect(screen.getByText('01:00')).toBeInTheDocument(); expect(screen.getByText('02:00')).toBeInTheDocument(); + expect(screen.getByText('03:00')).toBeInTheDocument(); expect(screen.getByText('04:00')).toBeInTheDocument(); expect(screen.getByText('22:00')).toBeInTheDocument(); - - // Verify non-labeled hours (every hour that isn't %120 == 0) - // We can't easily check for empty text nodes in Vitest/RTL without more precision - // but we can check that 01:00 is NOT present as a text label - expect(screen.queryByText('01:00')).not.toBeInTheDocument(); + expect(screen.getByText('23:00')).toBeInTheDocument(); }); it('colors sleeping intervals correctly', () => { diff --git a/frontend/src/components/stats/SleepTimetable.tsx b/frontend/src/components/stats/SleepTimetable.tsx index 9088e00..8a02ea3 100644 --- a/frontend/src/components/stats/SleepTimetable.tsx +++ b/frontend/src/components/stats/SleepTimetable.tsx @@ -13,12 +13,12 @@ const Timetable: React.FC = ({ from_date, to_date, day_start, da const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end); const days = useMemo(() => { - const start = new Date(from_date); - const end = new Date(to_date); + const start = new Date(from_date + 'T00:00:00'); + const end = new Date(to_date + 'T23:59:59'); const result: string[] = []; let current = new Date(start); while (current <= end) { - result.push(current.toISOString().split('T')[0]); + result.push(current.toLocaleDateString('en-CA')); current.setDate(current.getDate() + 1); } return result; @@ -51,6 +51,7 @@ const Timetable: React.FC = ({ from_date, to_date, day_start, da let sEnd = timeToMinutes(slot.slot_end); if (sEnd <= sStart) { + // Handle wrap-around within a single slot if it happens (though unlikely for 15m segments) const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd); const overlap2 = Math.max(0, intervalStart) < Math.min(sEnd, intervalEnd); return overlap1 || overlap2; @@ -94,7 +95,7 @@ const Timetable: React.FC = ({ from_date, to_date, day_start, da {/* Time Label */}
- {step % 120 === 0 ? formatTimeLabel(step) : ''} + {step % 60 === 0 ? formatTimeLabel(step) : ''}
{/* Grid Cells */} @@ -110,20 +111,20 @@ const Timetable: React.FC = ({ from_date, to_date, day_start, da
))} - {/* Red Marker Line */} - -
-
- - - - ); + {/* Red Marker Line */} + +
+
+ + + + ); }; export default Timetable; diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx index 514736e..e35e2fd 100644 --- a/frontend/src/components/stats/StatsPage.tsx +++ b/frontend/src/components/stats/StatsPage.tsx @@ -12,8 +12,10 @@ import { useSettings } from '../../hooks/useSettings'; const StatsPage: React.FC = () => { const { settings, update } = useSettings(); - const from_date = new Date(new Date().setDate(new Date().getDate() - settings.statsInterval)).toISOString().split('T')[0]; - const to_date = new Date().toISOString().split('T')[0]; + const now = new Date(); + 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'); + return (
diff --git a/frontend/tests/e2e/timetable_timezone.spec.ts b/frontend/tests/e2e/timetable_timezone.spec.ts new file mode 100644 index 0000000..d809409 --- /dev/null +++ b/frontend/tests/e2e/timetable_timezone.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Sleep Timetable Timezone Alignment', () => { + test.use({ + timezoneId: 'America/Los_Angeles', + locale: 'en-US' + }); + + test('red marker (current time) should align with the correct grid column/row for sleeping sessions', async ({ page }) => { + // 1. Mock the current time to be 08:00 AM local (PDT/PST) + // Since we can't easily mock system time in Playwright without extra tools, + // we will rely on the fact that 'new Date()' is called in the component. + // We can use `page.addInitScript` to mock Date. + const mockDate = new Date('2026-05-17T08:00:00-07:00'); + await page.addInitScript((date) => { + const originalDate = globalThis.Date; + function MockDate(...args: any[]) { + if (args.length === 0) return new originalDate(date.getTime()); + return new originalDate(...args); + } + MockDate.prototype = originalDate.prototype; + MockDate.now = () => date.getTime(); + globalThis.Date = MockDate as any; + }, mockDate); + + // 2. Mock the API response for timetable + // We want to simulate a session that is currently happening in local time. + // If it's 08:00 AM now, and they are sleeping, the slot for 08:00 should be green. + // The API returns slots with 'sleeping: true'. + // Note: the component's logic uses `timetable.slots` and checks `isSleepingInInterval`. + // The slots come from the backend with 'slot_start' and 'slot_end'. + await page.route('**/api/stats/timetable*', async (route) => { + const json = { + slots: [ + // A session that covers 07:00 to 09:00 on May 17th + { date: '2026-05-17', slot_start: '07:00', slot_end: '07:15', sleeping: true }, + { date: '2026-05-17', slot_start: '07:15', slot_end: '07:30', sleeping: true }, + { date: '2026-05-17', slot_start: '07:30', slot_end: '07:45', sleeping: true }, + { date: '2026-05-17', slot_start: '07:45', slot_end: '08:00', sleeping: true }, + { date: '2026-05-17', slot_start: '08:00', slot_end: '08:15', sleeping: true }, + { date: '2026-05-17', slot_start: '08:15', slot_end: '08:30', sleeping: true }, + { date: '2026-05-17', slot_start: '08:30', slot_end: '08:45', sleeping: true }, + { date: '2026-05-17', slot_start: '08:45', slot_end: '09:00', sleeping: true }, + ] + }; + await route.fulfill({ json }); + }); + + // Also mock the daily summary as it's used by useStats + await page.route('**/api/stats/daily-summary*', async (route) => { + await route.fulfill({ json: { days: [] } }); + }); + + // Also mock wake-sleep times as it's used by useStats + await page.route('**/api/stats/wake-sleep-times*', async (route) => { + await route.fulfill({ json: { days: [] } }); + }); + + // 3. Navigate to the stats page + await page.goto('/stats'); // Assuming /stats is the route for StatsPage + + // 4. Verify that the red marker (at 08:00) is inside a green cell + // The red marker has class 'bg-red-500' + const redMarker = page.locator('.bg-red-500'); + await expect(redMarker).toBeVisible(); + + // We want to check if the cell at 08:00 is green. + // In our mocked data, the slots for 08:00 are sleeping: true. + // The component renders cells with className={`h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`} + // Since we can't easily target a specific "cell" by index in a grid without more info, + // we check if there's at least one green cell that matches our criteria. + const greenCells = page.locator('.bg-green-500'); + await expect(greenCells.count()).toBeGreaterThan(0); + + // To be really precise, we can check the position of the red marker + // and see if there is a green cell at that same vertical position. + const markerBox = await redMarker.boundingBox(); + if (markerBox) { + // The marker is at 08:00, which is (8*60)/1440 = 33.33% from the top of the container + // We can check if the cell at that height is green. + const timetableContainer = page.locator('.relative'); + // Note: 'relative' is the container for the grid and marker. + // We look for a green cell that intersects with the red marker's Y position. + const allGreenCells = page.locator('.bg-green-500'); + const count = await allGreenCells.count(); + let foundAlignedCell = false; + for (let i = 0; i < count; i++) { + const cellBox = await allGreenCells.nth(i).boundingBox(); + if (cellBox && markerBox.y >= cellBox.y - 2 && markerBox.y <= cellBox.y + cellBox.height + 2) { + foundAlignedCell = true; + break; + } + } + expect(foundAlignedCell).toBe(true); + } else { + throw new Error('Could not get bounding box for red marker'); + } + }); +});