From 08944589d3fde4a32ee26f944c9ef530a6c35761 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 9 Apr 2026 22:50:23 -0700 Subject: [PATCH 1/4] Replace all z.any() output schemas with typed Zod schemas 47 tRPC procedures across 11 routers now have proper typed output schemas instead of z.any(). This enables runtime response validation, typed OpenAPI specs, and TypeScript client inference. Closes #329 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/routers/biometrics.ts | 61 ++++++++++++-- src/server/routers/calibration.ts | 27 ++++++- src/server/routers/device.ts | 38 ++++++++- src/server/routers/environment.ts | 84 +++++++++++++++++-- src/server/routers/health.ts | 56 ++++++++++++- src/server/routers/raw.ts | 14 +++- src/server/routers/runOnce.ts | 10 ++- src/server/routers/scheduleGroups.ts | 36 ++++++++- src/server/routers/schedules.ts | 59 ++++++++++++-- src/server/routers/settings.ts | 116 ++++++++++++++++++++++++++- src/server/routers/waterLevel.ts | 46 +++++++++-- 11 files changed, 500 insertions(+), 47 deletions(-) diff --git a/src/server/routers/biometrics.ts b/src/server/routers/biometrics.ts index a293bf9d..264ec531 100644 --- a/src/server/routers/biometrics.ts +++ b/src/server/routers/biometrics.ts @@ -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(), + }))) .input( z .object({ @@ -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({ @@ -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({ @@ -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 @@ -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({ @@ -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 diff --git a/src/server/routers/calibration.ts b/src/server/routers/calibration.ts index 26215eaa..253c6de8 100644 --- a/src/server/routers/calibration.ts +++ b/src/server/routers/calibration.ts @@ -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) { @@ -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) { diff --git a/src/server/routers/device.ts b/src/server/routers/device.ts index 73075f76..a8176b67 100644 --- a/src/server/routers/device.ts +++ b/src/server/routers/device.ts @@ -87,7 +87,36 @@ 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(), + }), + rightSide: z.object({ + currentTemperature: z.number(), + targetTemperature: z.number(), + currentLevel: z.number(), + targetLevel: z.number(), + heatingDuration: z.number(), + }), + 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() @@ -557,7 +586,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] diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts index b1b7c879..da028baa 100644 --- a/src/server/routers/environment.ts +++ b/src/server/routers/environment.ts @@ -28,7 +28,19 @@ export const environmentRouter = router({ getBedTemp: publicProcedure .meta({ openapi: { method: 'GET', path: '/environment/bed-temp', protect: false, tags: ['Environment'] } }) .input(dateRangeInput) - .output(z.any()) + .output(z.array(z.object({ + id: z.number(), + timestamp: z.date(), + ambientTemp: z.number().nullable(), + mcuTemp: z.number().nullable(), + humidity: z.number().nullable(), + leftOuterTemp: z.number().nullable(), + leftCenterTemp: z.number().nullable(), + leftInnerTemp: z.number().nullable(), + rightOuterTemp: z.number().nullable(), + rightCenterTemp: z.number().nullable(), + rightInnerTemp: z.number().nullable(), + }))) .query(async ({ input }) => { try { if (input.startDate && input.endDate && !validateDateRange(input.startDate, input.endDate)) { @@ -72,7 +84,14 @@ export const environmentRouter = router({ getFreezerTemp: publicProcedure .meta({ openapi: { method: 'GET', path: '/environment/freezer-temp', protect: false, tags: ['Environment'] } }) .input(dateRangeInput) - .output(z.any()) + .output(z.array(z.object({ + id: z.number(), + timestamp: z.date(), + ambientTemp: z.number().nullable(), + heatsinkTemp: z.number().nullable(), + leftWaterTemp: z.number().nullable(), + rightWaterTemp: z.number().nullable(), + }))) .query(async ({ input }) => { try { if (input.startDate && input.endDate && !validateDateRange(input.startDate, input.endDate)) { @@ -111,7 +130,19 @@ export const environmentRouter = router({ getLatestBedTemp: publicProcedure .meta({ openapi: { method: 'GET', path: '/environment/bed-temp/latest', protect: false, tags: ['Environment'] } }) .input(z.object({ unit: z.enum(['F', 'C']).default('F') }).strict()) - .output(z.any()) + .output(z.object({ + id: z.number(), + timestamp: z.date(), + ambientTemp: z.number().nullable(), + mcuTemp: z.number().nullable(), + humidity: z.number().nullable(), + leftOuterTemp: z.number().nullable(), + leftCenterTemp: z.number().nullable(), + leftInnerTemp: z.number().nullable(), + rightOuterTemp: z.number().nullable(), + rightCenterTemp: z.number().nullable(), + rightInnerTemp: z.number().nullable(), + }).nullable()) .query(async ({ input }) => { try { const [row] = await biometricsDb @@ -148,7 +179,14 @@ export const environmentRouter = router({ getLatestFreezerTemp: publicProcedure .meta({ openapi: { method: 'GET', path: '/environment/freezer-temp/latest', protect: false, tags: ['Environment'] } }) .input(z.object({ unit: z.enum(['F', 'C']).default('F') }).strict()) - .output(z.any()) + .output(z.object({ + id: z.number(), + timestamp: z.date(), + ambientTemp: z.number().nullable(), + heatsinkTemp: z.number().nullable(), + leftWaterTemp: z.number().nullable(), + rightWaterTemp: z.number().nullable(), + }).nullable()) .query(async ({ input }) => { try { const [row] = await biometricsDb @@ -179,7 +217,24 @@ export const environmentRouter = router({ getSummary: publicProcedure .meta({ openapi: { method: 'GET', path: '/environment/summary', protect: false, tags: ['Environment'] } }) - .output(z.any()) + .output(z.object({ + bedTemp: z.object({ + avgAmbientTemp: z.number().nullable(), + minAmbientTemp: z.number().nullable(), + maxAmbientTemp: z.number().nullable(), + avgHumidity: z.number().nullable(), + avgLeftCenterTemp: z.number().nullable(), + avgRightCenterTemp: z.number().nullable(), + recordCount: z.number(), + }).nullable(), + freezerTemp: z.object({ + avgAmbientTemp: z.number().nullable(), + avgHeatsinkTemp: z.number().nullable(), + avgLeftWaterTemp: z.number().nullable(), + avgRightWaterTemp: z.number().nullable(), + recordCount: z.number(), + }).nullable(), + })) .input( z.object({ startDate: z.date(), @@ -272,7 +327,11 @@ export const environmentRouter = router({ endDate: z.date().optional(), limit: z.number().int().min(1).max(1440).default(1440), }).strict()) - .output(z.any()) + .output(z.array(z.object({ + id: z.number(), + timestamp: z.date(), + lux: z.number().nullable(), + }))) .query(async ({ input }) => { try { if (input.startDate && input.endDate && !validateDateRange(input.startDate, input.endDate)) { @@ -302,7 +361,11 @@ export const environmentRouter = router({ getLatestAmbientLight: publicProcedure .meta({ openapi: { method: 'GET', path: '/environment/ambient-light/latest', protect: false, tags: ['Environment'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + id: z.number(), + timestamp: z.date(), + lux: z.number().nullable(), + }).nullable()) .query(async () => { try { const [row] = await biometricsDb @@ -327,7 +390,12 @@ export const environmentRouter = router({ startDate: z.date(), endDate: z.date(), }).strict()) - .output(z.any()) + .output(z.object({ + avgLux: z.number().nullable(), + minLux: z.number().nullable(), + maxLux: z.number().nullable(), + recordCount: z.number(), + }).nullable()) .query(async ({ input }) => { try { if (!validateDateRange(input.startDate, input.endDate)) { diff --git a/src/server/routers/health.ts b/src/server/routers/health.ts index 067284ab..c42838bb 100644 --- a/src/server/routers/health.ts +++ b/src/server/routers/health.ts @@ -33,7 +33,25 @@ export const healthRouter = router({ scheduler: publicProcedure .meta({ openapi: { method: 'GET', path: '/health/scheduler', protect: false, tags: ['Health'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + enabled: z.boolean(), + jobCounts: z.object({ + temperature: z.number(), + powerOn: z.number(), + powerOff: z.number(), + alarm: z.number(), + prime: z.number(), + reboot: z.number(), + total: z.number(), + }), + upcomingJobs: z.array(z.object({ + id: z.string(), + type: z.number(), + side: z.string().optional(), + nextRun: z.string().nullable(), + })), + healthy: z.boolean(), + })) .query(async () => { try { const jobManager = await getJobManager() @@ -114,7 +132,28 @@ export const healthRouter = router({ system: publicProcedure .meta({ openapi: { method: 'GET', path: '/health/system', protect: false, tags: ['Health'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + status: z.enum(['ok', 'degraded']), + timestamp: z.string(), + database: z.object({ + status: z.enum(['ok', 'degraded']), + latencyMs: z.number(), + error: z.string().optional(), + }), + scheduler: z.object({ + enabled: z.boolean(), + jobCount: z.number(), + drift: z.object({ + dbScheduleCount: z.number(), + schedulerJobCount: z.number(), + drifted: z.boolean(), + }).optional(), + }), + iptables: z.object({ + ok: z.boolean(), + missing: z.array(z.string()), + }), + })) .query(async () => { let overallStatus: 'ok' | 'degraded' = 'ok' @@ -244,7 +283,11 @@ export const healthRouter = router({ dacMonitor: publicProcedure .meta({ openapi: { method: 'GET', path: '/health/dac-monitor', protect: false, tags: ['Health'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + status: z.string(), + podVersion: z.string().nullable(), + gesturesSupported: z.boolean(), + })) .query(() => { const monitor = getDacMonitorIfRunning() if (!monitor) { @@ -264,7 +307,12 @@ export const healthRouter = router({ hardware: publicProcedure .meta({ openapi: { method: 'GET', path: '/health/hardware', protect: false, tags: ['Health'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + status: z.enum(['ok', 'degraded']), + socketPath: z.string(), + latencyMs: z.number(), + error: z.string().optional(), + })) .query(async () => { let status: 'ok' | 'degraded' = 'ok' let latencyMs = 0 diff --git a/src/server/routers/raw.ts b/src/server/routers/raw.ts index dafda55b..e83f761a 100644 --- a/src/server/routers/raw.ts +++ b/src/server/routers/raw.ts @@ -41,7 +41,11 @@ export const rawRouter = router({ files: publicProcedure .meta({ openapi: { method: 'GET', path: '/raw/files', protect: false, tags: ['Raw'] } }) .input(z.object({})) - .output(z.any()) + .output(z.array(z.object({ + name: z.string(), + sizeBytes: z.number(), + modifiedAt: z.string(), + }))) .query(async () => { try { return await listRawFiles() @@ -103,7 +107,13 @@ export const rawRouter = router({ diskUsage: publicProcedure .meta({ openapi: { method: 'GET', path: '/raw/disk-usage', protect: false, tags: ['Raw'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + totalBytes: z.number(), + usedBytes: z.number(), + availableBytes: z.number(), + rawFileCount: z.number(), + rawBytes: z.number(), + })) .query(async () => { try { const files = await listRawFiles() diff --git a/src/server/routers/runOnce.ts b/src/server/routers/runOnce.ts index 762a2951..fe1c24af 100644 --- a/src/server/routers/runOnce.ts +++ b/src/server/routers/runOnce.ts @@ -125,7 +125,15 @@ export const runOnceRouter = router({ getActive: publicProcedure .meta({ openapi: { method: 'GET', path: '/run-once/active', protect: false, tags: ['RunOnce'] } }) .input(z.object({ side: sideSchema }).strict()) - .output(z.any()) + .output(z.object({ + id: z.number(), + side: z.enum(['left', 'right']), + setPoints: z.unknown(), + wakeTime: z.string(), + startedAt: z.number(), + expiresAt: z.number(), + status: z.enum(['active', 'completed', 'cancelled']), + }).nullable()) .query(async ({ input }) => { const [session] = await db .select() diff --git a/src/server/routers/scheduleGroups.ts b/src/server/routers/scheduleGroups.ts index cc42e390..83ce81d6 100644 --- a/src/server/routers/scheduleGroups.ts +++ b/src/server/routers/scheduleGroups.ts @@ -48,7 +48,14 @@ export const scheduleGroupsRouter = router({ }) .strict() ) - .output(z.any()) + .output(z.array(z.object({ + id: z.number(), + side: sideSchema, + name: z.string(), + days: z.array(z.string()), + createdAt: z.date(), + updatedAt: z.date(), + }))) .query(async ({ input }) => { try { const groups = db @@ -82,7 +89,14 @@ export const scheduleGroupsRouter = router({ }) .strict() ) - .output(z.any()) + .output(z.object({ + id: z.number(), + side: sideSchema, + name: z.string(), + days: z.array(z.string()), + createdAt: z.date(), + updatedAt: z.date(), + })) .mutation(async ({ input }) => { try { const conflict = findConflictingGroup(input.side, input.days) @@ -140,7 +154,14 @@ export const scheduleGroupsRouter = router({ }) .strict() ) - .output(z.any()) + .output(z.object({ + id: z.number(), + side: sideSchema, + name: z.string(), + days: z.array(z.string()), + createdAt: z.date(), + updatedAt: z.date(), + })) .mutation(async ({ input }) => { try { // Fetch existing group first @@ -374,7 +395,14 @@ export const scheduleGroupsRouter = router({ }) .strict() ) - .output(z.any()) + .output(z.object({ + id: z.number(), + side: sideSchema, + name: z.string(), + days: z.array(z.string()), + createdAt: z.date(), + updatedAt: z.date(), + }).nullable()) .query(async ({ input }) => { try { const groups = db diff --git a/src/server/routers/schedules.ts b/src/server/routers/schedules.ts index f332d5c5..7cefb425 100644 --- a/src/server/routers/schedules.ts +++ b/src/server/routers/schedules.ts @@ -18,6 +18,49 @@ import { vibrationPatternSchema, alarmDurationSchema, } from '@/src/server/validation-schemas' + +const temperatureScheduleOutput = z.object({ + id: z.number(), + side: sideSchema, + dayOfWeek: dayOfWeekSchema, + time: z.string(), + temperature: z.number(), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +const powerScheduleOutput = z.object({ + id: z.number(), + side: sideSchema, + dayOfWeek: dayOfWeekSchema, + onTime: z.string(), + offTime: z.string(), + onTemperature: z.number(), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +const alarmScheduleOutput = z.object({ + id: z.number(), + side: sideSchema, + dayOfWeek: dayOfWeekSchema, + time: z.string(), + vibrationIntensity: z.number(), + vibrationPattern: vibrationPatternSchema, + duration: z.number(), + alarmTemperature: z.number(), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +const schedulesCollectionOutput = z.object({ + temperature: z.array(temperatureScheduleOutput), + power: z.array(powerScheduleOutput), + alarm: z.array(alarmScheduleOutput), +}) import { getJobManager } from '@/src/scheduler' import { toC } from '@/src/lib/tempUtils' @@ -61,7 +104,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(schedulesCollectionOutput) .query(async ({ input }) => { try { const temperatureSchedulesList = db @@ -111,7 +154,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(temperatureScheduleOutput) .mutation(async ({ input }) => { try { const created = db.transaction((tx) => { @@ -161,7 +204,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(temperatureScheduleOutput) .mutation(async ({ input }) => { try { const { id, ...updates } = input @@ -269,7 +312,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(powerScheduleOutput) .mutation(async ({ input }) => { try { const created = db.transaction((tx) => { @@ -320,7 +363,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(powerScheduleOutput) .mutation(async ({ input }) => { try { const { id, ...updates } = input @@ -430,7 +473,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(alarmScheduleOutput) .mutation(async ({ input }) => { try { const created = db.transaction((tx) => { @@ -483,7 +526,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(alarmScheduleOutput) .mutation(async ({ input }) => { try { const { id, ...updates } = input @@ -718,7 +761,7 @@ export const schedulesRouter = router({ }) .strict() ) - .output(z.any()) + .output(schedulesCollectionOutput) .query(async ({ input }) => { try { const temperatureSchedulesList = db diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index 3717d93d..aa13eec1 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -50,7 +50,78 @@ export const settingsRouter = router({ getAll: publicProcedure .meta({ openapi: { method: 'GET', path: '/settings', protect: false, tags: ['Settings'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + device: z.object({ + id: z.number(), + timezone: z.string(), + temperatureUnit: temperatureUnitSchema, + rebootDaily: z.boolean(), + rebootTime: z.string().nullable(), + primePodDaily: z.boolean(), + primePodTime: z.string().nullable(), + ledNightModeEnabled: z.boolean(), + ledDayBrightness: z.number(), + ledNightBrightness: z.number(), + ledNightStartTime: z.string().nullable(), + ledNightEndTime: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + }), + sides: z.object({ + left: z.object({ + side: z.literal('left'), + name: z.string(), + awayMode: z.boolean(), + alwaysOn: z.boolean(), + autoOffEnabled: z.boolean(), + autoOffMinutes: z.number(), + awayStart: z.string().nullable().optional(), + awayReturn: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + }), + right: z.object({ + side: z.literal('right'), + name: z.string(), + awayMode: z.boolean(), + alwaysOn: z.boolean(), + autoOffEnabled: z.boolean(), + autoOffMinutes: z.number(), + awayStart: z.string().nullable().optional(), + awayReturn: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + }), + }), + gestures: z.object({ + left: z.array(z.object({ + id: z.number(), + side: z.literal('left'), + tapType: tapTypeSchema, + actionType: z.enum(['temperature', 'alarm']), + temperatureChange: z.enum(['increment', 'decrement']).nullable(), + temperatureAmount: z.number().nullable(), + alarmBehavior: z.enum(['snooze', 'dismiss']).nullable(), + alarmSnoozeDuration: z.number().nullable(), + alarmInactiveBehavior: z.enum(['power', 'none']).nullable(), + createdAt: z.date(), + updatedAt: z.date(), + })), + right: z.array(z.object({ + id: z.number(), + side: z.literal('right'), + tapType: tapTypeSchema, + actionType: z.enum(['temperature', 'alarm']), + temperatureChange: z.enum(['increment', 'decrement']).nullable(), + temperatureAmount: z.number().nullable(), + alarmBehavior: z.enum(['snooze', 'dismiss']).nullable(), + alarmSnoozeDuration: z.number().nullable(), + alarmInactiveBehavior: z.enum(['power', 'none']).nullable(), + createdAt: z.date(), + updatedAt: z.date(), + })), + }), + })) .query(async () => { try { const [device] = await db.select().from(deviceSettings).limit(1) @@ -115,7 +186,22 @@ export const settingsRouter = router({ }) .strict() ) - .output(z.any()) + .output(z.object({ + id: z.number(), + timezone: z.string(), + temperatureUnit: temperatureUnitSchema, + rebootDaily: z.boolean(), + rebootTime: z.string().nullable(), + primePodDaily: z.boolean(), + primePodTime: z.string().nullable(), + ledNightModeEnabled: z.boolean(), + ledDayBrightness: z.number(), + ledNightBrightness: z.number(), + ledNightStartTime: z.string().nullable(), + ledNightEndTime: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + })) .mutation(async ({ input }) => { try { const updated = db.transaction((tx) => { @@ -211,7 +297,18 @@ export const settingsRouter = router({ }) .strict() ) - .output(z.any()) + .output(z.object({ + side: sideSchema, + name: z.string(), + awayMode: z.boolean(), + alwaysOn: z.boolean(), + autoOffEnabled: z.boolean(), + autoOffMinutes: z.number(), + awayStart: z.string().nullable(), + awayReturn: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + })) .mutation(async ({ input }) => { try { const { side, ...updates } = input @@ -324,7 +421,18 @@ export const settingsRouter = router({ }) .strict() ) - .output(z.any()) + .output(z.object({ + side: sideSchema, + name: z.string(), + awayMode: z.boolean(), + alwaysOn: z.boolean(), + autoOffEnabled: z.boolean(), + autoOffMinutes: z.number(), + awayStart: z.string().nullable(), + awayReturn: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + })) .mutation(async ({ input }) => { try { const updated = db.transaction((tx) => { diff --git a/src/server/routers/waterLevel.ts b/src/server/routers/waterLevel.ts index 2a4f2f88..b40948f9 100644 --- a/src/server/routers/waterLevel.ts +++ b/src/server/routers/waterLevel.ts @@ -17,7 +17,11 @@ export const waterLevelRouter = router({ endDate: z.date().optional(), limit: z.number().int().min(1).max(10000).default(1440), }).strict()) - .output(z.any()) + .output(z.array(z.object({ + id: z.number(), + timestamp: z.date(), + level: z.enum(['low', 'ok']), + }))) .query(async ({ input }) => { try { if (input.startDate && input.endDate && !validateDateRange(input.startDate, input.endDate)) { @@ -50,7 +54,11 @@ export const waterLevelRouter = router({ getLatest: publicProcedure .meta({ openapi: { method: 'GET', path: '/water-level/latest', protect: false, tags: ['Water Level'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + id: z.number(), + timestamp: z.date(), + level: z.enum(['low', 'ok']), + }).nullable()) .query(async () => { try { const [row] = await biometricsDb @@ -78,7 +86,12 @@ export const waterLevelRouter = router({ .input(z.object({ hours: z.number().int().min(1).max(168).default(24), }).strict()) - .output(z.any()) + .output(z.object({ + totalReadings: z.number(), + okPercent: z.number(), + lowPercent: z.number(), + trend: z.enum(['stable', 'declining', 'rising', 'unknown']), + })) .query(async ({ input }) => { try { const now = Date.now() @@ -166,7 +179,14 @@ export const waterLevelRouter = router({ getAlerts: publicProcedure .meta({ openapi: { method: 'GET', path: '/water-level/alerts', protect: false, tags: ['Water Level'] } }) .input(z.object({})) - .output(z.any()) + .output(z.array(z.object({ + id: z.number(), + type: z.enum(['low_sustained', 'rapid_change', 'leak_suspected']), + startedAt: z.date(), + dismissedAt: z.date().nullable(), + message: z.string().nullable(), + createdAt: z.date(), + }))) .query(async () => { try { return await biometricsDb @@ -226,7 +246,14 @@ export const waterLevelRouter = router({ .input(z.object({ hours: z.number().int().min(1).max(168).default(24), }).strict()) - .output(z.any()) + .output(z.array(z.object({ + id: z.number(), + timestamp: z.date(), + leftFlowrateCd: z.number().nullable(), + rightFlowrateCd: z.number().nullable(), + leftPumpRpm: z.number().nullable(), + rightPumpRpm: z.number().nullable(), + }))) .query(async ({ input }) => { try { const since = new Date(Date.now() - input.hours * 60 * 60 * 1000) @@ -252,7 +279,14 @@ export const waterLevelRouter = router({ getLatestFlowReading: publicProcedure .meta({ openapi: { method: 'GET', path: '/water-level/flow/latest', protect: false, tags: ['Water Level'] } }) .input(z.object({})) - .output(z.any()) + .output(z.object({ + id: z.number(), + timestamp: z.date(), + leftFlowrateCd: z.number().nullable(), + rightFlowrateCd: z.number().nullable(), + leftPumpRpm: z.number().nullable(), + rightPumpRpm: z.number().nullable(), + }).nullable()) .query(async () => { try { const [row] = await biometricsDb From 4ed7129982cbdaeaaf7828c18ae49eb2d1af4a53 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 9 Apr 2026 22:55:59 -0700 Subject: [PATCH 2/4] Fix type errors uncovered by typed output schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - health.ts: JobType is string enum, not number - settings.ts: use sideSchema instead of z.literal for db select results - BedTempMatrix: timestamp is Date, remove string cast - CurveEditor: type createPromises as Promise[] - WaterLevelCard/WaterModal: fix field name mismatches (direction→trend, levelPercent→level, changePercent→lowPercent, alertType→type) - Tests: add null guards for nullable getByDay/find results Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Schedule/CurveEditor.tsx | 4 +-- src/components/Sensors/BedTempMatrix.tsx | 2 +- src/components/status/WaterLevelCard.tsx | 26 +++++++++---------- src/components/status/WaterModal.tsx | 21 +++++++-------- src/server/routers/health.ts | 2 +- src/server/routers/settings.ts | 8 +++--- .../routers/tests/scheduleGroups.test.ts | 2 +- src/server/routers/tests/schedules.test.ts | 3 ++- 8 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/components/Schedule/CurveEditor.tsx b/src/components/Schedule/CurveEditor.tsx index 0674fe11..2bc84d3a 100644 --- a/src/components/Schedule/CurveEditor.tsx +++ b/src/components/Schedule/CurveEditor.tsx @@ -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[] = Object.entries(scheduleTemps).map(([time, temperature]) => createTempSchedule.mutateAsync({ side, dayOfWeek: day, @@ -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 as Promise<{ id: number }>, + }), ) await Promise.all(createPromises) diff --git a/src/components/Sensors/BedTempMatrix.tsx b/src/components/Sensors/BedTempMatrix.tsx index 2944cabd..013baa17 100644 --- a/src/components/Sensors/BedTempMatrix.tsx +++ b/src/components/Sensors/BedTempMatrix.tsx @@ -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, diff --git a/src/components/status/WaterLevelCard.tsx b/src/components/status/WaterLevelCard.tsx index 6971ec60..fa5eba80 100644 --- a/src/components/status/WaterLevelCard.tsx +++ b/src/components/status/WaterLevelCard.tsx @@ -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 - if (direction === 'rising') return +function trendIcon(trend: string) { + if (trend === 'declining') return + if (trend === 'rising') return return } @@ -84,7 +84,7 @@ export function WaterLevelCard() { Water Level - {trend && trendIcon(trend.direction)} + {trend && trendIcon(trend.trend)} {/* Current level */} @@ -99,9 +99,7 @@ export function WaterLevelCard() {

