From 929b75dfe35c11eb8e625759005d1841833a05ab Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Fri, 29 May 2026 09:55:59 +0200 Subject: [PATCH] AI removed features.... --- AGENTS.md | 2 + frontend/src/components/stats/SessionList.tsx | 87 +++++---- .../src/components/stats/SleepTimetable.tsx | 170 +++++++++++++----- frontend/src/pages/Playground.js | 1 + .../components/stats/SleepTimetable.test.tsx | 78 +++++--- plan.md | 27 ++- 6 files changed, 257 insertions(+), 108 deletions(-) create mode 100644 frontend/src/pages/Playground.js diff --git a/AGENTS.md b/AGENTS.md index e1f7b2e..28f443f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 empty catch blocks - error handling required - 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 diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx index cd88378..3d00460 100644 --- a/frontend/src/components/stats/SessionList.tsx +++ b/frontend/src/components/stats/SessionList.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; 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 { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes } from '../../utils/time'; +import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time'; export function SessionList() { - const { sessions, isLoading, updateSession } = useSessions(); + const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions(); const [editingId, setEditingId] = useState(null); const [editStart, setEditStart] = 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) => { setEditingId(s.id); setEditStart(toLocalDatetimeInputValue(new Date(s.start_time))); @@ -44,69 +56,84 @@ export function SessionList() {

Sleep Sessions

