diff --git a/AGENTS.md b/AGENTS.md index 38be9cd..e1f7b2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ uv run pytest # Frontend cd frontend && npm install npm run dev +# Use 'npm test' with caution as it may hang; use specific test scripts instead. npm run test ``` diff --git a/backend/scripts/populate_db.py b/backend/scripts/populate_db.py new file mode 100644 index 0000000..32e637c --- /dev/null +++ b/backend/scripts/populate_db.py @@ -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()) diff --git a/backend/scripts/raw_select.py b/backend/scripts/raw_select.py new file mode 100644 index 0000000..8905911 --- /dev/null +++ b/backend/scripts/raw_select.py @@ -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()) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e35808..936a8cc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "vite": "^6.0.0", - "vitest": "^2.1.0" + "vitest": "2.1.9" } }, "node_modules/@adobe/css-tools": { @@ -1702,9 +1702,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.99.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", - "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==", + "version": "5.100.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.1.tgz", + "integrity": "sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==", "license": "MIT", "funding": { "type": "github", @@ -1712,12 +1712,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.99.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", - "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==", + "version": "5.100.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.1.tgz", + "integrity": "sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.99.2" + "@tanstack/query-core": "5.100.1" }, "funding": { "type": "github", @@ -2180,9 +2180,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", - "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2565,21 +2565,21 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.343", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", - "integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==", + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -2739,9 +2739,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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", @@ -3378,21 +3378,6 @@ "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": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -4539,6 +4524,21 @@ "@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": { "version": "5.4.21", "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": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", @@ -5123,6 +5138,21 @@ "@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": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index e89be89..0fb2626 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "build": "tsc && vite build", "preview": "vite preview", "test": "vitest", + "test:all": "vitest run && npx playwright test", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { @@ -34,6 +35,6 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "vite": "^6.0.0", - "vitest": "^2.1.0" + "vitest": "2.1.9" } } diff --git a/frontend/src/components/stats/SleepTimetable.tsx b/frontend/src/components/stats/SleepTimetable.tsx index e498f8b..d88d34a 100644 --- a/frontend/src/components/stats/SleepTimetable.tsx +++ b/frontend/src/components/stats/SleepTimetable.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useStats } from '../../hooks/useStats'; +import { timeToMinutes } from '../../utils/time'; interface TimetableProps { from_date: string; @@ -30,18 +31,26 @@ const Timetable: React.FC = ({ from_date, to_date, day_start, da {Object.keys(groupedSlots).map((date) => (

{date}

