wip: get timetable to work

This commit is contained in:
2026-04-27 16:37:48 +02:00
parent 54e382cbb3
commit 81120547ae
10 changed files with 176 additions and 347 deletions
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useStats } from '../../hooks/useStats';
import { timeToMinutes, formatMinutesAfter } from '../../utils/time';
@@ -7,86 +7,125 @@ interface TimetableProps {
to_date: string;
day_start: string;
day_end: string;
};
}
const Timetable: React.FC<TimetableProps> = ({ 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: string[] = [];
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: 12 }, (_, i) => i * 120);
}, []);
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>;
}
const startMins = timeToMinutes(day_start);
let endMins = timeToMinutes(day_end);
if (endMins < startMins) endMins += 1440;
const totalDayMinutes = endMins - startMins;
const getDayName = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' });
};
// Grouping slots by date to make it easier to render rows
const groupedSlots = timetable.slots.reduce((acc: any, slot) => {
if (!acc[slot.date]) acc[slot.date] = [];
acc[slot.date].push(slot);
return acc;
}, {});
const 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 isSleepingInInterval = (date: string, intervalStart: number, intervalEnd: number) => {
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();
// Total rows: 1 (header) + 12 (time steps) = 13
const totalRows = 13;
return (
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Timetable</h3>
<div className="min-w-[800px]">
{Object.keys(groupedSlots).sort((a, b) => b.localeCompare(a)).map((date) => (
<div key={date} className="mb-8 last:mb-0">
<h4 className="text-sm font-medium text-gray-500 mb-2">{date}</h4>
<div className="relative w-full">
{/* The progress bar container */}
<div className="relative h-8 bg-gray-50 p-1 rounded-lg overflow-hidden">
{groupedSlots[date].map((slot: any) => {
let sStart = timeToMinutes(slot.slot_start);
if (sStart < startMins) sStart += 1440;
let sEnd = timeToMinutes(slot.slot_end);
if (sEnd < sStart) sEnd += 1440;
else if (sEnd < startMins) sEnd += 1440;
const left = ((sStart - startMins) / totalDayMinutes) * 100;
const width = ((sEnd - sStart) / totalDayMinutes) * 100;
<div className="min-w-[600px] flex flex-col gap-px">
{/* Header Row */}
<div
className="grid"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
<div className="h-8" /> {/* Empty corner cell */}
{days.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8">
{getDayName(day)}
</div>
))}
</div>
{/* Time Grid Container */}
<div className="relative">
<div
className="grid gap-px"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
{/* Time Rows */}
{timeSteps.map((step) => (
<React.Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8">
{formatTimeLabel(step)}
</div>
{/* Grid Cells */}
{days.map((day) => {
const sleeping = isSleepingInInterval(day, step, step + 120);
return (
<div
key={`${date}-${slot.slot_start}`}
className={`absolute h-full rounded-sm transition-all duration-300 cursor-help ${
slot.sleeping ? 'bg-blue-900' : 'bg-gray-200'
}`}
style={{ left: `${left}%`, width: `${width}%` }}
title={`${slot.slot_start} - ${slot.slot_end}`}
>
{/* Only show text if there's enough space */}
<div className="overflow-hidden whitespace-nowrap text-[10px] leading-none flex items-center justify-center h-full px-1 text-white/70">
{width > 15 && <span>{slot.slot_start}</span>}
</div>
</div>
key={`${day}-${step}`}
className={`h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
/>
);
})}
</div>
</React.Fragment>
))}
{/* Time hints below the bar */}
<div className="flex justify-between mt-1 px-1 text-[10px] text-gray-400">
{Array.from({ length: Math.ceil(totalDayMinutes / 120) + 1 }).map((_, i) => {
const minsOffset = i * 120;
if (minsOffset > totalDayMinutes) return null;
return (
<span key={i} className="w-0 min-w-[30px] text-center">
{formatMinutesAfter(minsOffset, day_start)}
</span>
);
})}
</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;