Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/Schedule/CurveEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function CurveEditor({ side }: CurveEditorProps) {
await Promise.all(deletePromises)

// Create new temperature schedule entries
const createPromises = Object.entries(scheduleTemps).map(([time, temperature]) =>
const createPromises: Promise<unknown>[] = Object.entries(scheduleTemps).map(([time, temperature]) =>
createTempSchedule.mutateAsync({
side,
dayOfWeek: day,
Expand All @@ -136,7 +136,7 @@ export function CurveEditor({ side }: CurveEditorProps) {
offTime: wakeTime,
onTemperature: Math.max(55, Math.min(110, 80 + curvePoints[0].tempOffset)),
enabled: true,
}) as Promise<unknown> as Promise<{ id: number }>,
}),
)

await Promise.all(createPromises)
Expand Down
2 changes: 1 addition & 1 deletion src/components/Sensors/BedTempMatrix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export function BedTempMatrix() {
// Stored data already converted to user's unit by the tRPC endpoint
return {
source: 'stored' as const,
timestamp: stored.timestamp ? Math.floor(new Date(stored.timestamp as string).getTime() / 1000) : undefined,
timestamp: stored.timestamp ? Math.floor(stored.timestamp.getTime() / 1000) : undefined,
ambientTemp: formatConverted(stored.ambientTemp),
mcuTemp: formatConverted(stored.mcuTemp),
humidity: stored.humidity != null ? `${Math.round(stored.humidity)}%` : undefined,
Expand Down
26 changes: 13 additions & 13 deletions src/components/status/WaterLevelCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { useState, useCallback, useMemo } from 'react'
import { trpc } from '@/src/utils/trpc'
import { Droplets, Play, X, AlertTriangle, TrendingDown, TrendingUp, Minus, Loader2 } from 'lucide-react'

function trendIcon(direction: string) {
if (direction === 'falling') return <TrendingDown size={12} className="text-amber-400" />
if (direction === 'rising') return <TrendingUp size={12} className="text-emerald-400" />
function trendIcon(trend: string) {
if (trend === 'declining') return <TrendingDown size={12} className="text-amber-400" />
if (trend === 'rising') return <TrendingUp size={12} className="text-emerald-400" />
return <Minus size={12} className="text-zinc-500" />
}

Expand Down Expand Up @@ -84,7 +84,7 @@ export function WaterLevelCard() {
<Droplets size={16} className="text-sky-400" />
<span className="text-sm font-medium text-white">Water Level</span>
</div>
{trend && trendIcon(trend.direction)}
{trend && trendIcon(trend.trend)}
</div>

{/* Current level */}
Expand All @@ -99,9 +99,7 @@ export function WaterLevelCard() {
<div className="flex items-end gap-3">
<div>
<p className="text-2xl font-bold tabular-nums text-white">
{typeof latest.levelPercent === 'number'
? `${Math.round(latest.levelPercent)}%`
: '--'}
{latest.level === 'ok' ? 'OK' : 'Low'}
</p>
<p className="text-[10px] text-zinc-500">
{new Date(latest.timestamp).toLocaleTimeString([], {
Expand All @@ -112,14 +110,16 @@ export function WaterLevelCard() {
</div>
{trend && (
<div className="mb-1 text-xs text-zinc-500">
{trend.direction !== 'stable' && (
{trend.trend === 'stable' && <span>Stable</span>}
{trend.trend === 'declining' && (
<span>
{(trend.changePercent ?? 0) > 0 ? '+' : ''}
{(trend.changePercent ?? 0).toFixed(1)}
% / 24h
Declining (
{trend.lowPercent}
% low)
</span>
)}
{trend.direction === 'stable' && <span>Stable</span>}
{trend.trend === 'rising' && <span>Rising</span>}
{trend.trend === 'unknown' && <span>Insufficient data</span>}
</div>
)}
</div>
Expand All @@ -134,7 +134,7 @@ export function WaterLevelCard() {
{/* Active alerts */}
{activeAlerts.length > 0 && (
<div className="space-y-1.5">
{activeAlerts.map((alert: { id: number, alertType: string, message: string }) => (
{activeAlerts.map(alert => (
<div
key={alert.id}
className="flex items-center gap-2 rounded-lg bg-amber-900/20 px-3 py-2"
Expand Down
21 changes: 10 additions & 11 deletions src/components/status/WaterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { useState, useCallback, useMemo } from 'react'
import { trpc } from '@/src/utils/trpc'
import { X, Droplets, Play, AlertTriangle, TrendingDown, TrendingUp, Minus, Loader2 } from 'lucide-react'

function trendIcon(direction: string) {
if (direction === 'falling') return <TrendingDown size={14} className="text-amber-400" />
if (direction === 'rising') return <TrendingUp size={14} className="text-emerald-400" />
function trendIcon(trend: string) {
if (trend === 'declining') return <TrendingDown size={14} className="text-amber-400" />
if (trend === 'rising') return <TrendingUp size={14} className="text-emerald-400" />
return <Minus size={14} className="text-zinc-500" />
}

Expand Down Expand Up @@ -99,9 +99,7 @@ export function WaterModal({ open, onClose }: { open: boolean, onClose: () => vo
<div className="flex items-end gap-3">
<div>
<p className="text-3xl font-bold tabular-nums text-white">
{typeof latest.levelPercent === 'number'
? `${Math.round(latest.levelPercent)}%`
: '--'}
{latest.level === 'ok' ? 'OK' : 'Low'}
</p>
<p className="text-[11px] text-zinc-500">
{new Date(latest.timestamp).toLocaleTimeString([], {
Expand All @@ -112,11 +110,12 @@ export function WaterModal({ open, onClose }: { open: boolean, onClose: () => vo
</div>
{trend && (
<div className="mb-1.5 flex items-center gap-1.5">
{trendIcon(trend.direction)}
{trendIcon(trend.trend)}
<span className="text-xs text-zinc-500">
{trend.direction !== 'stable'
? `${(trend.changePercent ?? 0) > 0 ? '+' : ''}${(trend.changePercent ?? 0).toFixed(1)}% / 24h`
: 'Stable'}
{trend.trend === 'stable' && 'Stable'}
{trend.trend === 'declining' && `Declining (${trend.lowPercent}% low)`}
{trend.trend === 'rising' && 'Rising'}
{trend.trend === 'unknown' && 'Insufficient data'}
</span>
</div>
)}
Expand All @@ -132,7 +131,7 @@ export function WaterModal({ open, onClose }: { open: boolean, onClose: () => vo
{/* Active alerts */}
{activeAlerts.length > 0 && (
<div className="space-y-1.5">
{activeAlerts.map((alert: { id: number, alertType: string, message: string }) => (
{activeAlerts.map(alert => (
<div key={alert.id} className="flex items-center gap-2 rounded-lg bg-amber-900/20 px-3 py-2">
<AlertTriangle size={12} className="shrink-0 text-amber-400" />
<span className="flex-1 text-[11px] text-amber-300">{alert.message}</span>
Expand Down
10 changes: 7 additions & 3 deletions src/hooks/useSchedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export interface TemperatureSchedule {
time: string
temperature: number
enabled: boolean
createdAt: string | Date
updatedAt: string | Date
createdAt: Date
updatedAt: Date
}

export interface PowerSchedule {
Expand All @@ -24,18 +24,22 @@ export interface PowerSchedule {
offTime: string
onTemperature: number
enabled: boolean
createdAt: Date
updatedAt: Date
}

export interface AlarmSchedule {
id: number
side: 'left' | 'right'
dayOfWeek: DayOfWeek
time: string
vibrationPattern: string
vibrationPattern: 'double' | 'rise'
vibrationIntensity: number
duration: number
alarmTemperature: number
enabled: boolean
createdAt: Date
updatedAt: Date
}

export interface ScheduleData {
Expand Down
61 changes: 55 additions & 6 deletions src/server/routers/biometrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,17 @@ export const biometricsRouter = router({
*/
getSleepRecords: publicProcedure
.meta({ openapi: { method: 'GET', path: '/biometrics/sleep-records', protect: false, tags: ['Biometrics'] } })
.output(z.any())
.output(z.array(z.object({
id: z.number(),
side: sideSchema,
enteredBedAt: z.date(),
leftBedAt: z.date(),
sleepDurationSeconds: z.number(),
timesExitedBed: z.number(),
presentIntervals: z.unknown(),
notPresentIntervals: z.unknown(),
createdAt: z.date(),
})))
Comment on lines +58 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Allow leftBedAt to be nullable in sleep-record output schemas.

leftBedAt is treated as nullable for active sessions in this router (see Line 745 and Line 755), but these outputs require a non-null z.date(). That can fail runtime output validation for active records.

💡 Suggested fix
 .output(z.array(z.object({
   id: z.number(),
   side: sideSchema,
   enteredBedAt: z.date(),
-  leftBedAt: z.date(),
+  leftBedAt: z.date().nullable(),
   sleepDurationSeconds: z.number(),
   timesExitedBed: z.number(),
   presentIntervals: z.unknown(),
   notPresentIntervals: z.unknown(),
   createdAt: z.date(),
 })))
 .output(z.object({
   id: z.number(),
   side: sideSchema,
   enteredBedAt: z.date(),
-  leftBedAt: z.date(),
+  leftBedAt: z.date().nullable(),
   sleepDurationSeconds: z.number(),
   timesExitedBed: z.number(),
   presentIntervals: z.unknown(),
   notPresentIntervals: z.unknown(),
   createdAt: z.date(),
 }).nullable())
 .output(z.object({
   id: z.number(),
   side: sideSchema,
   enteredBedAt: z.date(),
-  leftBedAt: z.date(),
+  leftBedAt: z.date().nullable(),
   sleepDurationSeconds: z.number(),
   timesExitedBed: z.number(),
   presentIntervals: z.unknown(),
   notPresentIntervals: z.unknown(),
   createdAt: z.date(),
 }))

Also applies to: 310-320, 572-582

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routers/biometrics.ts` around lines 58 - 68, The sleep-record
output schemas in src/server/routers/biometrics.ts declare leftBedAt as z.date()
but active sessions allow null; update the schema(s) that define leftBedAt (the
array/object outputs around the .output(...) that include id, side,
enteredBedAt, leftBedAt, sleepDurationSeconds, etc.) to make leftBedAt nullable
(use z.date().nullable()) so runtime validation accepts active records; apply
the same change to the other occurrences of the sleep-record output schema
patterns (the similar blocks around the other two occurrences referenced).

.input(
z
.object({
Expand Down Expand Up @@ -133,7 +143,14 @@ export const biometricsRouter = router({
*/
getVitals: publicProcedure
.meta({ openapi: { method: 'GET', path: '/biometrics/vitals', protect: false, tags: ['Biometrics'] } })
.output(z.any())
.output(z.array(z.object({
id: z.number(),
side: sideSchema,
timestamp: z.date(),
heartRate: z.number().nullable(),
hrv: z.number().nullable(),
breathingRate: z.number().nullable(),
})))
.input(
z
.object({
Expand Down Expand Up @@ -210,7 +227,12 @@ export const biometricsRouter = router({
*/
getMovement: publicProcedure
.meta({ openapi: { method: 'GET', path: '/biometrics/movement', protect: false, tags: ['Biometrics'] } })
.output(z.any())
.output(z.array(z.object({
id: z.number(),
side: sideSchema,
timestamp: z.date(),
totalMovement: z.number(),
})))
.input(
z
.object({
Expand Down Expand Up @@ -285,7 +307,17 @@ export const biometricsRouter = router({
})
.strict()
)
.output(z.any())
.output(z.object({
id: z.number(),
side: sideSchema,
enteredBedAt: z.date(),
leftBedAt: z.date(),
sleepDurationSeconds: z.number(),
timesExitedBed: z.number(),
presentIntervals: z.unknown(),
notPresentIntervals: z.unknown(),
createdAt: z.date(),
}).nullable())
.query(async ({ input }) => {
try {
const [record] = await biometricsDb
Expand Down Expand Up @@ -330,7 +362,14 @@ export const biometricsRouter = router({
*/
getVitalsSummary: publicProcedure
.meta({ openapi: { method: 'GET', path: '/biometrics/vitals/summary', protect: false, tags: ['Biometrics'] } })
.output(z.any())
.output(z.object({
avgHeartRate: z.number().nullable(),
minHeartRate: z.number().nullable(),
maxHeartRate: z.number().nullable(),
avgHRV: z.number().nullable(),
avgBreathingRate: z.number().nullable(),
recordCount: z.number(),
}).nullable())
.input(
z
.object({
Expand Down Expand Up @@ -530,7 +569,17 @@ export const biometricsRouter = router({
timesExitedBed: z.number().int().min(0).optional(),
}).strict()
)
.output(z.any())
.output(z.object({
id: z.number(),
side: sideSchema,
enteredBedAt: z.date(),
leftBedAt: z.date(),
sleepDurationSeconds: z.number(),
timesExitedBed: z.number(),
presentIntervals: z.unknown(),
notPresentIntervals: z.unknown(),
createdAt: z.date(),
}))
.mutation(async ({ input }) => {
const { id, ...updates } = input

Expand Down
27 changes: 25 additions & 2 deletions src/server/routers/calibration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,21 @@ export const calibrationRouter = router({
sensorType: sensorTypeSchema.optional(),
limit: z.number().int().min(1).max(50).default(10),
}).strict())
.output(z.any())
.output(z.array(z.object({
id: z.number(),
side: z.enum(['left', 'right']),
sensorType: z.enum(['piezo', 'capacitance', 'temperature']),
status: z.enum(['completed', 'failed']),
parameters: z.unknown(),
qualityScore: z.number().nullable(),
sourceWindowStart: z.number().nullable(),
sourceWindowEnd: z.number().nullable(),
samplesUsed: z.number().nullable(),
errorMessage: z.string().nullable(),
durationMs: z.number().nullable(),
triggeredBy: z.enum(['daily', 'manual', 'startup']),
createdAt: z.date(),
})))
.query(async ({ input }) => {
const conditions = [eq(calibrationRuns.side, input.side)]
if (input.sensorType) {
Expand Down Expand Up @@ -194,7 +208,16 @@ export const calibrationRouter = router({
endDate: z.date().optional(),
limit: z.number().int().min(1).max(500).default(100),
}).strict())
.output(z.any())
.output(z.array(z.object({
id: z.number(),
vitalsId: z.number(),
side: z.enum(['left', 'right']),
timestamp: z.date(),
qualityScore: z.number(),
flags: z.unknown(),
hrRaw: z.number().nullable(),
createdAt: z.date(),
})))
.query(async ({ input }) => {
const conditions = [eq(vitalsQuality.side, input.side)]
if (input.startDate) {
Expand Down
40 changes: 38 additions & 2 deletions src/server/routers/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,38 @@ export const deviceRouter = router({
getStatus: publicProcedure
.meta({ openapi: { method: 'GET', path: '/device/status', protect: false, tags: ['Device'] } })
.input(z.object({ unit: z.enum(['F', 'C']).default('F') }).strict())
.output(z.any())
.output(z.object({
leftSide: z.object({
currentTemperature: z.number(),
targetTemperature: z.number(),
currentLevel: z.number(),
targetLevel: z.number(),
heatingDuration: z.number(),
isAlarmVibrating: z.boolean().optional(),
}),
rightSide: z.object({
currentTemperature: z.number(),
targetTemperature: z.number(),
currentLevel: z.number(),
targetLevel: z.number(),
heatingDuration: z.number(),
isAlarmVibrating: z.boolean().optional(),
}),
waterLevel: z.enum(['low', 'ok']),
isPriming: z.boolean(),
podVersion: z.enum(['H00', 'I00', 'J00']),
sensorLabel: z.string(),
gestures: z.object({
doubleTap: z.object({ l: z.number(), r: z.number() }).optional(),
tripleTap: z.object({ l: z.number(), r: z.number() }).optional(),
quadTap: z.object({ l: z.number(), r: z.number() }).optional(),
}).optional(),
primeCompletedNotification: z.object({ timestamp: z.number() }).optional(),
snooze: z.object({
left: z.object({ active: z.boolean(), snoozeUntil: z.number().nullable() }),
right: z.object({ active: z.boolean(), snoozeUntil: z.number().nullable() }),
}),
}))
.query(async ({ input }) => {
return withHardwareClient(async (client) => {
const status = await client.getDeviceStatus()
Expand Down Expand Up @@ -557,7 +588,12 @@ export const deviceRouter = router({
command: z.enum(['SET_TEMP', 'SET_ALARM', 'ALARM_LEFT', 'ALARM_RIGHT', 'SET_SETTINGS', 'PRIME', 'DEVICE_STATUS', 'ALARM_CLEAR']),
args: z.string().optional(),
}).strict())
.output(z.any())
.output(z.object({
command: z.string(),
args: z.string().nullable(),
response: z.unknown(),
disclaimer: z.string(),
}))
.mutation(async ({ input }) => {
const hwCommand = COMMAND_MAP[input.command]

Expand Down
Loading
Loading