7 Commits

Author SHA1 Message Date
jacobh2 a86bf2ded4 feat(frontend): remove 'v' prefix from footer and add dev mode versioning
Build Backend / build (push) Successful in 2m42s
Build Frontend / build (push) Successful in 11m0s
2026-06-07 20:31:30 +02:00
jacobh2 969b8e033f No idea what it is doing... very sad 2026-06-07 19:59:31 +02:00
jacobh2 cfc3156906 refactor: update sleep session logic, models, and agent protocols 2026-06-04 16:37:37 +02:00
jacobh2 81eadf618a Update gitignore
Build Frontend / build (push) Successful in 9m6s
2026-06-04 11:33:42 +02:00
jacobh2 6847886e7a chore: add vite environment type definitions 2026-06-04 11:33:21 +02:00
jacobh2 5d8a8b3c57 chore: update deployment and app structure 2026-06-04 11:31:44 +02:00
jacobh2 32d3e86d38 fix(frontend): resolve linting errors and component duplication 2026-06-04 11:30:18 +02:00
19 changed files with 185 additions and 45 deletions
+2
View File
@@ -51,6 +51,8 @@ jobs:
run: |
docker buildx build \
--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 }} \
./frontend \
--push
+2
View File
@@ -5,6 +5,8 @@
*.js
*.jsx
.omo/
### Node ###
# Logs
logs
+6
View File
@@ -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.
- **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
### Backend Development
-1
View File
@@ -9,6 +9,5 @@ class SleepSession(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
start_time: datetime = Field(nullable=False)
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))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
-5
View File
@@ -6,22 +6,17 @@ from pydantic import BaseModel
class SleepSessionCreate(BaseModel):
start_time: datetime
class SleepSessionUpdate(BaseModel):
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
notes: Optional[str] = None
class SleepSessionRead(BaseModel):
id: int
start_time: datetime
end_time: Optional[datetime]
notes: Optional[str]
created_at: datetime
updated_at: datetime
class SessionListResponse(BaseModel):
sessions: List[SleepSessionRead]
total: int
+4
View File
@@ -1,5 +1,9 @@
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
# Copy package files first to leverage Docker cache
+3
View File
@@ -4,6 +4,8 @@ import TrackerPage from './components/tracker/TrackerPage';
import StatsPage from './components/stats/StatsPage';
import Navbar from './components/Navbar';
import Playground from './components/Playground';
import Footer from './components/Footer';
function App() {
return (
@@ -17,6 +19,7 @@ function App() {
<Route path="/playground" element={<Playground />} />
</Routes>
</main>
<Footer />
</div>
</Router>
);
+13
View File
@@ -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}
/>
<Tooltip
formatter={(value: any, name: any) => [
formatMinutes(Math.round(Number(value ?? 0) * 60)),
formatter={(value: number | string | readonly (string | number)[] | undefined, name: string | number | undefined) => [
formatMinutes(Math.round(Number((Array.isArray(value) ? value[0] : value ?? 0) * 60))),
name === 'daySleep' ? 'Day' : 'Night',
]}
/>
+11 -11
View File
@@ -4,7 +4,7 @@ import { useSettings } from '../../hooks/useSettings';
import { Pagination } from './Pagination';
import { Edit2, Check, X, Trash2 } from 'lucide-react';
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() {
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
@@ -29,13 +29,13 @@ export function SessionList() {
const handleSave = async (id: number) => {
try {
await updateSession({
sessionId: id,
payload: {
start_time: new Date(editStart).toISOString(),
end_time: editEnd ? new Date(editEnd).toISOString() : undefined,
}
});
await updateSession({
sessionId: id,
payload: {
start_time: toUTCFromLocalInput(editStart),
end_time: editEnd ? toUTCFromLocalInput(editEnd) : undefined,
},
});
setEditingId(null);
} catch (err) {
console.error(err);
@@ -57,8 +57,8 @@ export function SessionList() {
const startEditing = (s: SleepSessionRead) => {
setEditingId(s.id);
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time)));
setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : '');
setEditStart(toLocalDatetimeInputValue(parseBackendDateTime(s.start_time)));
setEditEnd(s.end_time ? toLocalDatetimeInputValue(parseBackendDateTime(s.end_time)) : '');
};
const handleCancel = () => {
@@ -161,6 +161,6 @@ export function SessionList() {
onPageChange={setCurrentPage}
/>
)}
</div>
</div >
);
}
@@ -52,7 +52,8 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
useEffect(() => {
if (days.length > 0) {
setSelectedDayIndex(days.length - 1);
const newIndex = days.length - 1;
setSelectedDayIndex(prev => (prev !== newIndex ? newIndex : prev));
}
}, [days]);
@@ -158,7 +159,7 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
</button>
);
})}
</div >
</div>
)}
{/* Grid Container */}
@@ -176,27 +177,27 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
{visibleDays.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-2">
{getDayName(day)}
</div >
</div>
))}
</div >
</div>
{/* Time Grid Container */}
<div className="relative">
<div
className="grid"
style={{
gridTemplateColumns: tier === 'desktop'
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
: `4rem repeat(${visibleDays.length}, 1fr)`
}}
>
className="grid"
style={{
gridTemplateColumns: tier === 'desktop'
? `4rem repeat(${days.length}, minmax(40px, 1fr))`
: `4rem repeat(${visibleDays.length}, 1fr)`
}}
>
{/* Time Rows */}
{timeSteps.map((step) => (
<Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-2">
{step % 60 === 0 ? formatTimeLabel(step) : ''}
</div >
</div>
{/* Grid Cells */}
{visibleDays.map((day) => {
@@ -220,11 +221,11 @@ const Timetable = ({ from_date, to_date, day_start, day_end }: TimetableProps) =
transform: 'translateY(-50%)'
}}
/>
</div >
</div >
</div >
</div >
</div>
</div>
</div>
</div>
);
};
export default Timetable;
export default Timetable;
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -1,5 +1,5 @@
import { useContext } from 'react';
import { SettingsContext, Settings } from './SettingsProvider';
import { SettingsContext } from './SettingsProvider';
export function useSettings() {
const context = useContext(SettingsContext);
+21 -3
View File
@@ -38,9 +38,15 @@ export function adjustForTimezone(utcMinutes: number): number {
* @returns string in "YYYY-MM-DDTHH:MM" format
*/
export function toLocalDatetimeInputValue(date: Date): string {
const offset = date.getTimezoneOffset() * 60 * 1000;
const localDate = new Date(date.getTime() - offset);
return localDate.toISOString().slice(0, 16);
const formatter = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
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 {
@@ -192,3 +198,15 @@ export function formatMinutes(totalMinutes: number): string {
}
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);
}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_GITSHA__: string;
+26
View File
@@ -13,6 +13,7 @@ import {
getLocalOffsetFromReference,
formatDateTime,
toUTCFromLocalInput,
parseBackendDateTime,
} from '../../src/utils/time';
describe('formatMinutesAfter', () => {
@@ -167,4 +168,29 @@ describe('toUTCFromLocalInput', () => {
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');
});
});
+48
View File
@@ -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
View File
@@ -2,10 +2,29 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
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({
base: './',
plugins: [react(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(APP_VERSION),
__APP_GITSHA__: JSON.stringify(APP_GITSHA),
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -19,4 +38,4 @@ export default defineConfig({
},
},
},
})
})
+3 -3
View File
@@ -19,10 +19,10 @@ 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
- [ ] We should drop the "notes" column from the sleep_session table
- [x] We should drop the "notes" column from the sleep_session table
- [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
- [-] Fix race condition of gitea workflows
- [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).