AI removed features....
This commit is contained in:
@@ -50,6 +50,8 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
|
|||||||
- No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`) - type safety enforced
|
- No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`) - type safety enforced
|
||||||
- No empty catch blocks - error handling required
|
- No empty catch blocks - error handling required
|
||||||
- No deleting failing tests to "pass"
|
- No deleting failing tests to "pass"
|
||||||
|
- **Frontend JS Slop**: NEVER create `.js` files for the frontend. Use only TypeScript (`.ts`, `.tsx`) and write high-quality, type-safe code. Do not repeat this mistake.
|
||||||
|
- **Assumption Error**: NEVER assume the content of ANY files EVER. ALWAYS read the file to confirm. Do not rely on the name.
|
||||||
|
|
||||||
## COMMANDS
|
## COMMANDS
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSessions } from '../../hooks/useSleepSession';
|
import { useSessions } from '../../hooks/useSleepSession';
|
||||||
import { Edit2, Check, X } from 'lucide-react';
|
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
||||||
import type { SleepSessionRead } from '../../types';
|
import type { SleepSessionRead } from '../../types';
|
||||||
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes } from '../../utils/time';
|
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
||||||
|
|
||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { sessions, isLoading, updateSession } = useSessions();
|
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editStart, setEditStart] = useState('');
|
const [editStart, setEditStart] = useState('');
|
||||||
const [editEnd, setEditEnd] = useState('');
|
const [editEnd, setEditEnd] = useState('');
|
||||||
@@ -29,6 +29,18 @@ export function SessionList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (e: React.MouseEvent, id: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.confirm('Are you sure you want to delete this sleep session?')) {
|
||||||
|
try {
|
||||||
|
await deleteSession(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to delete session');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startEditing = (s: SleepSessionRead) => {
|
const startEditing = (s: SleepSessionRead) => {
|
||||||
setEditingId(s.id);
|
setEditingId(s.id);
|
||||||
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
|
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
|
||||||
@@ -44,69 +56,84 @@ export function SessionList() {
|
|||||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
|
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sessions.map((s) => (
|
{sessions.map((s) => (
|
||||||
<div key={s.id} className="border-b border-gray-100 last:border-0 pb-2">
|
<div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
|
||||||
{editingId === s.id ? (
|
{editingId === s.id ? (
|
||||||
<div className="flex flex-col space-y-2 py-1">
|
<div className="flex flex-col space-y-3 py-1">
|
||||||
<div className="grid grid-cols-1 gap-2 text-xs text-gray-500">
|
<div className="grid grid-cols-1 gap-3 text-xs text-gray-500">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span>Start Time</span>
|
<span className="font-medium text-gray-700">Start Time</span>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="border rounded px-1 py-0.5 text-sm text-black w-full"
|
className="border rounded px-2 py-1.5 text-sm text-black w-full focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
value={editStart}
|
value={editStart}
|
||||||
onChange={(e) => setEditStart(e.target.value)}
|
onChange={(e) => setEditStart(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span>End Time</span>
|
<span className="font-medium text-gray-700">End Time</span>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="border rounded px-1 py-0.5 text-sm text-black w-full"
|
className="border rounded px-2 py-1.5 text-sm text-black w-full focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
value={editEnd}
|
value={editEnd}
|
||||||
onChange={(e) => setEditEnd(e.target.value)}
|
onChange={(e) => setEditEnd(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
|
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 bg-gray-100 rounded-lg transition-colors"
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={16} />
|
||||||
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSave(s.id)}
|
onClick={() => handleSave(s.id)}
|
||||||
className="p-1 text-green-600 hover:text-green-700 transition-colors"
|
className="flex items-center gap-1 px-3 py-1.5 text-sm text-white bg-green-600 hover:text-green-700 rounded-lg transition-colors"
|
||||||
title="Save"
|
title="Save"
|
||||||
>
|
>
|
||||||
<Check size={18} />
|
<Check size={16} />
|
||||||
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center text-sm cursor-pointer hover:bg-gray-50 p-1 rounded transition-colors"
|
className="flex flex-col sm:flex-row sm:justify-between sm:items-center cursor-pointer hover:bg-gray-50 transition-colors rounded-lg px-2"
|
||||||
onClick={() => startEditing(s)}
|
onClick={() => startEditing(s)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col sm:flex-row sm:items-baseline sm:gap-3">
|
||||||
<span className="text-gray-900 font-medium">
|
<div className="flex flex-col sm:flex-row sm:items-baseline sm:gap-2">
|
||||||
{formatDateTime(s.start_time)}
|
<span className="text-gray-900 font-bold text-base sm:text-sm">
|
||||||
</span>
|
{formatDateTime(s.start_time)}
|
||||||
|
</span>
|
||||||
|
<span className="text-blue-600 font-semibold text-xs sm:text-sm">
|
||||||
|
{s.end_time && s.start_time ? formatMinutes(getDurationMinutes(s.start_time, s.end_time)) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span className="text-gray-500 text-xs">
|
<span className="text-gray-500 text-xs">
|
||||||
{s.end_time
|
{s.end_time ? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}` : 'In progress'}
|
||||||
? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}`
|
|
||||||
: 'In progress'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-blue-600 font-mono text-sm">
|
|
||||||
{s.end_time && s.start_time
|
|
||||||
? `${getDurationMinutes(s.start_time, s.end_time)} min`
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Edit2 size={14} className="text-gray-400" />
|
<div className="flex items-center justify-end space-x-2 sm:space-x-4 pt-3 sm:pt-0">
|
||||||
|
<div className="flex items-center gap-2 sm:hidden text-blue-600 font-semibold text-sm">
|
||||||
|
<span>{s.end_time && s.start_time ? formatMinutes(getDurationMinutes(s.start_time, s.end_time)) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDelete(e, s.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="p-2 text-red-400 hover:text-red-600 disabled:opacity-50 transition-colors"
|
||||||
|
title="Delete session"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="p-2 text-gray-400">
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } 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 } from '../../utils/time';
|
||||||
|
|
||||||
@@ -9,8 +9,26 @@ interface TimetableProps {
|
|||||||
day_end: string;
|
day_end: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, day_end }) => {
|
function getDayName(dateStr: string) {
|
||||||
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeLabel(mins: number) {
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
const minutes = mins % 60;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) => {
|
||||||
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end);
|
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end);
|
||||||
|
const [selectedDayIndex, setSelectedDayIndex] = useState(0);
|
||||||
|
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => setWindowWidth(window.innerWidth);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(() => {
|
||||||
const start = new Date(from_date + 'T00:00:00');
|
const start = new Date(from_date + 'T00:00:00');
|
||||||
@@ -21,29 +39,57 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
|
|||||||
result.push(current.toLocaleDateString('en-CA'));
|
result.push(current.toLocaleDateString('en-CA'));
|
||||||
current.setDate(current.getDate() + 1);
|
current.setDate(current.getDate() + 1);
|
||||||
}
|
}
|
||||||
return result;
|
return result; // Chronological (oldest to newest)
|
||||||
}, [from_date, to_date]);
|
}, [from_date, to_date]);
|
||||||
|
|
||||||
|
const reversedDays = useMemo(() => [...days].reverse(), [days]);
|
||||||
|
|
||||||
const timeSteps = useMemo(() => {
|
const timeSteps = useMemo(() => {
|
||||||
return Array.from({ length: 24 }, (_, i) => i * 60);
|
return Array.from({ length: 96 }, (_, i) => i * 15);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isLoading) return <div className="text-center p-4">Loading timetable...</div>;
|
const tier = useMemo(() => {
|
||||||
if (!timetable?.slots || timetable.slots.length === 0) {
|
if (windowWidth >= 768) return 'desktop';
|
||||||
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
if (windowWidth >= 400) return 'medium';
|
||||||
}
|
return 'small';
|
||||||
|
}, [windowWidth]);
|
||||||
|
|
||||||
const getDayName = (dateStr: string) => {
|
const { visibleDays, tabGroups } = useMemo(() => {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' });
|
if (tier === 'desktop') {
|
||||||
};
|
return { visibleDays: days, tabGroups: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const formatTimeLabel = (mins: number) => {
|
const activeDays = reversedDays;
|
||||||
const hours = Math.floor(mins / 60);
|
const selectedDay = days[selectedDayIndex];
|
||||||
const minutes = mins % 60;
|
const indexInActiveDays = activeDays.indexOf(selectedDay);
|
||||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
|
||||||
};
|
if (tier === 'medium') {
|
||||||
|
const groups = [];
|
||||||
|
for (let i = 0; i < activeDays.length; i += 3) {
|
||||||
|
groups.push({
|
||||||
|
label: `${getDayName(activeDays[i])} - ${getDayName(activeDays[Math.min(i + 2, activeDays.length - 1)])}`,
|
||||||
|
startIndex: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
visibleDays: activeDays.slice(indexInActiveDays, indexInActiveDays + 3),
|
||||||
|
tabGroups: groups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small tier (single day)
|
||||||
|
const groups = activeDays.map((day, idx) => ({
|
||||||
|
label: getDayName(day),
|
||||||
|
startIndex: idx,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
visibleDays: activeDays.slice(indexInActiveDays, indexInActiveDays + 1),
|
||||||
|
tabGroups: groups,
|
||||||
|
};
|
||||||
|
}, [tier, days, reversedDays, selectedDayIndex]);
|
||||||
|
|
||||||
const isSleepingInInterval = (date: string, intervalStart: number, intervalEnd: number) => {
|
const isSleepingInInterval = (date: string, intervalStart: number, intervalEnd: number) => {
|
||||||
|
if (!timetable?.slots) return false;
|
||||||
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;
|
||||||
|
|
||||||
@@ -51,7 +97,6 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
|
|||||||
const sEnd = timeToMinutes(slot.slot_end);
|
const sEnd = timeToMinutes(slot.slot_end);
|
||||||
|
|
||||||
if (sEnd <= sStart) {
|
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 overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd);
|
||||||
const overlap2 = Math.max(0, intervalStart) < Math.min(sEnd, intervalEnd);
|
const overlap2 = Math.max(0, intervalStart) < Math.min(sEnd, intervalEnd);
|
||||||
return overlap1 || overlap2;
|
return overlap1 || overlap2;
|
||||||
@@ -61,71 +106,114 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTabClick = (startIndexInReversed: number) => {
|
||||||
|
const day = reversedDays[startIndexInReversed];
|
||||||
|
const indexInDays = days.indexOf(day);
|
||||||
|
setSelectedDayIndex(indexInDays);
|
||||||
|
};
|
||||||
|
|
||||||
const currentMins = new Date().getHours() * 60 + new Date().getMinutes();
|
const currentMins = new Date().getHours() * 60 + new Date().getMinutes();
|
||||||
const rowHeightPx = 32; // h-8 is 2rem, which is 32px in standard Tailwind config
|
const rowHeightPx = 8;
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-center p-4">Loading timetable...</div>;
|
||||||
|
if (!timetable?.slots || timetable.slots.length === 0) {
|
||||||
|
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Timetable</h3>
|
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Timetable</h3>
|
||||||
<div className="min-w-[600px] flex flex-col gap-px">
|
|
||||||
|
{/* Tab Selector (Mobile & Medium) */}
|
||||||
|
{(tier === 'medium' || tier === 'small') && (
|
||||||
|
<div className="flex overflow-x-auto gap-2 mb-4 pb-2 no-scrollbar">
|
||||||
|
{tabGroups.map((group, idx) => {
|
||||||
|
const isActive = selectedDayIndex === days.indexOf(reversedDays[group.startIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => handleTabClick(group.startIndex)}
|
||||||
|
className={`px-4 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div >
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid Container */}
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div
|
<div
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
gridTemplateColumns: tier === 'desktop'
|
||||||
|
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
||||||
|
: tier === 'medium'
|
||||||
|
? `4rem repeat(3, 1fr)`
|
||||||
|
: `4rem 1fr`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-8" /> {/* Empty corner cell */}
|
<div className="h-2" /> {/* Empty corner cell */}
|
||||||
{days.map((day) => (
|
{visibleDays.map((day) => (
|
||||||
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8">
|
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
|
||||||
{getDayName(day)}
|
{getDayName(day)}
|
||||||
</div>
|
</div >
|
||||||
))}
|
))}
|
||||||
</div>
|
</div >
|
||||||
|
|
||||||
{/* Time Grid Container */}
|
{/* Time Grid Container */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
gridTemplateColumns: tier === 'desktop'
|
||||||
|
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
||||||
|
: tier === 'medium'
|
||||||
|
? `4rem repeat(3, 1fr)`
|
||||||
|
: `4rem 1fr`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Time Rows */}
|
{/* Time Rows */}
|
||||||
{timeSteps.map((step) => (
|
{timeSteps.map((step) => (
|
||||||
<React.Fragment key={step}>
|
<Fragment key={step}>
|
||||||
{/* Time Label */}
|
{/* Time Label */}
|
||||||
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8">
|
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
|
||||||
{step % 60 === 0 ? formatTimeLabel(step) : ''}
|
{step % 60 === 0 ? formatTimeLabel(step) : ''}
|
||||||
</div>
|
</div >
|
||||||
|
|
||||||
{/* Grid Cells */}
|
{/* Grid Cells */}
|
||||||
{days.map((day) => {
|
{visibleDays.map((day) => {
|
||||||
const sleeping = isSleepingInInterval(day, step, step + 60);
|
const sleeping = isSleepingInInterval(day, step, step + 15);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${day}-${step}`}
|
key={`${day}-${step}`}
|
||||||
className={`h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
|
className={`h-2 w-full border border-gray-100 ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</React.Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Red Marker Line */}
|
{/* Red Marker Line */}
|
||||||
<div
|
<div
|
||||||
className="absolute left-[4rem] right-0 h-0.5 bg-red-500 pointer-events-none z-10"
|
className="absolute left-[4rem] right-0 h-0.5 bg-red-500 pointer-events-none z-10"
|
||||||
style={{
|
style={{
|
||||||
top: `${(currentMins / 1440) * (24 * rowHeightPx)}px`,
|
top: `${(currentMins / 1440) * (timeSteps.length * rowHeightPx)}px`,
|
||||||
transform: 'translateY(-50%)'
|
transform: 'translateY(-50%)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div >
|
||||||
</div>
|
</div >
|
||||||
</div>
|
</div >
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Timetable;
|
export default Timetable;
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"use strict";
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { vi, describe, it, expect } from 'vitest';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
import Timetable from '../../../../src/components/stats/SleepTimetable';
|
import Timetable from '../../../../src/components/stats/SleepTimetable';
|
||||||
import { useStats } from '../../../../src/hooks/useStats';
|
import { useStats } from '../../../../src/hooks/useStats';
|
||||||
|
|
||||||
@@ -12,12 +12,17 @@ vi.mock('../../../../src/hooks/useStats', () => ({
|
|||||||
|
|
||||||
describe('SleepTimetable', () => {
|
describe('SleepTimetable', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
from_date: '2025-04-20',
|
from_date: '2025-04-18',
|
||||||
to_date: '2025-04-21',
|
to_date: '2025-04-25',
|
||||||
day_start: '22:00',
|
day_start: '22:00',
|
||||||
day_end: '08:00',
|
day_end: '08:00',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal('innerWidth', 1024);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders loading state', () => {
|
it('renders loading state', () => {
|
||||||
(useStats as any).mockReturnValue({ timetable: null, isLoading: true });
|
(useStats as any).mockReturnValue({ timetable: null, isLoading: true });
|
||||||
render(<Timetable {...defaultProps} />);
|
render(<Timetable {...defaultProps} />);
|
||||||
@@ -30,35 +35,52 @@ describe('SleepTimetable', () => {
|
|||||||
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
|
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the grid correctly with hourly rows and biennial labels', () => {
|
it('renders in desktop mode (chronological order)', () => {
|
||||||
(useStats as any).mockReturnValue({
|
(useStats as any).mockReturnValue({
|
||||||
timetable: {
|
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
|
||||||
slots: [
|
|
||||||
{
|
|
||||||
date: '2025-04-20',
|
|
||||||
sleeping: true,
|
|
||||||
slot_start: '01:00',
|
|
||||||
slot_end: '05:00',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('innerWidth', 1024);
|
||||||
render(<Timetable {...defaultProps} />);
|
render(<Timetable {...defaultProps} />);
|
||||||
|
|
||||||
// Check for day headers
|
// Check for day headers in chronological order
|
||||||
expect(screen.getByText(/SUN/i)).toBeInTheDocument(); // 2025-04-20 is Sunday
|
const days = screen.getAllByText(/^[A-Z]{3}$/i); // SUN, MON, etc.
|
||||||
expect(screen.getByText(/MON/i)).toBeInTheDocument(); // 2025-04-21 is Monday
|
// 2025-04-18 is Friday (FRI)
|
||||||
|
expect(days[0]).toHaveTextContent(/FRI/i);
|
||||||
|
expect(days[1]).toHaveTextContent(/SAT/i);
|
||||||
|
});
|
||||||
|
|
||||||
// Check for time labels (every 1 hour: 00, 01, 02...)
|
it('renders in medium mode (3-day tabs and 3-column grid)', () => {
|
||||||
expect(screen.getByText('00:00')).toBeInTheDocument();
|
(useStats as any).mockReturnValue({
|
||||||
expect(screen.getByText('01:00')).toBeInTheDocument();
|
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
|
||||||
expect(screen.getByText('02:00')).toBeInTheDocument();
|
isLoading: false,
|
||||||
expect(screen.getByText('03:00')).toBeInTheDocument();
|
});
|
||||||
expect(screen.getByText('04:00')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('22:00')).toBeInTheDocument();
|
vi.stubGlobal('innerWidth', 600);
|
||||||
expect(screen.getByText('23:00')).toBeInTheDocument();
|
render(<Timetable {...defaultProps} />);
|
||||||
|
fireEvent(window, new Event('resize'));
|
||||||
|
|
||||||
|
// Check for 3-day interval tabs
|
||||||
|
// In reversed order (25, 24, 23...), the first group is Fri - Wed
|
||||||
|
expect(screen.getByRole('button', { name: /FRI - WED/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders in small mode (single-day tabs and 1-column grid)', () => {
|
||||||
|
(useStats as any).mockReturnValue({
|
||||||
|
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('innerWidth', 300);
|
||||||
|
render(<Timetable {...defaultProps} />);
|
||||||
|
fireEvent(window, new Event('resize'));
|
||||||
|
|
||||||
|
// Check for single-day tabs
|
||||||
|
const dayTabs = screen.getAllByRole('button');
|
||||||
|
// At least one tab should have the text FRI
|
||||||
|
const friTab = dayTabs.find(tab => tab.textContent?.toUpperCase().includes('FRI'));
|
||||||
|
expect(friTab).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('colors sleeping intervals correctly', () => {
|
it('colors sleeping intervals correctly', () => {
|
||||||
@@ -66,7 +88,7 @@ describe('SleepTimetable', () => {
|
|||||||
timetable: {
|
timetable: {
|
||||||
slots: [
|
slots: [
|
||||||
{
|
{
|
||||||
date: '2025-04-20',
|
date: '2025-04-18',
|
||||||
sleeping: true,
|
sleeping: true,
|
||||||
slot_start: '01:00',
|
slot_start: '01:00',
|
||||||
slot_end: '03:00', // Covers 01:00 and 02:00
|
slot_end: '03:00', // Covers 01:00 and 02:00
|
||||||
@@ -87,4 +109,4 @@ describe('SleepTimetable', () => {
|
|||||||
const grayCells = container.querySelectorAll('.bg-gray-50');
|
const grayCells = container.querySelectorAll('.bg-gray-50');
|
||||||
expect(grayCells.length).toBeGreaterThan(0);
|
expect(grayCells.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
# Plan: Fix Wakeup Plot Timezone Display
|
# Sleep Timetable Bug Fixes
|
||||||
|
|
||||||
## Status: COMPLETED
|
## 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.
|
||||||
|
|
||||||
## Problem
|
## Implementation Plan
|
||||||
`WakeupPlot.tsx` shows incorrect wakeup times (e.g., showing 01:00 instead of 05:00 when input is 03:00 UTC in Stockholm). This was caused by sending a local reference time to the backend, which then calculated a delta relative to that time being treated as UTC.
|
|
||||||
|
|
||||||
## Solution
|
### 1. Fix Pre-selection
|
||||||
1. **Frontend API Fix**: Modified `fetchWakeSleepTimes` in `frontend/src/api/client.ts` to convert the local `wakeup_ref` and `bedtime_ref` HH:MM strings into UTC HH:MM strings before sending them to the backend.
|
- 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. **Frontend Component Fix**: Updated `WakeupPlot.tsx` and `BedtimePlot.tsx` to use the returned `wakeup_minutes`/`bedtime_minutes` (which is now a correctly calculated timezone-invariant delta) directly as the offset from reference, instead of attempting to re-adjust it using local timezone offsets.
|
|
||||||
|
|
||||||
## Verification
|
### 2. Fix Grid Alignment
|
||||||
Verified with Playwright test in `Europe/Stockholm` timezone: 03:00 UTC input correctly displays as 05:00.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user