{sessions.map((s) => ( -
+
{editingId === s.id ? ( -
-
+
+
- Start Time + Start Time setEditStart(e.target.value)} />
- End Time + End Time setEditEnd(e.target.value)} />
-
+
) : (
startEditing(s)} > -
- - {formatDateTime(s.start_time)} - +
+
+ + {formatDateTime(s.start_time)} + + + {s.end_time && s.start_time ? formatMinutes(getDurationMinutes(s.start_time, s.end_time)) : '—'} + +
- {s.end_time - ? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}` - : 'In progress'} + {s.end_time ? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}` : 'In progress'}
-
- - {s.end_time && s.start_time - ? `${getDurationMinutes(s.start_time, s.end_time)} min` - : '—'} - - +
+
+ {s.end_time && s.start_time ? formatMinutes(getDurationMinutes(s.start_time, s.end_time)) : '—'} +
+
+ +
+ +
+
)} diff --git a/frontend/src/components/stats/SleepTimetable.tsx b/frontend/src/components/stats/SleepTimetable.tsx index 00013c9..269b13a 100644 --- a/frontend/src/components/stats/SleepTimetable.tsx +++ b/frontend/src/components/stats/SleepTimetable.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import { useMemo, useState, useEffect, Fragment } from 'react'; import { useStats } from '../../hooks/useStats'; import { timeToMinutes } from '../../utils/time'; @@ -9,8 +9,26 @@ interface TimetableProps { day_end: string; } -const Timetable: React.FC = ({ 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 [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 start = new Date(from_date + 'T00:00:00'); @@ -21,29 +39,57 @@ const Timetable: React.FC = ({ from_date, to_date, day_start, da result.push(current.toLocaleDateString('en-CA')); current.setDate(current.getDate() + 1); } - return result; + return result; // Chronological (oldest to newest) }, [from_date, to_date]); + const reversedDays = useMemo(() => [...days].reverse(), [days]); + const timeSteps = useMemo(() => { - return Array.from({ length: 24 }, (_, i) => i * 60); + return Array.from({ length: 96 }, (_, i) => i * 15); }, []); - if (isLoading) return
Loading timetable...
; - if (!timetable?.slots || timetable.slots.length === 0) { - return
No data available for this period.
; - } + const tier = useMemo(() => { + if (windowWidth >= 768) return 'desktop'; + if (windowWidth >= 400) return 'medium'; + return 'small'; + }, [windowWidth]); - const getDayName = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' }); - }; + const { visibleDays, tabGroups } = useMemo(() => { + if (tier === 'desktop') { + return { visibleDays: days, tabGroups: [] }; + } - 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 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: `${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) => { + if (!timetable?.slots) return false; return timetable.slots.some((slot) => { if (slot.date !== date || !slot.sleeping) return false; @@ -51,7 +97,6 @@ const Timetable: React.FC = ({ from_date, to_date, day_start, da const 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; @@ -61,71 +106,114 @@ const Timetable: React.FC = ({ 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 rowHeightPx = 32; // h-8 is 2rem, which is 32px in standard Tailwind config + const rowHeightPx = 8; + + if (isLoading) return
Loading timetable...
; + if (!timetable?.slots || timetable.slots.length === 0) { + return
No data available for this period.
; + } return ( -
+

Sleep Timetable

-
+ + {/* Tab Selector (Mobile & Medium) */} + {(tier === 'medium' || tier === 'small') && ( +
+ {tabGroups.map((group, idx) => { + const isActive = selectedDayIndex === days.indexOf(reversedDays[group.startIndex]); + + return ( + + ); + })} +
+ )} + + {/* Grid Container */} +
{/* Header Row */}
-
{/* Empty corner cell */} - {days.map((day) => ( -
+
{/* Empty corner cell */} + {visibleDays.map((day) => ( +
{getDayName(day)} -
+
))} -
+
{/* Time Grid Container */}
{/* Time Rows */} {timeSteps.map((step) => ( - + {/* Time Label */} -
+
{step % 60 === 0 ? formatTimeLabel(step) : ''} -
+
{/* Grid Cells */} - {days.map((day) => { - const sleeping = isSleepingInInterval(day, step, step + 60); + {visibleDays.map((day) => { + const sleeping = isSleepingInInterval(day, step, step + 15); return (
); })} - + ))} {/* Red Marker Line */}
-
-
-
-
+
+
+
+
); }; -export default Timetable; - +export default Timetable; \ No newline at end of file diff --git a/frontend/src/pages/Playground.js b/frontend/src/pages/Playground.js new file mode 100644 index 0000000..3918c74 --- /dev/null +++ b/frontend/src/pages/Playground.js @@ -0,0 +1 @@ +"use strict"; diff --git a/frontend/tests/unit/components/stats/SleepTimetable.test.tsx b/frontend/tests/unit/components/stats/SleepTimetable.test.tsx index 63dd044..d1d0afc 100644 --- a/frontend/tests/unit/components/stats/SleepTimetable.test.tsx +++ b/frontend/tests/unit/components/stats/SleepTimetable.test.tsx @@ -1,8 +1,8 @@ /* 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 { vi, describe, it, expect } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; import Timetable from '../../../../src/components/stats/SleepTimetable'; import { useStats } from '../../../../src/hooks/useStats'; @@ -12,12 +12,17 @@ vi.mock('../../../../src/hooks/useStats', () => ({ describe('SleepTimetable', () => { const defaultProps = { - from_date: '2025-04-20', - to_date: '2025-04-21', + from_date: '2025-04-18', + to_date: '2025-04-25', day_start: '22:00', day_end: '08:00', }; + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('innerWidth', 1024); + }); + it('renders loading state', () => { (useStats as any).mockReturnValue({ timetable: null, isLoading: true }); render(); @@ -30,35 +35,52 @@ describe('SleepTimetable', () => { 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({ - timetable: { - slots: [ - { - date: '2025-04-20', - sleeping: true, - slot_start: '01:00', - slot_end: '05:00', - }, - ], - }, + timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '05:00' }] }, isLoading: false, }); + vi.stubGlobal('innerWidth', 1024); render(); - // Check for day headers - expect(screen.getByText(/SUN/i)).toBeInTheDocument(); // 2025-04-20 is Sunday - expect(screen.getByText(/MON/i)).toBeInTheDocument(); // 2025-04-21 is Monday + // Check for day headers in chronological order + const days = screen.getAllByText(/^[A-Z]{3}$/i); // SUN, MON, etc. + // 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...) - 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(); - expect(screen.getByText('23:00')).toBeInTheDocument(); + it('renders in medium mode (3-day tabs and 3-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', 600); + render(); + 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(); + 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', () => { @@ -66,7 +88,7 @@ describe('SleepTimetable', () => { timetable: { slots: [ { - date: '2025-04-20', + date: '2025-04-18', sleeping: true, slot_start: '01:00', slot_end: '03:00', // Covers 01:00 and 02:00 @@ -87,4 +109,4 @@ describe('SleepTimetable', () => { const grayCells = container.querySelectorAll('.bg-gray-50'); expect(grayCells.length).toBeGreaterThan(0); }); -}); +}); \ No newline at end of file diff --git a/plan.md b/plan.md index b89b383..b1fa2a8 100644 --- a/plan.md +++ b/plan.md @@ -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 -`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. +## Implementation Plan -## Solution -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. -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. +### 1. Fix Pre-selection +- 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. -## Verification -Verified with Playwright test in `Europe/Stockholm` timezone: 03:00 UTC input correctly displays as 05:00. +### 2. Fix Grid Alignment +- 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.