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