228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
import { useMemo, useState, useEffect, Fragment } from 'react';
|
|
import { useStats } from '../../hooks/useStats';
|
|
import { timeToMinutes } from '../../utils/time';
|
|
|
|
interface TimetableProps {
|
|
from_date: string;
|
|
to_date: string;
|
|
day_start: string;
|
|
day_end: string;
|
|
}
|
|
|
|
function getDayName(dateStr: string) {
|
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short' });
|
|
}
|
|
|
|
function formatDayLabel(dateStr: string) {
|
|
const date = new Date(dateStr + 'T00:00:00');
|
|
const weekday = date.toLocaleDateString('en-US', { weekday: 'short' });
|
|
const day = date.getDate();
|
|
return `${weekday} ${day}`;
|
|
}
|
|
|
|
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 [selectedDayIndex, setSelectedDayIndex] = useState(0);
|
|
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
|
|
|
|
const days = useMemo(() => {
|
|
const start = new Date(from_date + 'T00:00:00');
|
|
const end = new Date(to_date + 'T23:59:59');
|
|
const result: string[] = [];
|
|
const current = new Date(start);
|
|
while (current <= end) {
|
|
result.push(current.toLocaleDateString('en-CA'));
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
return result; // Chronological (oldest to newest)
|
|
}, [from_date, to_date]);
|
|
|
|
useEffect(() => {
|
|
const handleResize = () => setWindowWidth(window.innerWidth);
|
|
window.addEventListener('resize', handleResize);
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (days.length > 0) {
|
|
setSelectedDayIndex(days.length - 1);
|
|
}
|
|
}, [days]);
|
|
|
|
const reversedDays = useMemo(() => [...days].reverse(), [days]);
|
|
|
|
const timeSteps = useMemo(() => {
|
|
return Array.from({ length: 96 }, (_, i) => i * 15);
|
|
}, []);
|
|
|
|
const tier = useMemo(() => {
|
|
if (windowWidth >= 768) return 'desktop';
|
|
if (windowWidth >= 400) return 'medium';
|
|
return 'small';
|
|
}, [windowWidth]);
|
|
|
|
const { visibleDays, tabGroups } = useMemo(() => {
|
|
if (tier === 'desktop') {
|
|
return { visibleDays: days, tabGroups: [] };
|
|
}
|
|
|
|
const activeDays = reversedDays;
|
|
const selectedDay = days[selectedDayIndex];
|
|
const indexInActiveDays = activeDays.indexOf(selectedDay);
|
|
|
|
if (tier === 'medium') {
|
|
const groups = [];
|
|
for (let i = 0; i < activeDays.length; i += 3) {
|
|
groups.push({
|
|
label: `${formatDayLabel(activeDays[i])} - ${formatDayLabel(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: formatDayLabel(day),
|
|
startIndex: idx,
|
|
}));
|
|
return {
|
|
visibleDays: activeDays.slice(indexInActiveDays, indexInActiveDays + 1),
|
|
tabGroups: groups,
|
|
};
|
|
}, [tier, days, reversedDays, selectedDayIndex]);
|
|
|
|
const isSleepingInInterval = (date: string, intervalStart: number, intervalEnd: number) => {
|
|
if (!timetable?.slots) return false;
|
|
return timetable.slots.some((slot) => {
|
|
if (slot.date !== date || !slot.sleeping) return false;
|
|
|
|
const sStart = timeToMinutes(slot.slot_start);
|
|
const 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 handleTabClick = (startIndexInReversed: number) => {
|
|
const day = reversedDays[startIndexInReversed];
|
|
const indexInDays = days.indexOf(day);
|
|
setSelectedDayIndex(indexInDays);
|
|
};
|
|
|
|
const currentMins = new Date().getHours() * 60 + new Date().getMinutes();
|
|
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 (
|
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
|
<h3 className="text-lg font-semibold mb-2 text-gray-800">Sleep Timetable</h3>
|
|
|
|
{/* Tab Selector (Mobile & Medium) */}
|
|
{(tier === 'medium' || tier === 'small') && (
|
|
<div className="flex overflow-x-auto gap-2 mb-2 pb-1 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 */}
|
|
<div
|
|
className="grid"
|
|
style={{
|
|
gridTemplateColumns: tier === 'desktop'
|
|
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
|
: `4rem repeat(${visibleDays.length}, 1fr)`
|
|
}}
|
|
>
|
|
<div className="h-2" /> {/* Empty corner cell */}
|
|
{visibleDays.map((day) => (
|
|
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
|
|
{getDayName(day)}
|
|
</div >
|
|
))}
|
|
</div >
|
|
|
|
{/* Time Grid Container */}
|
|
<div className="relative">
|
|
<div
|
|
className="grid"
|
|
style={{
|
|
gridTemplateColumns: tier === 'desktop'
|
|
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
|
: `4rem repeat(${visibleDays.length}, 1fr)`
|
|
}}
|
|
>
|
|
{/* Time Rows */}
|
|
{timeSteps.map((step) => (
|
|
<Fragment key={step}>
|
|
{/* Time Label */}
|
|
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
|
|
{step % 60 === 0 ? formatTimeLabel(step) : ''}
|
|
</div >
|
|
|
|
{/* Grid Cells */}
|
|
{visibleDays.map((day) => {
|
|
const sleeping = isSleepingInInterval(day, step, step + 15);
|
|
return (
|
|
<div
|
|
key={`${day}-${step}`}
|
|
className={`h-2 w-full border border-gray-100 ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
|
|
/>
|
|
);
|
|
})}
|
|
</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) * (timeSteps.length * rowHeightPx)}px`,
|
|
transform: 'translateY(-50%)'
|
|
}}
|
|
/>
|
|
</div >
|
|
</div >
|
|
</div >
|
|
</div >
|
|
);
|
|
};
|
|
|
|
export default Timetable; |