Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a86bf2ded4 | |||
| 969b8e033f | |||
| cfc3156906 | |||
| 81eadf618a | |||
| 6847886e7a | |||
| 5d8a8b3c57 | |||
| 32d3e86d38 | |||
| b763aa1e9d | |||
| df210c66a0 | |||
| 560863d914 |
@@ -59,16 +59,3 @@ jobs:
|
|||||||
git tag "$VERSION"
|
git tag "$VERSION"
|
||||||
git push origin "$VERSION"
|
git push origin "$VERSION"
|
||||||
|
|
||||||
lint:
|
|
||||||
runs-on: host
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Run actionlint
|
|
||||||
run: |
|
|
||||||
# Trying to install it via apk if possible, otherwise fallback
|
|
||||||
apk add --no-cache actionlint || echo "actionlint not found in apk"
|
|
||||||
if command -v actionlint >/dev/null 2>&1; then
|
|
||||||
actionlint .gitea/workflows/*.yml
|
|
||||||
else
|
|
||||||
echo "Skipping actionlint: not installed"
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
|
--build-arg VITE_APP_VERSION=$(git describe --tags) \
|
||||||
|
--build-arg VITE_APP_GITSHA=$(git rev-parse --short HEAD) \
|
||||||
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
|
-t gitea.lan/jacobh2/babymonitor-frontend:${{ env.VERSION }} \
|
||||||
./frontend \
|
./frontend \
|
||||||
--push
|
--push
|
||||||
@@ -59,16 +61,3 @@ jobs:
|
|||||||
git tag "$VERSION"
|
git tag "$VERSION"
|
||||||
git push origin "$VERSION"
|
git push origin "$VERSION"
|
||||||
|
|
||||||
lint:
|
|
||||||
runs-on: host
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Run actionlint
|
|
||||||
run: |
|
|
||||||
# Trying to install it via apk if possible, otherwise fallback
|
|
||||||
apk add --no-cache actionlint || echo "actionlint not found in apk"
|
|
||||||
if command -v actionlint >/dev/null 2>&1; then
|
|
||||||
actionlint .gitea/workflows/*.yml
|
|
||||||
else
|
|
||||||
echo "Skipping actionlint: not installed"
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
*.js
|
*.js
|
||||||
*.jsx
|
*.jsx
|
||||||
|
|
||||||
|
.omo/
|
||||||
|
|
||||||
### Node ###
|
### Node ###
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
|
|||||||
- **Frontend JS Slop**: NEVER create `.js` or `.jsx` files for the frontend. Use only TypeScript (`.ts`, `.tsx`). Write clean, type-safe code.
|
- **Frontend JS Slop**: NEVER create `.js` or `.jsx` files for the frontend. Use only TypeScript (`.ts`, `.tsx`). Write clean, type-safe code.
|
||||||
- **Assumption Error**: NEVER assume the content of ANY files. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it.
|
- **Assumption Error**: NEVER assume the content of ANY files. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it.
|
||||||
|
|
||||||
|
## MANDATORY EXECUTION PROTOCOLS (INJECTABLE)
|
||||||
|
|
||||||
|
The following protocols must be strictly adhered to upon injection:
|
||||||
|
- **Read Before Edit**: ALWAYS read the entire target file before performing any `edit` operation.
|
||||||
|
- **Post-Edit Verification**: Immediately after an edit, verify that no lines have been duplicated.
|
||||||
|
- **Stability Check**: Following any code change, run both backend and frontend services AND the full test suite to ensure system stability.
|
||||||
## COMMANDS
|
## COMMANDS
|
||||||
|
|
||||||
### Backend Development
|
### Backend Development
|
||||||
|
|||||||
@@ -9,6 +9,5 @@ class SleepSession(SQLModel, table=True):
|
|||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
start_time: datetime = Field(nullable=False)
|
start_time: datetime = Field(nullable=False)
|
||||||
end_time: Optional[datetime] = Field(default=None, nullable=True)
|
end_time: Optional[datetime] = Field(default=None, nullable=True)
|
||||||
notes: Optional[str] = Field(default=None)
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -6,22 +6,17 @@ from pydantic import BaseModel
|
|||||||
class SleepSessionCreate(BaseModel):
|
class SleepSessionCreate(BaseModel):
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
|
|
||||||
|
|
||||||
class SleepSessionUpdate(BaseModel):
|
class SleepSessionUpdate(BaseModel):
|
||||||
start_time: Optional[datetime] = None
|
start_time: Optional[datetime] = None
|
||||||
end_time: Optional[datetime] = None
|
end_time: Optional[datetime] = None
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SleepSessionRead(BaseModel):
|
class SleepSessionRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
end_time: Optional[datetime]
|
end_time: Optional[datetime]
|
||||||
notes: Optional[str]
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class SessionListResponse(BaseModel):
|
class SessionListResponse(BaseModel):
|
||||||
sessions: List[SleepSessionRead]
|
sessions: List[SleepSessionRead]
|
||||||
total: int
|
total: int
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
INFO: Will watch for changes in these directories: ['/Users/jhag/projects/babymonitor/backend']
|
|
||||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
|
||||||
INFO: Started reloader process [63655] using StatReload
|
|
||||||
INFO: Started server process [63657]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
WARNING: StatReload detected changes in 'app/main.py'. Reloading...
|
|
||||||
INFO: Shutting down
|
|
||||||
INFO: Waiting for application shutdown.
|
|
||||||
INFO: Application shutdown complete.
|
|
||||||
INFO: Finished server process [63657]
|
|
||||||
INFO: Started server process [71064]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/arm64 \
|
|
||||||
-t babymonitor-frontend:latest \
|
|
||||||
-f frontend/Dockerfile \
|
|
||||||
--load \
|
|
||||||
frontend
|
|
||||||
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/arm64 \
|
|
||||||
-t babymonitor-backend:latest \
|
|
||||||
-f backend/Dockerfile \
|
|
||||||
--load \
|
|
||||||
backend
|
|
||||||
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
ARG VITE_APP_VERSION=unknown
|
||||||
|
ARG VITE_APP_GITSHA=unknown
|
||||||
|
ENV VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
|
ENV VITE_APP_GITSHA=$VITE_APP_GITSHA
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files first to leverage Docker cache
|
# Copy package files first to leverage Docker cache
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import TrackerPage from './components/tracker/TrackerPage';
|
|||||||
import StatsPage from './components/stats/StatsPage';
|
import StatsPage from './components/stats/StatsPage';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Playground from './components/Playground';
|
import Playground from './components/Playground';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -17,6 +19,7 @@ function App() {
|
|||||||
<Route path="/playground" element={<Playground />} />
|
<Route path="/playground" element={<Playground />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<footer className="py-4 text-center">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{__APP_VERSION__} ({__APP_GITSHA__})
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -100,8 +100,8 @@ const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_sta
|
|||||||
width={60}
|
width={60}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: any, name: any) => [
|
formatter={(value: number | string | readonly (string | number)[] | undefined, name: string | number | undefined) => [
|
||||||
formatMinutes(Math.round(Number(value ?? 0) * 60)),
|
formatMinutes(Math.round(Number((Array.isArray(value) ? value[0] : value ?? 0) * 60))),
|
||||||
name === 'daySleep' ? 'Day' : 'Night',
|
name === 'daySleep' ? 'Day' : 'Night',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSettings } from '../../hooks/useSettings';
|
|||||||
import { Pagination } from './Pagination';
|
import { Pagination } from './Pagination';
|
||||||
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
import { Edit2, Check, X, Trash2 } from 'lucide-react';
|
||||||
import type { SleepSessionRead } from '../../types';
|
import type { SleepSessionRead } from '../../types';
|
||||||
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
|
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes, toUTCFromLocalInput, parseBackendDateTime } from '../../utils/time';
|
||||||
|
|
||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
|
||||||
@@ -29,13 +29,13 @@ export function SessionList() {
|
|||||||
|
|
||||||
const handleSave = async (id: number) => {
|
const handleSave = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await updateSession({
|
await updateSession({
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
payload: {
|
payload: {
|
||||||
start_time: new Date(editStart).toISOString(),
|
start_time: toUTCFromLocalInput(editStart),
|
||||||
end_time: editEnd ? new Date(editEnd).toISOString() : undefined,
|
end_time: editEnd ? toUTCFromLocalInput(editEnd) : undefined,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -57,8 +57,8 @@ export function SessionList() {
|
|||||||
|
|
||||||
const startEditing = (s: SleepSessionRead) => {
|
const startEditing = (s: SleepSessionRead) => {
|
||||||
setEditingId(s.id);
|
setEditingId(s.id);
|
||||||
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
|
setEditStart(toLocalDatetimeInputValue(parseBackendDateTime(s.start_time)));
|
||||||
setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : '');
|
setEditEnd(s.end_time ? toLocalDatetimeInputValue(parseBackendDateTime(s.end_time)) : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -161,6 +161,6 @@ export function SessionList() {
|
|||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (days.length > 0) {
|
if (days.length > 0) {
|
||||||
setSelectedDayIndex(days.length - 1);
|
const newIndex = days.length - 1;
|
||||||
|
setSelectedDayIndex(prev => (prev !== newIndex ? newIndex : prev));
|
||||||
}
|
}
|
||||||
}, [days]);
|
}, [days]);
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div >
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Grid Container */}
|
{/* Grid Container */}
|
||||||
@@ -176,27 +177,27 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
|
|||||||
{visibleDays.map((day) => (
|
{visibleDays.map((day) => (
|
||||||
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
|
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
|
||||||
{getDayName(day)}
|
{getDayName(day)}
|
||||||
</div >
|
</div>
|
||||||
))}
|
))}
|
||||||
</div >
|
</div>
|
||||||
|
|
||||||
{/* Time Grid Container */}
|
{/* Time Grid Container */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: tier === 'desktop'
|
gridTemplateColumns: tier === 'desktop'
|
||||||
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
|
||||||
: `4rem repeat(${visibleDays.length}, 1fr)`
|
: `4rem repeat(${visibleDays.length}, 1fr)`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Time Rows */}
|
{/* Time Rows */}
|
||||||
{timeSteps.map((step) => (
|
{timeSteps.map((step) => (
|
||||||
<Fragment key={step}>
|
<Fragment key={step}>
|
||||||
{/* Time Label */}
|
{/* Time Label */}
|
||||||
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
|
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
|
||||||
{step % 60 === 0 ? formatTimeLabel(step) : ''}
|
{step % 60 === 0 ? formatTimeLabel(step) : ''}
|
||||||
</div >
|
</div>
|
||||||
|
|
||||||
{/* Grid Cells */}
|
{/* Grid Cells */}
|
||||||
{visibleDays.map((day) => {
|
{visibleDays.map((day) => {
|
||||||
@@ -220,11 +221,11 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
|
|||||||
transform: 'translateY(-50%)'
|
transform: 'translateY(-50%)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div >
|
</div>
|
||||||
</div >
|
</div>
|
||||||
</div >
|
</div>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Timetable;
|
export default Timetable;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import { createContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
const STORAGE_KEY = 'baby-sleep-settings';
|
const STORAGE_KEY = 'baby-sleep-settings';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { SettingsContext, Settings } from './SettingsProvider';
|
import { SettingsContext } from './SettingsProvider';
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const context = useContext(SettingsContext);
|
const context = useContext(SettingsContext);
|
||||||
|
|||||||
@@ -38,9 +38,15 @@ export function adjustForTimezone(utcMinutes: number): number {
|
|||||||
* @returns string in "YYYY-MM-DDTHH:MM" format
|
* @returns string in "YYYY-MM-DDTHH:MM" format
|
||||||
*/
|
*/
|
||||||
export function toLocalDatetimeInputValue(date: Date): string {
|
export function toLocalDatetimeInputValue(date: Date): string {
|
||||||
const offset = date.getTimezoneOffset() * 60 * 1000;
|
const formatter = new Intl.DateTimeFormat('sv-SE', {
|
||||||
const localDate = new Date(date.getTime() - offset);
|
year: 'numeric',
|
||||||
return localDate.toISOString().slice(0, 16);
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
return formatter.format(date).replace(' ', 'T');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatElapsedTime(utcTimestamp: string): string {
|
export function formatElapsedTime(utcTimestamp: string): string {
|
||||||
@@ -192,3 +198,15 @@ export function formatMinutes(totalMinutes: number): string {
|
|||||||
}
|
}
|
||||||
return `${mins}m`;
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a backend timestamp string into a Date object,
|
||||||
|
* ensuring it is treated as UTC if no timezone info is present.
|
||||||
|
*/
|
||||||
|
export function parseBackendDateTime(time: string): Date {
|
||||||
|
if (typeof time === 'string' && !time.endsWith('Z') && !time.includes('+')) {
|
||||||
|
return new Date(time + 'Z');
|
||||||
|
}
|
||||||
|
// If it already has Z or offset, new Date() will handle it correctly.
|
||||||
|
return new Date(time);
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __APP_GITSHA__: string;
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getLocalOffsetFromReference,
|
getLocalOffsetFromReference,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
toUTCFromLocalInput,
|
toUTCFromLocalInput,
|
||||||
|
parseBackendDateTime,
|
||||||
} from '../../src/utils/time';
|
} from '../../src/utils/time';
|
||||||
|
|
||||||
describe('formatMinutesAfter', () => {
|
describe('formatMinutesAfter', () => {
|
||||||
@@ -167,4 +168,29 @@ describe('toUTCFromLocalInput', () => {
|
|||||||
expect(toUTCFromLocalInput('2026-06-0')).toBe('');
|
expect(toUTCFromLocalInput('2026-06-0')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('parseBackendDateTime', () => {
|
||||||
|
it('should parse a UTC string without Z as UTC', () => {
|
||||||
|
const time = '2026-06-04 13:58:00';
|
||||||
|
const date = parseBackendDateTime(time);
|
||||||
|
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse a UTC string with Z as UTC', () => {
|
||||||
|
const time = '2026-06-04T13:58:00Z';
|
||||||
|
const date = parseBackendDateTime(time);
|
||||||
|
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse a string with an offset correctly', () => {
|
||||||
|
const time = '2026-06-04T13:58:00+02:00';
|
||||||
|
const date = parseBackendDateTime(time);
|
||||||
|
expect(date.toISOString()).toBe('2026-06-04T11:58:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ISO format with T', () => {
|
||||||
|
const time = '2026-06-04T13:58:00';
|
||||||
|
const date = parseBackendDateTime(time);
|
||||||
|
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toLocalDatetimeInputValue } from '../../src/utils/time';
|
||||||
|
|
||||||
|
describe('toLocalDatetimeInputValue - Timezone Correctness', () => {
|
||||||
|
it('converts a UTC date to the correct local datetime-local input value (Stockholm)', () => {
|
||||||
|
// 11:58 UTC on June 4th, 2026 should be 13:58 in Stockholm (UTC+2)
|
||||||
|
const date = new Date('2026-06-04T11:58:00Z');
|
||||||
|
|
||||||
|
// We calculate the expected value using a deterministic formatter with explicit timezone
|
||||||
|
const expected = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Europe/Stockholm'
|
||||||
|
}).format(date).replace(' ', 'T');
|
||||||
|
|
||||||
|
const result = toLocalDatetimeInputValue(date);
|
||||||
|
|
||||||
|
// This assertion will fail if the function returns UTC instead of Stockholm local time
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts a UTC date to the correct local datetime-local input value (other timezone)', () => {
|
||||||
|
// Testing with another timezone to ensure it's not hardcoded (though it shouldn't be)
|
||||||
|
const date = new Date('2026-06-04T11:58:00Z');
|
||||||
|
|
||||||
|
// Tokyo is UTC+9. 11:58 UTC -> 20:58 Tokyo
|
||||||
|
const expectedTokyo = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Asia/Tokyo'
|
||||||
|
}).format(date).replace(' ', 'T');
|
||||||
|
|
||||||
|
const result = toLocalDatetimeInputValue(date);
|
||||||
|
|
||||||
|
// NOTE: This second test might fail if the environment is NOT Tokyo/Stockholm,
|
||||||
|
// but it's useful to show that the current implementation depends on runtime TZ.
|
||||||
|
// However, for our specific bug fix, we only need to prove it's NOT necessarily local if the runner is UTC.
|
||||||
|
// Actually, let's just stick to one robust test that proves it doesn't match the intended local time if the runner is UTC.
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-1
@@ -2,10 +2,29 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' || process.env.vite === 'true';
|
||||||
|
|
||||||
|
let APP_VERSION = process.env.VITE_APP_VERSION || 'unknown';
|
||||||
|
let APP_GITSHA = process.env.VITE_APP_GITSHA || 'unknown';
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
APP_VERSION = 'DEV';
|
||||||
|
try {
|
||||||
|
APP_GITSHA = execSync('git rev-parse --short HEAD').toString().trim();
|
||||||
|
} catch (e) {
|
||||||
|
APP_GITSHA = 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||||
|
__APP_GITSHA__: JSON.stringify(APP_GITSHA),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
@@ -19,4 +38,4 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
podman save babymonitor-frontend:latest -o frontend.tar
|
|
||||||
|
|
||||||
podman save babymonitor-backend:latest -o backend.tar
|
|
||||||
|
|
||||||
scp frontend.tar backend.tar jacobhagstedt@raspberrypi.local:~/
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"failedTests": []
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const adjustForTimezone = (utcMinutes) => {
|
|
||||||
const offsetInMinutes = -120; // Simulate UTC+2
|
|
||||||
return utcMinutes - offsetInMinutes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMinutesAfter = (minutes, reference = "04:00") => {
|
|
||||||
const [refHours, refMinutes] = reference.split(":").map(Number);
|
|
||||||
const totalRefMinutes = refHours * 60 + refMinutes;
|
|
||||||
const targetMinutes = totalRefMinutes + minutes;
|
|
||||||
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
|
|
||||||
const hours = Math.floor(normalizedMinutes / 60);
|
|
||||||
const mins = normalizedMinutes % 60;
|
|
||||||
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Case 1: Backend sends offset from 04:00 UTC (-5 minutes)
|
|
||||||
const backendOffset = -5;
|
|
||||||
const adjusted = adjustForTimezone(backendOffset);
|
|
||||||
console.log("Adjusted offset:", adjusted); // Should be 115
|
|
||||||
console.log("Formatted time:", formatMinutesAfter(adjusted, "04:00")); // Should be 05:55
|
|
||||||
|
|
||||||
// Case 2: Backend sends minutes from UTC midnight (235 minutes)
|
|
||||||
const backendMidnightMins = 235;
|
|
||||||
const adjusted2 = adjustForTimezone(backendMidnightMins);
|
|
||||||
console.log("Adjusted midnight mins:", adjusted2); // Should be 355
|
|
||||||
// If we use it as an offset from reference:
|
|
||||||
console.log("Formatted time (as offset):", formatMinutesAfter(adjusted2, "04:00")); // Should be 09:55 (WRONG if it was supposed to be 05:55)
|
|
||||||
|
|
||||||
// If we want the offset from reference for Case 2:
|
|
||||||
const refMins = 4 * 60;
|
|
||||||
const correctOffset = adjusted2 - refMins; // 355 - 240 = 115
|
|
||||||
console.log("Corrected offset (from midnight):", correctOffset); // 115
|
|
||||||
console.log("Formatted time (corrected):", formatMinutesAfter(correctOffset, "04:00")); // 05:55
|
|
||||||
@@ -19,6 +19,17 @@ exitcode '1': failure
|
|||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Editing a sleep session in "sleep sessions", when I click the edit button the times show up as UTC but should show as my local timezone
|
- [ ] Editing a sleep session in "sleep sessions", when I click the edit button the times show up as UTC but should show as my local timezone
|
||||||
- [ ] We should drop the "notes" column from the sleep_session table
|
- [x] We should drop the "notes" column from the sleep_session table
|
||||||
- [ ] Switch over to oh-my-opencode and try it out
|
- [x] Switch over to oh-my-opencode and try it out
|
||||||
- [ ] Adda footer at the bottom of the app that shows the version (aka git tag) of the app
|
- [x] Adda footer at the bottom of the app that shows the version (aka git tag) of the app
|
||||||
|
- [x] Fix race condition of gitea workflows
|
||||||
|
|
||||||
|
If both workflows run at the same time, a race condition occurs:
|
||||||
|
1. Collision: Both workflows will query the latest tag (e.g., v1.0.5) and calculate the same next version (v1.0.6).
|
||||||
|
2. The Winner: The first workflow to finish its push will successfully create the v1.0.6 tag on the remote.
|
||||||
|
3. The Loser: The second workflow will attempt to push v1.0.6, but the remote will reject it because that tag already exists, causing the workflow to fail.
|
||||||
|
This happens because both workflows share the same global tag namespace (v1.0.x). To fix this, you should differentiate them by using unique prefixes, such as backend-v1.0.x and frontend-v1.0.x.
|
||||||
|
|
||||||
|
--> Invalid. the containers are named differently.
|
||||||
|
|
||||||
|
- [x] remove the lint form the gitea workflows added by the agent. makes no sense there
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import datetime
|
|
||||||
|
|
||||||
def verify_real_wakeup_with_mock_api():
|
|
||||||
# --- DATA FROM DATABASE (REAL) ---
|
|
||||||
# session id 1 wakeup: 2026-05-27 03:17:00 UTC
|
|
||||||
wakeup_utc = datetime.datetime(2026, 5, 27, 3, 17)
|
|
||||||
wakeup_utc_mins = wakeup_utc.hour * 60 + wakeup_utc.minute
|
|
||||||
|
|
||||||
# --- SETUP PARAMETERS (CLIENT SIDE) ---
|
|
||||||
# User is in Stockholm (+2)
|
|
||||||
offset_in_minutes = -120
|
|
||||||
local_ref_str = "04:00"
|
|
||||||
local_ref_mins = 4 * 60
|
|
||||||
|
|
||||||
print(f"1. Database Wakeup (UTC): {wakeup_utc} ({wakeup_utc_mins}m)")
|
|
||||||
print(f"2. Client Local Reference: {local_ref_str} ({local_ref_mins}m)")
|
|
||||||
print(f"3. Client Timezone Offset: {offset_in_minutes}m")
|
|
||||||
|
|
||||||
# --- STEP 1: SIMULATE API CALL (CLIENT SIDE) ---
|
|
||||||
def localHHMMToUTCHHMM(hhmm, offset):
|
|
||||||
[h, m] = map(int, hhmm.split(":"))
|
|
||||||
total_mins_utc = (h * 60 + m) + offset
|
|
||||||
norm = ((total_mins_utc % 1440) + 1440) % 1440
|
|
||||||
return f"{norm // 60:02d}:{norm % 60:02d}"
|
|
||||||
|
|
||||||
utc_ref = localHHMMToUTCHHMM(local_ref_str, offset_in_minutes)
|
|
||||||
print(f"4. Simulated API call with wakeup_ref (converted to UTC): {utc_ref}")
|
|
||||||
|
|
||||||
# --- STEP 2: SIMULATE BACKEND (SERVER SIDE) ---
|
|
||||||
# Backend receives utc_ref and calculates delta: wakeup_utc - utc_ref
|
|
||||||
utc_ref_mins = (int(utc_ref.split(":")[0]) * 60 + int(utc_ref.split(":")[1]))
|
|
||||||
backend_delta = wakeup_utc_mins - utc_ref_mins
|
|
||||||
print(f"5. Backend returns delta: {backend_delta}")
|
|
||||||
|
|
||||||
# --- STEP 3: SIMULATE COMPONENT RENDERING (CLIENT SIDE) ---
|
|
||||||
# Component receives delta and uses it directly as offsetFromRef.
|
|
||||||
offset_from_ref = backend_delta
|
|
||||||
|
|
||||||
def formatMinutesAfter(minutes, ref):
|
|
||||||
[rh, rm] = map(int, ref.split(":"))
|
|
||||||
target = (rh * 60 + rm) + minutes
|
|
||||||
norm = ((target % 1440) + 1440) % 1440
|
|
||||||
return f"{norm // 60:02d}:{norm % 60:02d}"
|
|
||||||
|
|
||||||
displayed_time = formatMinutesAfter(offset_from_ref, local_ref_str)
|
|
||||||
print(f"6. Component renders time: {displayed_time}")
|
|
||||||
|
|
||||||
# --- ASSERTION ---
|
|
||||||
expected_local_time = "05:17"
|
|
||||||
print(f"\nASSERTION: Expected {expected_local_time}, Actual {displayed_time}")
|
|
||||||
if displayed_time == expected_local_time:
|
|
||||||
print("VERIFICATION SUCCESSFUL!")
|
|
||||||
else:
|
|
||||||
print("VERIFICATION FAILED!")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
verify_real_wakeup_with_mock_api()
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import datetime
|
|
||||||
|
|
||||||
def solve():
|
|
||||||
# Scenario: User in Stockholm (UTC+2)
|
|
||||||
# Local Wakeup Reference: 04:00
|
|
||||||
# Actual Wakeup Time (UTC): 03:17
|
|
||||||
|
|
||||||
offset_minutes = -120 # Stockholm UTC+2 -> getTimezoneOffset() is -120
|
|
||||||
|
|
||||||
wakeup_ref_local = "04:00"
|
|
||||||
wakeup_ref_mins_local = 4 * 60
|
|
||||||
|
|
||||||
# Let's assume the backend calculates: wakeup_minutes = (UTC_wakeup - UTC_ref)
|
|
||||||
# where it treats the provided ref as if it were in UTC.
|
|
||||||
|
|
||||||
# Case 1: BUGGY (Frontend sends Local ref "04:00" to backend)
|
|
||||||
# Backend treats 04:00 as UTC.
|
|
||||||
utc_wakeup = 3 * 60 + 17 # 03:17 UTC
|
|
||||||
wakeup_minutes_from_backend = utc_wakeup - wakeup_ref_mins_local # 197 - 240 = -43
|
|
||||||
print(f"BUGGY: Backend returns wakeup_minutes (assuming ref 04:00 is UTC): {wakeup_minutes_from_backend}")
|
|
||||||
|
|
||||||
# Frontend (current) tries to fix it with adjustForTimezone:
|
|
||||||
received_wakeup_minutes = wakeup_minutes_from_backend
|
|
||||||
# offsetFromRef = getLocalOffsetFromReference(received, "04:00")
|
|
||||||
# localMins = adjustForTimezone(received) = received - offset_minutes
|
|
||||||
# offsetFromRef = localMins - ref_mins_local
|
|
||||||
|
|
||||||
def adjustForTimezone(utc_min):
|
|
||||||
return utc_min - offset_minutes
|
|
||||||
|
|
||||||
def getLocalOffsetFromReference(utc_min, ref_mins):
|
|
||||||
local_m = adjustForTimezone(utc_min)
|
|
||||||
return local_m - ref_mins
|
|
||||||
|
|
||||||
offset_from_ref = getLocalOffsetFromReference(received_wakeup_minutes, wakeup_ref_mins_local)
|
|
||||||
print(f"BUGGY: Frontend calculates offsetFromRef: {offset_from_ref}")
|
|
||||||
# formatMinutesAfter(offset, ref) -> ref + offset
|
|
||||||
print(f"BUGGY: Displayed time (ref + offset): {wakeup_ref_mins_local + offset_from_ref}")
|
|
||||||
|
|
||||||
# Case 2: CORRECT (Frontend sends UTC ref "02:00" to backend)
|
|
||||||
# Stockholm +2 means 04:00 Local is 02:00 UTC.
|
|
||||||
utc_wakeup_ref = wakeup_ref_mins_local + offset_minutes # 240 - 120 = 120
|
|
||||||
wakeup_minutes_from_backend_correct = utc_wakeup - utc_wakeup_ref # 197 - 120 = 77
|
|
||||||
print(f"\nCORRECT: Backend returns wakeup_minutes (assuming ref 02:00 is UTC): {wakeup_minutes_from_backend_correct}")
|
|
||||||
|
|
||||||
# Frontend receives 77.
|
|
||||||
# If it's a delta, the component should use it directly as offsetFromRef.
|
|
||||||
# Or if we want to keep existing logic, we have to realize 77 is ALREADY the delta.
|
|
||||||
# But the component thinks it's "minutes from midnight".
|
|
||||||
# This is a mismatch.
|
|
||||||
|
|
||||||
print("---")
|
|
||||||
print(f"Target Local Wakeup: 05:17 ({3 * 60 + 17 + abs(offset_minutes)})")
|
|
||||||
print(f"Target Local Ref: 04:00 ({wakeup_ref_mins_local})")
|
|
||||||
print(f"Target Local Delta: {317 - 240} = 77")
|
|
||||||
|
|
||||||
solve()
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Plan: Fix Timezone Handling in Wakeup Times Module
|
|
||||||
|
|
||||||
## Problem Description
|
|
||||||
The "Wakeup Times" chart (in `frontend/src/components/stats/WakeupPlot.tsx`) displays wakeup times incorrectly due to a timezone mismatch.
|
|
||||||
- **Observation**: If a baby wakes up at `05:55` local time (e.g., in UTC+2), the chart shows `03:55`.
|
|
||||||
- **Cause**: The backend stores times in UTC. The frontend currently receives minutes relative to UTC midnight and renders them directly using `formatMinutesAfter` without applying the client's local timezone offset.
|
|
||||||
|
|
||||||
## Investigation Steps
|
|
||||||
1. **Backend Verification**:
|
|
||||||
- Examine `backend/app/routers/stats.py` (or equivalent) and the CRUD logic to confirm that `wakeup_minutes` is calculated based on UTC midnight.
|
|
||||||
- Verify how the backend handles the `wakeup_ref` and `bedtime_ref` parameters.
|
|
||||||
2. **Frontend Logic Audit**:
|
|
||||||
- Trace the data flow from `fetchWakeSleepTimes` -> `useStats` -> `WakeupPlot.tsx`.
|
|
||||||
- Confirm that `wakeup_minutes` is indeed a raw offset from UTC midnight.
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
1. **Calculate Local Offset**: Use `new Date().getTimezoneOffset()` in the frontend to determine the difference between UTC and local time.
|
|
||||||
2. **Adjust Minutes**:
|
|
||||||
- In `WakeupPlot.tsx`, modify the mapping logic to adjust `day.wakeup_minutes` by the local timezone offset before it is passed to `formatMinutesAfter`.
|
|
||||||
- Formula: `local_minutes = utc_minutes - (timezone_offset_in_minutes)`.
|
|
||||||
*(Note: `getTimezoneOffset()` returns minutes positive for west of UTC, negative for east, so subtraction is correct to add offset to UTC).*
|
|
||||||
3. **Refactor Utilities (if needed)**: If the logic is used elsewhere, consider moving timezone-aware formatting to `frontend/src/utils/time.ts`.
|
|
||||||
|
|
||||||
## Verification Plan
|
|
||||||
1. **Manual Verification**:
|
|
||||||
- Mock a response from the backend with a known UTC time.
|
|
||||||
- Check if the rendered string matches the expected local time.
|
|
||||||
2. **Automated Testing**:
|
|
||||||
- Add/update a test case in `frontend/tests/e2e/stats.spec.ts` (or a unit test in `frontend/tests/`) that simulates different timezone offsets to ensure the display is correct.
|
|
||||||
3. **Regression Check**:
|
|
||||||
- Ensure `BedtimePlot.tsx` and other time-related components are not negatively impacted by changes to shared utilities.
|
|
||||||
Reference in New Issue
Block a user