- {typeof latest.levelPercent === 'number' - ? `${Math.round(latest.levelPercent)}%` - : '--'} + {latest.level === 'ok' ? 'OK' : 'Low'}

{new Date(latest.timestamp).toLocaleTimeString([], { @@ -112,14 +110,16 @@ export function WaterLevelCard() {

{trend && (
- {trend.direction !== 'stable' && ( + {trend.trend === 'stable' && Stable} + {trend.trend === 'declining' && ( - {(trend.changePercent ?? 0) > 0 ? '+' : ''} - {(trend.changePercent ?? 0).toFixed(1)} - % / 24h + Declining ( + {trend.lowPercent} + % low) )} - {trend.direction === 'stable' && Stable} + {trend.trend === 'rising' && Rising} + {trend.trend === 'unknown' && Insufficient data}
)}
@@ -134,7 +134,7 @@ export function WaterLevelCard() { {/* Active alerts */} {activeAlerts.length > 0 && (
- {activeAlerts.map((alert: { id: number, alertType: string, message: string }) => ( + {activeAlerts.map(alert => (
- if (direction === 'rising') return +function trendIcon(trend: string) { + if (trend === 'declining') return + if (trend === 'rising') return return } @@ -99,9 +99,7 @@ export function WaterModal({ open, onClose }: { open: boolean, onClose: () => vo

- {typeof latest.levelPercent === 'number' - ? `${Math.round(latest.levelPercent)}%` - : '--'} + {latest.level === 'ok' ? 'OK' : 'Low'}

{new Date(latest.timestamp).toLocaleTimeString([], { @@ -112,11 +110,12 @@ export function WaterModal({ open, onClose }: { open: boolean, onClose: () => vo

{trend && (
- {trendIcon(trend.direction)} + {trendIcon(trend.trend)} - {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'}
)} @@ -132,7 +131,7 @@ export function WaterModal({ open, onClose }: { open: boolean, onClose: () => vo {/* Active alerts */} {activeAlerts.length > 0 && (
- {activeAlerts.map((alert: { id: number, alertType: string, message: string }) => ( + {activeAlerts.map(alert => (
{alert.message} diff --git a/src/server/routers/health.ts b/src/server/routers/health.ts index c42838bb..4b43a38b 100644 --- a/src/server/routers/health.ts +++ b/src/server/routers/health.ts @@ -46,7 +46,7 @@ export const healthRouter = router({ }), upcomingJobs: z.array(z.object({ id: z.string(), - type: z.number(), + type: z.string(), side: z.string().optional(), nextRun: z.string().nullable(), })), diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index aa13eec1..324048a5 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -69,7 +69,7 @@ export const settingsRouter = router({ }), sides: z.object({ left: z.object({ - side: z.literal('left'), + side: sideSchema, name: z.string(), awayMode: z.boolean(), alwaysOn: z.boolean(), @@ -81,7 +81,7 @@ export const settingsRouter = router({ updatedAt: z.date(), }), right: z.object({ - side: z.literal('right'), + side: sideSchema, name: z.string(), awayMode: z.boolean(), alwaysOn: z.boolean(), @@ -96,7 +96,7 @@ export const settingsRouter = router({ gestures: z.object({ left: z.array(z.object({ id: z.number(), - side: z.literal('left'), + side: sideSchema, tapType: tapTypeSchema, actionType: z.enum(['temperature', 'alarm']), temperatureChange: z.enum(['increment', 'decrement']).nullable(), @@ -109,7 +109,7 @@ export const settingsRouter = router({ })), right: z.array(z.object({ id: z.number(), - side: z.literal('right'), + side: sideSchema, tapType: tapTypeSchema, actionType: z.enum(['temperature', 'alarm']), temperatureChange: z.enum(['increment', 'decrement']).nullable(), diff --git a/src/server/routers/tests/scheduleGroups.test.ts b/src/server/routers/tests/scheduleGroups.test.ts index d2a5a1cf..521c8ee6 100644 --- a/src/server/routers/tests/scheduleGroups.test.ts +++ b/src/server/routers/tests/scheduleGroups.test.ts @@ -184,7 +184,7 @@ describe('scheduleGroups', () => { }) const result = await caller.getByDay({ side: 'left', dayOfWeek: 'tuesday' }) - expect(result).not.toBeNull() + if (result === null) throw new Error('Expected non-null result') expect(result.name).toBe('Weekdays') expect(result.days).toContain('tuesday') }) diff --git a/src/server/routers/tests/schedules.test.ts b/src/server/routers/tests/schedules.test.ts index b17f3958..9e59ce6b 100644 --- a/src/server/routers/tests/schedules.test.ts +++ b/src/server/routers/tests/schedules.test.ts @@ -161,7 +161,8 @@ describe('schedules.batchUpdate', () => { const after = await caller.getAll({ side: 'left' }) expect(after.temperature).toHaveLength(2) - const tuesday = after.temperature.find((t: any) => t.dayOfWeek === 'tuesday') + const tuesday = after.temperature.find(t => t.dayOfWeek === 'tuesday') + if (!tuesday) throw new Error('Expected to find tuesday schedule') expect(tuesday.time).toBe('22:00') expect(tuesday.temperature).toBe(68) }) From acb7d900a744ee46326949a40ba0adf456c36195 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 9 Apr 2026 23:00:27 -0700 Subject: [PATCH 3/4] Fix remaining type mismatches from typed schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - device.ts: add optional isAlarmVibrating to SideStatus output (present in WS stream, absent in HTTP — union type needs it) - useSchedules.ts: align local interfaces with typed API output (createdAt/updatedAt as Date, add missing fields) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useSchedules.ts | 8 ++++++-- src/server/routers/device.ts | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSchedules.ts b/src/hooks/useSchedules.ts index a66d0dca..3d8a1d9e 100644 --- a/src/hooks/useSchedules.ts +++ b/src/hooks/useSchedules.ts @@ -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 { @@ -24,6 +24,8 @@ export interface PowerSchedule { offTime: string onTemperature: number enabled: boolean + createdAt: Date + updatedAt: Date } export interface AlarmSchedule { @@ -36,6 +38,8 @@ export interface AlarmSchedule { duration: number alarmTemperature: number enabled: boolean + createdAt: Date + updatedAt: Date } export interface ScheduleData { diff --git a/src/server/routers/device.ts b/src/server/routers/device.ts index a8176b67..50a67a63 100644 --- a/src/server/routers/device.ts +++ b/src/server/routers/device.ts @@ -94,6 +94,7 @@ export const deviceRouter = router({ currentLevel: z.number(), targetLevel: z.number(), heatingDuration: z.number(), + isAlarmVibrating: z.boolean().optional(), }), rightSide: z.object({ currentTemperature: z.number(), @@ -101,6 +102,7 @@ export const deviceRouter = router({ currentLevel: z.number(), targetLevel: z.number(), heatingDuration: z.number(), + isAlarmVibrating: z.boolean().optional(), }), waterLevel: z.enum(['low', 'ok']), isPriming: z.boolean(), From 825ba646a02366c81923a60dbe957fb1e4786a73 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 9 Apr 2026 23:01:58 -0700 Subject: [PATCH 4/4] Fix AlarmSchedule.vibrationPattern type to match schema Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useSchedules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSchedules.ts b/src/hooks/useSchedules.ts index 3d8a1d9e..5ddba085 100644 --- a/src/hooks/useSchedules.ts +++ b/src/hooks/useSchedules.ts @@ -33,7 +33,7 @@ export interface AlarmSchedule { side: 'left' | 'right' dayOfWeek: DayOfWeek time: string - vibrationPattern: string + vibrationPattern: 'double' | 'rise' vibrationIntensity: number duration: number alarmTemperature: number