Working on fixing sleep timetable & add gitea workflows
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user