Working on fixing sleep timetable & add gitea workflows

This commit is contained in:
2026-05-20 17:30:13 +02:00
parent e38fae1940
commit b361fc5172
9 changed files with 182 additions and 171 deletions
+20
View File
@@ -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
+20
View File
@@ -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
+6 -2
View File
@@ -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():
+10 -82
View File
@@ -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<SleepSessionRead | null> {
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<SessionListResponse> {
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<string, string> = {}): Promise<any> {
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<SleepSessionRead> {
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<SleepSessionRead> {
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<SleepSessionRead> {
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<void> {
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<any> {
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<any> {
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<any> {
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 });
}
@@ -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;
@@ -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', () => {
@@ -13,12 +13,12 @@ const Timetable: React.FC<TimetableProps> = ({ 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<TimetableProps> = ({ 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<TimetableProps> = ({ from_date, to_date, day_start, da
<React.Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8">
{step % 120 === 0 ? formatTimeLabel(step) : ''}
{step % 60 === 0 ? formatTimeLabel(step) : ''}
</div>
{/* Grid Cells */}
@@ -110,20 +111,20 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
</React.Fragment>
))}
{/* Red Marker Line */}
<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%)'
}}
/>
</div>
</div>
</div>
</div>
);
{/* Red Marker Line */}
<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%)'
}}
/>
</div>
</div>
</div>
</div>
);
};
export default Timetable;
+4 -2
View File
@@ -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 (
<div className="max-w-4xl mx-auto space-y-8 pb-12">
@@ -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');
}
});
});