-
- {groupedSlots[date].map((slot: any) => ( -
- {slot.slot_start} -
- ))} +
+ {groupedSlots[date].map((slot: any) => { + const duration = Math.max(1, timeToMinutes(slot.slot_end) - timeToMinutes(slot.slot_start)); + + return ( +
+ {/* Only show text if there's enough space */} +
+ {duration > 40 && {slot.slot_start}} +
+
+ ); + })}
))} diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx index 0eb72df..1346266 100644 --- a/frontend/src/components/stats/StatsPage.tsx +++ b/frontend/src/components/stats/StatsPage.tsx @@ -8,7 +8,6 @@ import BedtimePlot from './BedtimePlot'; import { SessionList } from './SessionList'; import { ErrorBoundary } from '../../components/ErrorBoundary'; import { useSettings } from '../../hooks/useSettings'; -import TestErrorComponent from '../test-error-component'; const StatsPage: React.FC = () => { const { settings, update } = useSettings(); @@ -79,16 +78,8 @@ const StatsPage: React.FC = () => { - -
-

Error Boundary Test Area

- - - -
- -
+ ); }; -export default StatsPage; \ No newline at end of file +export default StatsPage; diff --git a/frontend/src/components/test-error-component.tsx b/frontend/src/components/test-error-component.tsx deleted file mode 100644 index 238564a..0000000 --- a/frontend/src/components/test-error-component.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const TestErrorComponent = () => { - throw new Error("Test Error"); -}; - -export default TestErrorComponent; \ No newline at end of file diff --git a/frontend/src/components/tracker/SleepButton.tsx b/frontend/src/components/tracker/SleepButton.tsx index 14439bd..ec40a39 100644 --- a/frontend/src/components/tracker/SleepButton.tsx +++ b/frontend/src/components/tracker/SleepButton.tsx @@ -9,13 +9,6 @@ const SleepButton: React.FC = ({ time }) => { const { activeSession, createSession, endSession, isCreating, isEnding } = useSleepSession(); const isSleeping = !!activeSession; - console.log('DEBUG: SleepButton State:', { - activeSession, - isSleeping, - isCreating, - isEnding - }); - const handleClick = async () => { try { if (isSleeping && activeSession?.id) { diff --git a/frontend/src/components/tracker/TimeAdjuster.tsx b/frontend/src/components/tracker/TimeAdjuster.tsx index 986e534..3ca83fc 100644 --- a/frontend/src/components/tracker/TimeAdjuster.tsx +++ b/frontend/src/components/tracker/TimeAdjuster.tsx @@ -55,4 +55,4 @@ const TimeAdjuster: React.FC = ({ onTimeChange, defaultValue ); }; -export default TimeAdjuster; +export default TimeAdjuster; \ No newline at end of file diff --git a/frontend/src/components/tracker/TrackerPage.tsx b/frontend/src/components/tracker/TrackerPage.tsx index 9f26f5e..752f749 100644 --- a/frontend/src/components/tracker/TrackerPage.tsx +++ b/frontend/src/components/tracker/TrackerPage.tsx @@ -4,7 +4,13 @@ import SleepButton from './SleepButton'; import TimeAdjuster from './TimeAdjuster'; const TrackerPage: React.FC = () => { - const [time, setTime] = useState(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(getLocalTime()); return (
@@ -19,4 +25,4 @@ const TrackerPage: React.FC = () => { ); }; -export default TrackerPage; +export default TrackerPage; \ No newline at end of file diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts index f67d351..73993e6 100644 --- a/frontend/src/utils/time.ts +++ b/frontend/src/utils/time.ts @@ -13,7 +13,6 @@ export function formatMinutesAfter(minutes: number | null, reference: string = " const [refHours, refMinutes] = reference.split(":").map(Number); const totalRefMinutes = refHours * 60 + refMinutes; - const targetMinutes = totalRefMinutes + minutes; // Handle wrap around midnight const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440; @@ -34,26 +33,23 @@ export function toLocalDatetimeInputValue(date: Date): string { return localDate.toISOString().slice(0, 16); } -/** - * Format elapsed time in human-readable format - * @param startTime - start time as ISO string or Date - * @returns formatted string like "2h 35m" or "just now" - */ -export function formatElapsedTime(startTime: string | Date): string { - const start = typeof startTime === "string" ? new Date(startTime) : startTime; +export function formatElapsedTime(utcTimestamp: string): string { + let isoString = utcTimestamp; + if (utcTimestamp.includes(' ')) { + isoString = utcTimestamp.replace(' ', 'T'); + } + if (!isoString.endsWith('Z')) { + isoString += 'Z'; + } + const then = new Date(isoString); 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"; - - 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`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; } /** @@ -77,6 +73,7 @@ export function clampToDay(date: Date, bound: "start" | "end"): Date { * @param timeStr - HH:MM format * @param baseDate - base date (defaults to today) * @returns Date object + * @throws Error if timeStr is invalid */ export function parseTime(timeStr: string, baseDate: Date = new Date()): Date { const [hours, minutes] = timeStr.split(":").map(Number); @@ -85,19 +82,6 @@ export function parseTime(timeStr: string, baseDate: Date = new Date()): Date { 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 * @param days - number of days ago @@ -115,4 +99,14 @@ export function getDaysAgo(days: number): string { */ export function getToday(): string { return new Date().toISOString().split("T")[0]; -} \ No newline at end of file +} + +/** + * 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; +} diff --git a/frontend/tests/unit/StatusBanner.test.tsx b/frontend/tests/unit/StatusBanner.test.tsx new file mode 100644 index 0000000..a809b2b --- /dev/null +++ b/frontend/tests/unit/StatusBanner.test.tsx @@ -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(); + + 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(); + + expect(screen.getByText(/Baby is awake/)).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/tests/unit/time.test.ts b/frontend/tests/unit/time.test.ts index 133bb7a..17ab06d 100644 --- a/frontend/tests/unit/time.test.ts +++ b/frontend/tests/unit/time.test.ts @@ -43,7 +43,7 @@ describe('toLocalDatetimeInputValue', () => { describe('formatElapsedTime', () => { it('formats hours and minutes', () => { 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', () => {