Fix time duration and visuals
This commit is contained in:
@@ -62,6 +62,7 @@ uv run pytest
|
|||||||
# Frontend
|
# Frontend
|
||||||
cd frontend && npm install
|
cd frontend && npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
|
# Use 'npm test' with caution as it may hang; use specific test scripts instead.
|
||||||
npm run test
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
|
||||||
|
from app.database import engine, init_db
|
||||||
|
from app.models import SleepSession
|
||||||
|
|
||||||
|
async def populate_data():
|
||||||
|
# Ensure tables are created
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
# Today's date (as per environment) is April 24, 2026
|
||||||
|
# We use UTC to be consistent with the model's default_factory
|
||||||
|
now = datetime(2026, 4, 24, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# We want 2 weeks of data (14 days)
|
||||||
|
# To ensure the oldest date gets the lowest ID, we iterate from the oldest to the newest.
|
||||||
|
# i=13 is 13 days ago (oldest). i=0 is today.
|
||||||
|
for day_offset in range(13, -1, -1):
|
||||||
|
target_date = now - timedelta(days=day_offset)
|
||||||
|
|
||||||
|
# Normalize target date to midnight UTC for consistency in base calculations
|
||||||
|
base_date = datetime(target_date.year, target_date.month, target_date.day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
# 1. Morning Nap (around 10:00 - 11:30)
|
||||||
|
morning_start = base_date + timedelta(hours=10, minutes=0) + timedelta(minutes=random.randint(-15, 30))
|
||||||
|
morning_end = base_date + timedelta(hours=11, minutes=30) + timedelta(minutes=random.randint(-30, 30))
|
||||||
|
if morning_end <= morning_start:
|
||||||
|
morning_end = morning_start + timedelta(hours=1, minutes=random.randint(0, 30))
|
||||||
|
|
||||||
|
# 2. Afternoon Nap (around 15:00 - 16:30)
|
||||||
|
afternoon_start = base_date + timedelta(hours=15, minutes=0) + timedelta(minutes=random.randint(-30, 30))
|
||||||
|
afternoon_end = base_date + timedelta(hours=16, minutes=30) + timedelta(minutes=random.randint(-30, 30))
|
||||||
|
if afternoon_end <= afternoon_start:
|
||||||
|
afternoon_end = afternoon_start + timedelta(hours=1, minutes=random.randint(0, 30))
|
||||||
|
|
||||||
|
# Add the naps
|
||||||
|
session.add(SleepSession(start_time=morning_start, end_time=morning_end, notes="Morning nap"))
|
||||||
|
session.add(SleepSession(start_time=afternoon_start, end_time=afternoon_end, notes="Afternoon nap"))
|
||||||
|
|
||||||
|
# 3. Night Sleep (around 20:00 to 06:00 the following day)
|
||||||
|
# ONLY add if it's not today (day_offset 0), to avoid adding future sleep.
|
||||||
|
if day_offset > 0:
|
||||||
|
night_start = base_date + timedelta(hours=20, minutes=0) + timedelta(minutes=random.randint(-30, 30))
|
||||||
|
night_end = (base_date + timedelta(days=1)).replace(hour=6, minute=0) + timedelta(minutes=random.randint(-30, 30))
|
||||||
|
session.add(SleepSession(start_time=night_start, end_time=night_end, notes="Night sleep"))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
print(f"Added data for {base_date.strftime('%Y-%m-%d')}")
|
||||||
|
|
||||||
|
print("Successfully populated the database with 2 weeks of mock sleep data.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(populate_data())
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import asyncio
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.database import engine
|
||||||
|
|
||||||
|
async def run_query():
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(text("SELECT * FROM sleep_session"))
|
||||||
|
rows = result.all()
|
||||||
|
print(f"Total rows: {len(rows)}")
|
||||||
|
for row in rows:
|
||||||
|
print(row)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_query())
|
||||||
Generated
+66
-36
@@ -31,7 +31,7 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "2.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -1702,9 +1702,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.99.2",
|
"version": "5.100.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.1.tgz",
|
||||||
"integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==",
|
"integrity": "sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1712,12 +1712,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.99.2",
|
"version": "5.100.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.1.tgz",
|
||||||
"integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==",
|
"integrity": "sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.99.2"
|
"@tanstack/query-core": "5.100.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2180,9 +2180,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.20",
|
"version": "2.10.21",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
|
||||||
"integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
|
"integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2565,21 +2565,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.343",
|
"version": "1.5.344",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
|
||||||
"integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==",
|
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.1",
|
"version": "5.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
"tapable": "^2.3.0"
|
"tapable": "^2.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
@@ -2739,9 +2739,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3378,21 +3378,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright/node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
@@ -4539,6 +4524,21 @@
|
|||||||
"@esbuild/win32-x64": "0.21.5"
|
"@esbuild/win32-x64": "0.21.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-node/node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-node/node_modules/vite": {
|
"node_modules/vite-node/node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -4599,6 +4599,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite/node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
||||||
@@ -5123,6 +5138,21 @@
|
|||||||
"@esbuild/win32-x64": "0.21.5"
|
"@esbuild/win32-x64": "0.21.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest/node_modules/vite": {
|
"node_modules/vitest/node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
"test:all": "vitest run && npx playwright test",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -34,6 +35,6 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useStats } from '../../hooks/useStats';
|
import { useStats } from '../../hooks/useStats';
|
||||||
|
import { timeToMinutes } from '../../utils/time';
|
||||||
|
|
||||||
interface TimetableProps {
|
interface TimetableProps {
|
||||||
from_date: string;
|
from_date: string;
|
||||||
@@ -30,18 +31,26 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
|
|||||||
{Object.keys(groupedSlots).map((date) => (
|
{Object.keys(groupedSlots).map((date) => (
|
||||||
<div key={date} className="mb-6 last:mb-0">
|
<div key={date} className="mb-6 last:mb-0">
|
||||||
<h4 className="text-sm font-medium text-gray-500 mb-2">{date}</h4>
|
<h4 className="text-sm font-medium text-gray-500 mb-2">{date}</h4>
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(20px,1fr))] gap-1">
|
<div className="flex w-full h-8 gap-1 bg-gray-50 p-1 rounded-lg overflow-hidden">
|
||||||
{groupedSlots[date].map((slot: any) => (
|
{groupedSlots[date].map((slot: any) => {
|
||||||
<div
|
const duration = Math.max(1, timeToMinutes(slot.slot_end) - timeToMinutes(slot.slot_start));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={`${date}-${slot.slot_start}`}
|
key={`${date}-${slot.slot_start}`}
|
||||||
className={`h-6 w-full rounded-sm ${
|
className={`h-full rounded-sm transition-all duration-300 cursor-help ${
|
||||||
slot.sleeping ? 'bg-blue-900' : 'bg-gray-100'
|
slot.sleeping ? 'bg-blue-900' : 'bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
title={`${slot.slot_start} - ${slot.slot_end}`}
|
style={{ flex: duration }}
|
||||||
>
|
title={`${slot.slot_start} - ${slot.slot_end} (${duration}m)`}
|
||||||
<span className="text-[10px] text-white/50">{slot.slot_start}</span>
|
>
|
||||||
</div>
|
{/* 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">
|
||||||
|
{duration > 40 && <span className="text-white/70">{slot.slot_start}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import BedtimePlot from './BedtimePlot';
|
|||||||
import { SessionList } from './SessionList';
|
import { SessionList } from './SessionList';
|
||||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||||
import { useSettings } from '../../hooks/useSettings';
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
import TestErrorComponent from '../test-error-component';
|
|
||||||
|
|
||||||
const StatsPage: React.FC = () => {
|
const StatsPage: React.FC = () => {
|
||||||
const { settings, update } = useSettings();
|
const { settings, update } = useSettings();
|
||||||
@@ -79,15 +78,7 @@ const StatsPage: React.FC = () => {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<SessionList />
|
<SessionList />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
||||||
<div className="mt-8 p-4 border-2 border-dashed border-gray-300 rounded">
|
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-600">Error Boundary Test Area</h3>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<TestErrorComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div >
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const TestErrorComponent = () => {
|
|
||||||
throw new Error("Test Error");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TestErrorComponent;
|
|
||||||
@@ -9,13 +9,6 @@ const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
|
|||||||
const { activeSession, createSession, endSession, isCreating, isEnding } = useSleepSession();
|
const { activeSession, createSession, endSession, isCreating, isEnding } = useSleepSession();
|
||||||
const isSleeping = !!activeSession;
|
const isSleeping = !!activeSession;
|
||||||
|
|
||||||
console.log('DEBUG: SleepButton State:', {
|
|
||||||
activeSession,
|
|
||||||
isSleeping,
|
|
||||||
isCreating,
|
|
||||||
isEnding
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
try {
|
try {
|
||||||
if (isSleeping && activeSession?.id) {
|
if (isSleeping && activeSession?.id) {
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import SleepButton from './SleepButton';
|
|||||||
import TimeAdjuster from './TimeAdjuster';
|
import TimeAdjuster from './TimeAdjuster';
|
||||||
|
|
||||||
const TrackerPage: React.FC = () => {
|
const TrackerPage: React.FC = () => {
|
||||||
const [time, setTime] = useState<string>(new Date().toISOString().slice(0, 16));
|
const getLocalTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [time, setTime] = useState<string>(getLocalTime());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto space-y-6">
|
<div className="max-w-md mx-auto space-y-6">
|
||||||
|
|||||||
+26
-32
@@ -13,7 +13,6 @@ export function formatMinutesAfter(minutes: number | null, reference: string = "
|
|||||||
|
|
||||||
const [refHours, refMinutes] = reference.split(":").map(Number);
|
const [refHours, refMinutes] = reference.split(":").map(Number);
|
||||||
const totalRefMinutes = refHours * 60 + refMinutes;
|
const totalRefMinutes = refHours * 60 + refMinutes;
|
||||||
|
|
||||||
const targetMinutes = totalRefMinutes + minutes;
|
const targetMinutes = totalRefMinutes + minutes;
|
||||||
// Handle wrap around midnight
|
// Handle wrap around midnight
|
||||||
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
|
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
|
||||||
@@ -34,26 +33,23 @@ export function toLocalDatetimeInputValue(date: Date): string {
|
|||||||
return localDate.toISOString().slice(0, 16);
|
return localDate.toISOString().slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function formatElapsedTime(utcTimestamp: string): string {
|
||||||
* Format elapsed time in human-readable format
|
let isoString = utcTimestamp;
|
||||||
* @param startTime - start time as ISO string or Date
|
if (utcTimestamp.includes(' ')) {
|
||||||
* @returns formatted string like "2h 35m" or "just now"
|
isoString = utcTimestamp.replace(' ', 'T');
|
||||||
*/
|
}
|
||||||
export function formatElapsedTime(startTime: string | Date): string {
|
if (!isoString.endsWith('Z')) {
|
||||||
const start = typeof startTime === "string" ? new Date(startTime) : startTime;
|
isoString += 'Z';
|
||||||
|
}
|
||||||
|
const then = new Date(isoString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - start.getTime();
|
const diffMs = now - then;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const hours = Math.floor(diffMins / 60);
|
||||||
|
const mins = diffMins % 60;
|
||||||
|
|
||||||
if (diffMs < 0) return "just now";
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
const totalMinutes = Math.floor(diffMs / 60000);
|
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
|
||||||
const minutes = totalMinutes % 60;
|
|
||||||
|
|
||||||
if (hours === 0 && minutes === 0) return "just now";
|
|
||||||
if (hours === 0) return `${minutes}m`;
|
|
||||||
if (minutes === 0) return `${hours}h`;
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +73,7 @@ export function clampToDay(date: Date, bound: "start" | "end"): Date {
|
|||||||
* @param timeStr - HH:MM format
|
* @param timeStr - HH:MM format
|
||||||
* @param baseDate - base date (defaults to today)
|
* @param baseDate - base date (defaults to today)
|
||||||
* @returns Date object
|
* @returns Date object
|
||||||
|
* @throws Error if timeStr is invalid
|
||||||
*/
|
*/
|
||||||
export function parseTime(timeStr: string, baseDate: Date = new Date()): Date {
|
export function parseTime(timeStr: string, baseDate: Date = new Date()): Date {
|
||||||
const [hours, minutes] = timeStr.split(":").map(Number);
|
const [hours, minutes] = timeStr.split(":").map(Number);
|
||||||
@@ -85,19 +82,6 @@ export function parseTime(timeStr: string, baseDate: Date = new Date()): Date {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format minutes as hours and minutes string
|
|
||||||
* @param minutes - total minutes
|
|
||||||
* @returns formatted string like "12h 30m"
|
|
||||||
*/
|
|
||||||
export function formatMinutes(minutes: number): string {
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const mins = minutes % 60;
|
|
||||||
if (hours === 0) return `${mins}m`;
|
|
||||||
if (mins === 0) return `${hours}h`;
|
|
||||||
return `${hours}h ${mins}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get date string for N days ago
|
* Get date string for N days ago
|
||||||
* @param days - number of days ago
|
* @param days - number of days ago
|
||||||
@@ -116,3 +100,13 @@ export function getDaysAgo(days: number): string {
|
|||||||
export function getToday(): string {
|
export function getToday(): string {
|
||||||
return new Date().toISOString().split("T")[0];
|
return new Date().toISOString().split("T")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HH:MM time string to total minutes from start of day
|
||||||
|
* @param timeStr - HH:MM format
|
||||||
|
* @returns total minutes
|
||||||
|
*/
|
||||||
|
export function timeToMinutes(timeStr: string): number {
|
||||||
|
const [hours, minutes] = timeStr.split(":").map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import StatusBanner from '../../src/components/tracker/StatusBanner';
|
||||||
|
import { useSleepSession } from '../../src/hooks/useSleepSession';
|
||||||
|
import { formatElapsedTime } from '../../src/utils/time';
|
||||||
|
|
||||||
|
vi.mock('../../src/hooks/useSleepSession', () => ({
|
||||||
|
useSleepSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('StatusBanner - Timezone Fix', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatElapsedTime', () => {
|
||||||
|
it('should show correct elapsed time for session started ~49 minutes ago', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-04-24T08:51:00Z'));
|
||||||
|
|
||||||
|
const result = formatElapsedTime('2026-04-24T08:02:00');
|
||||||
|
|
||||||
|
expect(result).toBe('49m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show hours and minutes for longer sessions', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-04-24T10:02:00Z'));
|
||||||
|
|
||||||
|
const result = formatElapsedTime('2026-04-24T08:02:00');
|
||||||
|
|
||||||
|
expect(result).toBe('2h 0m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show minutes only for sessions under an hour', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-04-24T08:07:00Z'));
|
||||||
|
|
||||||
|
const result = formatElapsedTime('2026-04-24T08:02:00');
|
||||||
|
|
||||||
|
expect(result).toBe('5m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StatusBanner display', () => {
|
||||||
|
it('shows correct elapsed time for active session', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-04-24T08:51:00Z'));
|
||||||
|
|
||||||
|
vi.mocked(useSleepSession).mockReturnValue({
|
||||||
|
activeSession: {
|
||||||
|
id: 4,
|
||||||
|
start_time: '2026-04-24T08:02:00',
|
||||||
|
end_time: null,
|
||||||
|
notes: null,
|
||||||
|
created_at: '2026-04-24T08:02:28.942902',
|
||||||
|
updated_at: '2026-04-24T08:02:28.942927',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<StatusBanner />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Baby is sleeping/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/\(49m\)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows awake state when no active session', () => {
|
||||||
|
vi.mocked(useSleepSession).mockReturnValue({
|
||||||
|
activeSession: null,
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<StatusBanner />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Baby is awake/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,7 @@ describe('toLocalDatetimeInputValue', () => {
|
|||||||
describe('formatElapsedTime', () => {
|
describe('formatElapsedTime', () => {
|
||||||
it('formats hours and minutes', () => {
|
it('formats hours and minutes', () => {
|
||||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||||
expect(formatElapsedTime(twoHoursAgo)).toBe('2h');
|
expect(formatElapsedTime(twoHoursAgo)).toBe('2h 0m');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats minutes only', () => {
|
it('formats minutes only', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user