feat: 7 free-sleep beta features — version, CRUD, snooze, water level, ambient light#193
feat: 7 free-sleep beta features — version, CRUD, snooze, water level, ambient light#193
Conversation
…cation, ambient light, water level monitoring Implements 7 features from free-sleep beta (credit: @Geczy, @throwaway31265, nikita): - #184: GET /system/version — build info from .git-info (generated at prebuild) - #186: PUT/DELETE /biometrics/sleep-records — edit/delete with duration recalc - #188: Prime completion notification — tracks isPriming transitions, dismiss endpoint - #183: POST /device/alarm/snooze — snooze with configurable duration, auto-restart - #185: Ambient light sensor — new table + GET/summary/latest endpoints - #181: Water level monitoring — historical readings (60s rate-limited from DeviceStateSync), trend analysis, leak alerts with dismiss - Wires waterLevel router into app, adds biometrics migration for new tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (17)
📝 WalkthroughWalkthroughThis PR introduces build-time git info generation, extends the biometrics database with water level and ambient light monitoring, implements alarm snooze functionality with prime notifications, and adds corresponding API endpoints for version info, water level management, and sleep record operations. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~65 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds several “free-sleep beta” API + hardware features to the core controller, spanning new REST/tRPC endpoints, in-memory notification/snooze state, and biometrics DB schema/migrations.
Changes:
- Adds water level monitoring endpoints + DB tables/migration, and writes periodic readings from
DeviceStateSync. - Adds ambient light endpoints +
ambient_lightbiometrics table/migration. - Adds alarm snooze support and prime-completion notification surfaced via
/device/status, plus a dismiss endpoint. - Adds
/system/versionendpoint backed by a build-time.git-infoartifact generated duringprebuild.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/server/routers/waterLevel.ts | New water level history/latest/trend/alerts endpoints. |
| src/server/routers/system.ts | Adds /system/version endpoint reading .git-info. |
| src/server/routers/environment.ts | Adds ambient light endpoints and queries. |
| src/server/routers/device.ts | Adds snooze + prime notification exposure/dismiss; cancels snooze on clear. |
| src/server/routers/biometrics.ts | Adds sleep record update/delete endpoints with duration recalculation. |
| src/server/routers/app.ts | Registers waterLevelRouter in the app router. |
| src/server/openapi.ts | Adds “Water Level” to OpenAPI tags. |
| src/hardware/snoozeManager.ts | New in-memory snooze timer manager that re-triggers alarms. |
| src/hardware/primeNotification.ts | New in-memory prime completion notification tracker. |
| src/hardware/deviceStateSync.ts | Writes water level readings to biometrics DB (rate-limited). |
| src/hardware/dacMonitor.instance.ts | Hooks priming state tracking into status updates. |
| src/db/biometrics-schema.ts | Adds water_level_readings, water_level_alerts, ambient_light tables. |
| src/db/biometrics-migrations/meta/_journal.json | Registers new biometrics migration. |
| src/db/biometrics-migrations/meta/0004_snapshot.json | Snapshot reflecting new biometrics tables. |
| src/db/biometrics-migrations/0004_peaceful_mordo.sql | Migration creating the new biometrics tables/indexes. |
| scripts/generate-git-info.mjs | Generates .git-info at build time. |
| package.json | Runs git-info generation on prebuild. |
| .gitignore | Ignores .git-info build artifact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| const now = Date.now() | ||
| if (now - this.lastWaterLevelWrite < 60_000) return | ||
| this.lastWaterLevelWrite = now | ||
|
|
||
| const level = status.waterLevel === 'low' ? 'low' as const : 'ok' as const | ||
| try { | ||
| biometricsDb | ||
| .insert(waterLevelReadings) | ||
| .values({ timestamp: new Date(now), level }) | ||
| .run() |
| import { publicProcedure, router } from '@/src/server/trpc' | ||
| import { biometricsDb } from '@/src/db' | ||
| import { waterLevelReadings, waterLevelAlerts } from '@/src/db/biometrics-schema' | ||
| import { eq, and, gte, lte, desc, isNull, count, sql } from 'drizzle-orm' |
| export function snoozeAlarm( | ||
| side: Side, | ||
| durationSeconds: number, | ||
| config: SnoozeState['config'], | ||
| ): Date { | ||
| // Cancel any existing snooze for this side | ||
| cancelSnooze(side) | ||
|
|
||
| const snoozeUntil = new Date(Date.now() + durationSeconds * 1000) | ||
|
|
||
| const timeoutId = setTimeout(async () => { | ||
| activeSnoozes.delete(side) | ||
| try { | ||
| const client = getSharedHardwareClient() | ||
| await client.setAlarm(side, config) | ||
| } | ||
| catch (err) { | ||
| console.error(`[Snooze] Failed to restart alarm for ${side}:`, err) | ||
| } | ||
| }, durationSeconds * 1000) | ||
|
|
||
| activeSnoozes.set(side, { timeoutId, snoozeUntil, config }) | ||
| return snoozeUntil |
| .mutation(async ({ input }) => { | ||
| return withHardwareClient(async (client) => { | ||
| await client.clearAlarm(input.side) | ||
| cancelSnooze(input.side) | ||
|
|
| export function trackPrimingState(isPriming: boolean): void { | ||
| if (wasPriming && !isPriming) { | ||
| primeCompletedAt = new Date() | ||
| } | ||
| wasPriming = isPriming | ||
| } |
| } | ||
|
|
||
| const entered = updates.enteredBedAt ?? existing.enteredBedAt | ||
| const left = updates.leftBedAt ?? existing.leftBedAt |
| try { | ||
| const raw = await readFile('.git-info', 'utf-8') | ||
| return JSON.parse(raw) | ||
| } |
| writeFileSync('.git-info', JSON.stringify({ | ||
| branch: run('git rev-parse --abbrev-ref HEAD'), | ||
| commitHash: run('git rev-parse --short HEAD'), | ||
| commitTitle: run('git log -1 --format=%s'), | ||
| buildDate: new Date().toISOString(), | ||
| })) | ||
|
|
||
| console.log('Generated .git-info') |
- Move lastWaterLevelWrite update after successful DB insert - Remove unused imports (count, sql) from waterLevel router - Cancel pending snooze when setting a new alarm (prevents stale re-trigger) - Clear prime notification when new priming cycle starts - Validate leftBedAt > enteredBedAt in sleep record update - Validate .git-info fields before returning (malformed file fallback) - Wrap .git-info write in try/catch so build doesn't fail on read-only FS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (8)
src/db/biometrics-schema.ts (1)
80-95: Consider adding an index ondismissedAtfor the alerts table.Querying active (undismissed) alerts will filter by
dismissedAt IS NULL. Without an index, this scan could become slow as the alerts table grows.💡 Suggested index addition
export const waterLevelAlerts = sqliteTable('water_level_alerts', { id: integer('id').primaryKey({ autoIncrement: true }), type: text('type', { enum: ['low_sustained', 'rapid_change', 'leak_suspected'] }).notNull(), startedAt: integer('started_at', { mode: 'timestamp' }).notNull(), dismissedAt: integer('dismissed_at', { mode: 'timestamp' }), message: text('message'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), -}) +}, t => [ + index('idx_water_level_alerts_dismissed').on(t.dismissedAt), +])🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/db/biometrics-schema.ts` around lines 80 - 95, The alerts table (waterLevelAlerts) lacks an index on dismissedAt, causing full table scans when querying active alerts (WHERE dismissedAt IS NULL); add an index on the dismissedAt column (e.g., idx_water_level_alerts_dismissed_at) in the sqliteTable definition for waterLevelAlerts so queries filtering by dismissedAt use the index and improve performance.src/server/routers/biometrics.ts (1)
544-547: Type safety concern with dynamicsetValuesobject.Using
Record<string, unknown>loses type safety with Drizzle's.set()method. If column names drift from schema, TypeScript won't catch it.💡 Type-safe alternative using Drizzle's inferred types
- const setValues: Record<string, unknown> = {} - if (updates.enteredBedAt) setValues.enteredBedAt = updates.enteredBedAt - if (updates.leftBedAt) setValues.leftBedAt = updates.leftBedAt - if (updates.timesExitedBed !== undefined) setValues.timesExitedBed = updates.timesExitedBed + const setValues: Partial<typeof sleepRecords.$inferInsert> = {} + if (updates.enteredBedAt) setValues.enteredBedAt = updates.enteredBedAt + if (updates.leftBedAt) setValues.leftBedAt = updates.leftBedAt + if (updates.timesExitedBed !== undefined) setValues.timesExitedBed = updates.timesExitedBedThis ensures
setValueskeys are validated against the actual schema columns.🤖 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 544 - 547, The current setValues variable is typed as Record<string, unknown>, losing compile-time validation for keys passed to Drizzle's .set() and risking mismatches if schema changes; change its type to a type derived from the actual table columns (e.g., Partial<Record<keyof typeof <tableName>, unknown>> or better, Partial<InferModel<typeof <tableName>, "update">>) so TypeScript verifies keys like enteredBedAt, leftBedAt, timesExitedBed, then build setValues from updates and pass it to .set() — update the import to bring in the table symbol and InferModel (or use keyof typeof <tableName>) and replace Record<string, unknown> with the schema-derived type to restore type safety for .set().src/hardware/deviceStateSync.ts (1)
94-94: Simplify:status.waterLevelis already correctly typed.Per
DeviceStatusinsrc/hardware/types.ts,waterLevelis typed as'low' | 'ok', which matches the schema enum exactly.💡 Simplified version
- const level = status.waterLevel === 'low' ? 'low' as const : 'ok' as const + const level = status.waterLevel🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hardware/deviceStateSync.ts` at line 94, The assignment unnecessarily re-casts status.waterLevel; replace the line that sets const level = status.waterLevel === 'low' ? 'low' as const : 'ok' as const with a direct assignment const level = status.waterLevel to use the existing DeviceStatus typing (refer to the variable level and the status.waterLevel usage in deviceStateSync.ts), removing the redundant conditional and as const casts.src/db/biometrics-migrations/0004_peaceful_mordo.sql (2)
8-15: Add an index for active-alert reads.
getAlertsfilters bydismissed_at IS NULLand sorts bycreated_at DESC; indexing these columns will avoid full scans as alert volume grows.Suggested index
CREATE TABLE `water_level_alerts` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `type` text NOT NULL, `started_at` integer NOT NULL, `dismissed_at` integer, `message` text, `created_at` integer NOT NULL ); --> statement-breakpoint +CREATE INDEX `idx_water_level_alerts_active_created` + ON `water_level_alerts` (`dismissed_at`, `created_at`);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/db/biometrics-migrations/0004_peaceful_mordo.sql` around lines 8 - 15, Add a composite index to speed up `getAlerts` queries on the `water_level_alerts` table which filter by `dismissed_at IS NULL` and order by `created_at DESC`; create an index referencing `water_level_alerts(dismissed_at, created_at)` (choose a clear name like `idx_water_level_alerts_active_created_at`) so reads filter and sort without full table scans and update the migration `0004_peaceful_mordo.sql` to include the CREATE INDEX statement.
17-21: Constrainwater_level_readings.levelto valid domain values.Line 20 currently allows arbitrary text, which can silently corrupt trend classification logic.
Suggested migration adjustment
CREATE TABLE `water_level_readings` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` integer NOT NULL, - `level` text NOT NULL + `level` text NOT NULL CHECK(`level` IN ('ok', 'low')) );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/db/biometrics-migrations/0004_peaceful_mordo.sql` around lines 17 - 21, The water_level_readings table currently allows arbitrary text in column `level`, which can corrupt trend logic; update the migration that creates `water_level_readings` to constrain `level` to an explicit domain (e.g., replace the loose `level` text type with a constraint such as a CHECK on `level` IN ('low','normal','high') or your actual allowed set) by editing the CREATE TABLE for `water_level_readings` to include that CHECK clause on `level`; ensure the chosen allowed values match downstream code that reads `level` and, if needed, add a follow-up migration to coerce or validate existing values before applying the constraint.src/hardware/snoozeManager.ts (1)
16-25: Add an internal guard fordurationSecondsin exported API.Line 16–25 assumes callers always validate duration; adding a local check makes this safer for future call sites.
Suggested guard
export function snoozeAlarm( side: Side, durationSeconds: number, config: SnoozeState['config'], ): Date { + if (!Number.isInteger(durationSeconds) || durationSeconds < 1) { + throw new Error(`Invalid snooze duration: ${durationSeconds}`) + } + // Cancel any existing snooze for this side cancelSnooze(side)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hardware/snoozeManager.ts` around lines 16 - 25, Add a local guard at the start of the exported function snoozeAlarm to validate durationSeconds is a finite positive number (and optionally not absurdly large); if it is not a finite number or <= 0, throw a RangeError (or TypeError) with a clear message, and if it exceeds a defined MAX_SNOOZE_SECONDS clamp it down or throw—this ensures callers can't pass invalid durations before calling cancelSnooze and computing snoozeUntil; introduce a MAX_SNOOZE_SECONDS constant if needed and reference snoozeAlarm, cancelSnooze, and snoozeUntil in the change.src/db/biometrics-migrations/meta/0004_snapshot.json (1)
705-756: Consider adding an index onwater_level_alertsfor querying active alerts.The
water_level_alertstable has no indexes beyond the primary key. If the application frequently queries for active (undismissed) alerts usingWHERE dismissed_at IS NULL, performance may degrade as the table grows.Consider adding a partial index or a composite index depending on query patterns:
-- Option: Index on type and dismissed_at for filtering active alerts by type CREATE INDEX idx_water_level_alerts_type_dismissed ON water_level_alerts(type, dismissed_at);This is a minor concern if the alerts table remains small.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/db/biometrics-migrations/meta/0004_snapshot.json` around lines 705 - 756, Add an index to speed up queries for active alerts on the water_level_alerts table by creating an index on the dismissed_at column (and include type if you filter by type) — e.g., add a migration that creates idx_water_level_alerts_type_dismissed on water_level_alerts(type, dismissed_at) or a partial index on dismissed_at IS NULL if your DB supports it; update the migrations/meta snapshot to include the new index entry for water_level_alerts so future schema checks recognize the index.src/server/routers/device.ts (1)
349-351: Consider expanding the docstring forsnoozeAlarm.Other procedures in this file have detailed documentation covering behavior, timing, and edge cases. The snooze procedure would benefit from similar treatment, particularly around:
- What happens if no alarm is currently active
- Behavior when snooze is called multiple times
- How the re-triggered alarm differs from the original (uses provided parameters, not original alarm's)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/server/routers/device.ts` around lines 349 - 351, The docstring for snoozeAlarm lacks detail; update the comment above the snoozeAlarm function to explain behavior when no alarm is active (e.g., no-op or returns specific error), define behavior for repeated calls (whether it resets the snooze timer or is ignored), and clarify that the re-triggered alarm uses the provided snooze parameters (duration/other args) rather than the original alarm configuration; reference the snoozeAlarm function name and include expected return values/side-effects (stops vibration immediately, schedules restart after duration) and any edge-case behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@scripts/generate-git-info.mjs`:
- Around line 5-8: The run function currently places multiple statements on a
single line, violating the stylistic rule; refactor the run arrow function so
each statement is on its own line: expand the try block and its return (calling
execSync with { encoding: 'utf-8' } and .trim()), place the catch on its own
line (optionally capture the error as e) and return 'unknown' inside the catch
block; update the run declaration and references to use execSync and run exactly
as named to locate and fix the issue.
In `@src/hardware/snoozeManager.ts`:
- Line 11: The type literal for the config member uses semicolons which the
linter flags; replace the semicolons with commas so the declaration reads
config: { vibrationIntensity: number, vibrationPattern: 'double' | 'rise',
duration: number } and apply the same change to the other type/member reported
on line 49 (use commas between members instead of semicolons) so both type
literals conform to the lint rule.
In `@src/server/routers/system.ts`:
- Around line 420-434: The code calls readFile('.git-info') inside the anonymous
async query (the .query handler) which relies on the process CWD; change this to
compute an absolute path for '.git-info' based on the server file location
before calling readFile (e.g., resolve the path using the module file location /
__dirname or fileURLToPath(import.meta.url) + path.dirname and
path.resolve/join) so readFile reads a deterministic absolute path instead of a
relative one; update the readFile('.git-info', 'utf-8') call to use that
resolvedPath and ensure the deployment bundles the .git-info file alongside the
app.
In `@src/server/routers/waterLevel.ts`:
- Around line 91-110: The trend logic currently treats a single reading as
'stable'; change it to return 'unknown' when there are fewer than 2 readings by
checking the computed total (rows.length) before inferring trend: if total < 2
set trend = 'unknown' (or return { ..., trend: 'unknown' }) instead of
proceeding to compute mid/recentRate/olderRate; update the block that declares
and assigns trend (variable named trend) and the early-return for rows.length
=== 0 to ensure it also handles total === 1.
---
Nitpick comments:
In `@src/db/biometrics-migrations/0004_peaceful_mordo.sql`:
- Around line 8-15: Add a composite index to speed up `getAlerts` queries on the
`water_level_alerts` table which filter by `dismissed_at IS NULL` and order by
`created_at DESC`; create an index referencing `water_level_alerts(dismissed_at,
created_at)` (choose a clear name like
`idx_water_level_alerts_active_created_at`) so reads filter and sort without
full table scans and update the migration `0004_peaceful_mordo.sql` to include
the CREATE INDEX statement.
- Around line 17-21: The water_level_readings table currently allows arbitrary
text in column `level`, which can corrupt trend logic; update the migration that
creates `water_level_readings` to constrain `level` to an explicit domain (e.g.,
replace the loose `level` text type with a constraint such as a CHECK on `level`
IN ('low','normal','high') or your actual allowed set) by editing the CREATE
TABLE for `water_level_readings` to include that CHECK clause on `level`; ensure
the chosen allowed values match downstream code that reads `level` and, if
needed, add a follow-up migration to coerce or validate existing values before
applying the constraint.
In `@src/db/biometrics-migrations/meta/0004_snapshot.json`:
- Around line 705-756: Add an index to speed up queries for active alerts on the
water_level_alerts table by creating an index on the dismissed_at column (and
include type if you filter by type) — e.g., add a migration that creates
idx_water_level_alerts_type_dismissed on water_level_alerts(type, dismissed_at)
or a partial index on dismissed_at IS NULL if your DB supports it; update the
migrations/meta snapshot to include the new index entry for water_level_alerts
so future schema checks recognize the index.
In `@src/db/biometrics-schema.ts`:
- Around line 80-95: The alerts table (waterLevelAlerts) lacks an index on
dismissedAt, causing full table scans when querying active alerts (WHERE
dismissedAt IS NULL); add an index on the dismissedAt column (e.g.,
idx_water_level_alerts_dismissed_at) in the sqliteTable definition for
waterLevelAlerts so queries filtering by dismissedAt use the index and improve
performance.
In `@src/hardware/deviceStateSync.ts`:
- Line 94: The assignment unnecessarily re-casts status.waterLevel; replace the
line that sets const level = status.waterLevel === 'low' ? 'low' as const : 'ok'
as const with a direct assignment const level = status.waterLevel to use the
existing DeviceStatus typing (refer to the variable level and the
status.waterLevel usage in deviceStateSync.ts), removing the redundant
conditional and as const casts.
In `@src/hardware/snoozeManager.ts`:
- Around line 16-25: Add a local guard at the start of the exported function
snoozeAlarm to validate durationSeconds is a finite positive number (and
optionally not absurdly large); if it is not a finite number or <= 0, throw a
RangeError (or TypeError) with a clear message, and if it exceeds a defined
MAX_SNOOZE_SECONDS clamp it down or throw—this ensures callers can't pass
invalid durations before calling cancelSnooze and computing snoozeUntil;
introduce a MAX_SNOOZE_SECONDS constant if needed and reference snoozeAlarm,
cancelSnooze, and snoozeUntil in the change.
In `@src/server/routers/biometrics.ts`:
- Around line 544-547: The current setValues variable is typed as Record<string,
unknown>, losing compile-time validation for keys passed to Drizzle's .set() and
risking mismatches if schema changes; change its type to a type derived from the
actual table columns (e.g., Partial<Record<keyof typeof <tableName>, unknown>>
or better, Partial<InferModel<typeof <tableName>, "update">>) so TypeScript
verifies keys like enteredBedAt, leftBedAt, timesExitedBed, then build setValues
from updates and pass it to .set() — update the import to bring in the table
symbol and InferModel (or use keyof typeof <tableName>) and replace
Record<string, unknown> with the schema-derived type to restore type safety for
.set().
In `@src/server/routers/device.ts`:
- Around line 349-351: The docstring for snoozeAlarm lacks detail; update the
comment above the snoozeAlarm function to explain behavior when no alarm is
active (e.g., no-op or returns specific error), define behavior for repeated
calls (whether it resets the snooze timer or is ignored), and clarify that the
re-triggered alarm uses the provided snooze parameters (duration/other args)
rather than the original alarm configuration; reference the snoozeAlarm function
name and include expected return values/side-effects (stops vibration
immediately, schedules restart after duration) and any edge-case behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 710033ee-a3e4-413b-bba1-4e6b1b1e8c49
📒 Files selected for processing (18)
.gitignorepackage.jsonscripts/generate-git-info.mjssrc/db/biometrics-migrations/0004_peaceful_mordo.sqlsrc/db/biometrics-migrations/meta/0004_snapshot.jsonsrc/db/biometrics-migrations/meta/_journal.jsonsrc/db/biometrics-schema.tssrc/hardware/dacMonitor.instance.tssrc/hardware/deviceStateSync.tssrc/hardware/primeNotification.tssrc/hardware/snoozeManager.tssrc/server/openapi.tssrc/server/routers/app.tssrc/server/routers/biometrics.tssrc/server/routers/device.tssrc/server/routers/environment.tssrc/server/routers/system.tssrc/server/routers/waterLevel.ts
| .query(async () => { | ||
| const fallback = { branch: 'unknown', commitHash: 'unknown', commitTitle: 'unknown', buildDate: 'unknown' } | ||
| try { | ||
| const raw = await readFile('.git-info', 'utf-8') | ||
| const parsed = JSON.parse(raw) | ||
| return { | ||
| branch: typeof parsed.branch === 'string' ? parsed.branch : 'unknown', | ||
| commitHash: typeof parsed.commitHash === 'string' ? parsed.commitHash : 'unknown', | ||
| commitTitle: typeof parsed.commitTitle === 'string' ? parsed.commitTitle : 'unknown', | ||
| buildDate: typeof parsed.buildDate === 'string' ? parsed.buildDate : 'unknown', | ||
| } | ||
| } | ||
| catch { | ||
| return fallback | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how other files in the project resolve paths relative to project root
rg -n "readFile\s*\(" --type ts -g '!node_modules' | head -20Repository: sleepypod/core
Length of output: 147
🏁 Script executed:
cat package.json | head -30Repository: sleepypod/core
Length of output: 1128
🏁 Script executed:
fd -e "git-info" . --type fRepository: sleepypod/core
Length of output: 40
🏁 Script executed:
rg "\.git-info" --type ts --type json -g '!node_modules' -B 2 -A 2Repository: sleepypod/core
Length of output: 706
🏁 Script executed:
head -50 src/server/routers/system.tsRepository: sleepypod/core
Length of output: 1557
🏁 Script executed:
# Check if there are any build scripts or config that creates .git-info
rg "git-info" --type json --type js --type ts -g '!node_modules' -B 2 -A 2Repository: sleepypod/core
Length of output: 912
🏁 Script executed:
cat scripts/generate-git-info.mjsRepository: sleepypod/core
Length of output: 655
🏁 Script executed:
# Check Next.js config for output directory or other deployment-related settings
fd next.config -e js -e ts -e mjsRepository: sleepypod/core
Length of output: 73
🏁 Script executed:
# Check if .git-info is in .gitignore or other config files
cat .gitignore | head -50Repository: sleepypod/core
Length of output: 573
🏁 Script executed:
# Look for any other references to process.cwd() or __dirname in server code
rg "process\.cwd\(\)|__dirname|process\.env\..*HOME|path\.resolve" src/server --type ts -B 2 -A 2Repository: sleepypod/core
Length of output: 270
🏁 Script executed:
cat next.config.mjsRepository: sleepypod/core
Length of output: 527
🏁 Script executed:
# Check if there's any special handling in Next.js for output directory
rg "output|distDir|outputFileTracing" --type js --type ts -g '!node_modules'Repository: sleepypod/core
Length of output: 4597
Relative path .git-info may fail in production deployments.
The relative path '.git-info' depends on the process working directory being the project root. This works in development, but .git-info is not bundled into the Next.js .next directory and may not be present in standalone or containerized deployments unless explicitly deployed alongside the application.
Consider using an absolute path to make the dependency explicit:
💡 Suggested fix
+import { fileURLToPath } from 'node:url'
+import { dirname, join } from 'node:path'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const GIT_INFO_PATH = join(__dirname, '../../..', '.git-info')
+
// Then in the query:
- const raw = await readFile('.git-info', 'utf-8')
+ const raw = await readFile(GIT_INFO_PATH, 'utf-8')This resolves the path relative to the server file's location, making it work consistently across deployment environments. Ensure .git-info is deployed with your application (e.g., via Docker COPY, or deployment configuration).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/server/routers/system.ts` around lines 420 - 434, The code calls
readFile('.git-info') inside the anonymous async query (the .query handler)
which relies on the process CWD; change this to compute an absolute path for
'.git-info' based on the server file location before calling readFile (e.g.,
resolve the path using the module file location / __dirname or
fileURLToPath(import.meta.url) + path.dirname and path.resolve/join) so readFile
reads a deterministic absolute path instead of a relative one; update the
readFile('.git-info', 'utf-8') call to use that resolvedPath and ensure the
deployment bundles the .git-info file alongside the app.
Consensus fixes from Optimizer + Skeptic review: - Cancel active snoozes + reset prime state in shutdownDacMonitor (ghost timer leak) - Wrap trackPrimingState in error boundary (prevents uncaught exception crash) - Wrap updateSleepRecord select+update in transaction (TOCTOU race) - Replace getTrend in-memory row scan with SQL COUNT aggregation (10K row perf fix) - Change waterLevelReadings to uniqueIndex (consistency with other time-series tables) - Add index on waterLevelAlerts.dismissedAt for active-alert queries - Add try/catch to dismissAlert (pattern consistency) - Update migration SQL to match schema changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add snooze.left/snooze.right to device status response so iOS can recover snooze UI state after reload - Fix reportVitalsBatch to return actual inserted count via .returning() instead of input rows.length (pre-existing inaccuracy) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Expand try/catch to separate lines (max-statements-per-line lint rule) - Return trend: 'unknown' when fewer than 2 water level readings exist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds several “free-sleep beta” features across the API, hardware runtime, and biometrics DB to expose build/version info, support new device behaviors (snooze + priming completion notification), and add new environment/water-level data surfaces.
Changes:
- Add build metadata generation (
.git-info) at build time and expose it viaGET /system/version. - Add alarm snooze scheduling + prime-completion notification into device status and new device endpoints.
- Extend biometrics DB + routers for ambient light + water level data, and add sleep-record update/delete.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/server/routers/waterLevel.ts | New router exposing water level history/latest/trend + alert listing/dismiss |
| src/server/routers/system.ts | Adds GET /system/version reading .git-info |
| src/server/routers/environment.ts | Adds ambient light history/latest/summary endpoints |
| src/server/routers/device.ts | Adds snooze endpoint + prime notification fields + dismiss endpoint; extends status payload |
| src/server/routers/biometrics.ts | Adds sleep record update/delete; changes vitals batch “written” count to actual inserted rows |
| src/server/routers/app.ts | Registers waterLevel router in app router |
| src/server/openapi.ts | Adds “Water Level” tag to OpenAPI doc |
| src/hardware/snoozeManager.ts | New in-memory snooze scheduler (timeouts) per side |
| src/hardware/primeNotification.ts | New in-memory prime completion notification state machine |
| src/hardware/deviceStateSync.ts | Writes rate-limited water level readings into biometrics DB |
| src/hardware/dacMonitor.instance.ts | Tracks priming transitions; cancels snoozes/resets notification on shutdown |
| src/db/biometrics-schema.ts | Adds ambient_light, water_level_readings, water_level_alerts tables |
| src/db/biometrics-migrations/meta/_journal.json | Adds migration journal entry for 0004 |
| src/db/biometrics-migrations/meta/0004_snapshot.json | New snapshot for biometrics migration 0004 |
| src/db/biometrics-migrations/0004_peaceful_mordo.sql | Migration creating new ambient light + water level tables/indexes |
| scripts/generate-git-info.mjs | New prebuild script generating .git-info from git metadata |
| package.json | Adds prebuild hook to generate .git-info |
| modules/common/calibration.py | Adds CapSense2 calibration + presence detection helper |
| modules/calibrator/main.py | Prefers CapSense2 calibration records when available |
| .gitignore | Ignores .git-info build artifact |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| .where(and( | ||
| gte(waterLevelReadings.timestamp, midpoint), | ||
| sql`${waterLevelReadings.level} = 'low'`, | ||
| )) |
| "columns": [ | ||
| "timestamp" | ||
| ], | ||
| "isUnique": false |
| export function snoozeAlarm( | ||
| side: Side, | ||
| durationSeconds: number, | ||
| config: SnoozeState['config'], | ||
| ): Date { | ||
| // Cancel any existing snooze for this side | ||
| cancelSnooze(side) | ||
|
|
||
| const snoozeUntil = new Date(Date.now() + durationSeconds * 1000) | ||
|
|
||
| const timeoutId = setTimeout(async () => { | ||
| activeSnoozes.delete(side) | ||
| try { | ||
| const client = getSharedHardwareClient() | ||
| await client.setAlarm(side, config) | ||
| } | ||
| catch (err) { | ||
| console.error(`[Snooze] Failed to restart alarm for ${side}:`, err) | ||
| } | ||
| }, durationSeconds * 1000) |
| export function trackPrimingState(isPriming: boolean): void { | ||
| if (!wasPriming && isPriming) { | ||
| // New priming cycle — clear stale notification | ||
| primeCompletedAt = null | ||
| } | ||
| else if (wasPriming && !isPriming) { | ||
| primeCompletedAt = new Date() | ||
| } | ||
| wasPriming = isPriming | ||
| } | ||
|
|
||
| export function getPrimeCompletedAt(): number | null { | ||
| return primeCompletedAt ? Math.floor(primeCompletedAt.getTime() / 1000) : null | ||
| } | ||
|
|
||
| export function dismissPrimeNotification(): void { | ||
| primeCompletedAt = null | ||
| } |
| snoozeAlarm: publicProcedure | ||
| .meta({ openapi: { method: 'POST', path: '/device/alarm/snooze', protect: false, tags: ['Device'] } }) | ||
| .input( | ||
| z.object({ | ||
| side: sideSchema, | ||
| duration: z.number().int().min(60).max(1800).default(300), |
| getHistory: publicProcedure | ||
| .meta({ openapi: { method: 'GET', path: '/water-level/history', protect: false, tags: ['Water Level'] } }) | ||
| .input(z.object({ | ||
| startDate: z.date().optional(), | ||
| endDate: z.date().optional(), | ||
| limit: z.number().int().min(1).max(10000).default(1440), |
| LOOKBACK_HOURS = 6 | ||
| MIN_WINDOW_S = 300 # 5 minutes at ~2 Hz = ~600 samples | ||
| MIN_STD = 0.05 # floats in the tens, not ints in the hundreds | ||
| # Sensing channel pairs: (name, index_a, index_b) | ||
| SENSE_PAIRS = (("A", 0, 1), ("B", 2, 3), ("C", 4, 5)) | ||
| REF_PAIR = ("REF", 6, 7) | ||
|
|
||
| def calibrate(self, records: list, side: str) -> CalibrationResult: | ||
| """Find the quietest 5-min window and compute per-channel baselines.""" | ||
| if not records: | ||
| raise ValueError("No capSense2 records available for calibration") | ||
|
|
||
| timestamps = [] | ||
| channels = {name: [] for name, _, _ in self.SENSE_PAIRS} | ||
| channels["REF"] = [] | ||
|
|
||
| for rec in records: | ||
| data = rec.get(side, {}) | ||
| vals = data.get("values") if data else None | ||
| if not vals or len(vals) < 8: | ||
| continue | ||
| ts = float(rec.get("ts", 0)) | ||
| timestamps.append(ts) | ||
| for name, ia, ib in self.SENSE_PAIRS: | ||
| channels[name].append((vals[ia] + vals[ib]) / 2.0) | ||
| rn, ria, rib = self.REF_PAIR | ||
| channels["REF"].append((vals[ria] + vals[rib]) / 2.0) | ||
|
|
||
| if len(timestamps) < 60: | ||
| raise ValueError( | ||
| f"Insufficient capSense2 data: {len(timestamps)} samples (need >= 60)" | ||
| ) | ||
|
|
||
| # Find quietest 5-min window across sensing channels only | ||
| window = min(self.MIN_WINDOW_S, len(timestamps)) | ||
| best_start = 0 | ||
| best_variance = float("inf") | ||
| sense_names = [name for name, _, _ in self.SENSE_PAIRS] | ||
|
|
||
| for i in range(len(timestamps) - window + 1): | ||
| total_var = 0.0 | ||
| for name in sense_names: | ||
| seg = channels[name][i:i + window] | ||
| m = sum(seg) / len(seg) | ||
| total_var += sum((x - m) ** 2 for x in seg) / len(seg) | ||
| if total_var < best_variance: |
| export function snoozeAlarm( | ||
| side: Side, | ||
| durationSeconds: number, | ||
| config: SnoozeState['config'], | ||
| ): Date { | ||
| // Cancel any existing snooze for this side | ||
| cancelSnooze(side) | ||
|
|
||
| const snoozeUntil = new Date(Date.now() + durationSeconds * 1000) | ||
|
|
||
| const timeoutId = setTimeout(async () => { | ||
| activeSnoozes.delete(side) | ||
| try { | ||
| const client = getSharedHardwareClient() | ||
| await client.setAlarm(side, config) | ||
| } | ||
| catch (err) { | ||
| console.error(`[Snooze] Failed to restart alarm for ${side}:`, err) | ||
| } | ||
| }, durationSeconds * 1000) | ||
|
|
||
| activeSnoozes.set(side, { timeoutId, snoozeUntil, config }) | ||
| return snoozeUntil | ||
| } | ||
|
|
||
| export function cancelSnooze(side: Side): void { | ||
| const existing = activeSnoozes.get(side) | ||
| if (existing) { | ||
| clearTimeout(existing.timeoutId) | ||
| activeSnoozes.delete(side) | ||
| } | ||
| } | ||
|
|
||
| export function getSnoozeStatus(side: Side): { active: boolean; snoozeUntil: number | null } { | ||
| const state = activeSnoozes.get(side) | ||
| return { | ||
| active: !!state, | ||
| snoozeUntil: state ? Math.floor(state.snoozeUntil.getTime() / 1000) : null, | ||
| } | ||
| } |
| dismissPrimeNotification: publicProcedure | ||
| .meta({ openapi: { method: 'POST', path: '/device/prime/dismiss', protect: false, tags: ['Device'] } }) | ||
| .input(z.object({})) | ||
| .output(z.object({ success: z.boolean() })) | ||
| .mutation(() => { | ||
| dismissPrimeNotification() | ||
| return { success: true } |
| updateSleepRecord: publicProcedure | ||
| .meta({ openapi: { method: 'PUT', path: '/biometrics/sleep-records', protect: false, tags: ['Biometrics'] } }) | ||
| .input( | ||
| z.object({ | ||
| id: idSchema, | ||
| enteredBedAt: z.date().optional(), | ||
| leftBedAt: z.date().optional(), |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements a bundle of “free-sleep beta” features across the server API, hardware monitor layer, and biometrics DB schema/migrations (version reporting, snooze + prime completion notifications, ambient light + water level telemetry, and sleep record update/delete).
Changes:
- Add new REST/tRPC endpoints for system version, sleep record update/delete, ambient light history/summary/latest, and water level history/trend/alerts + dismiss.
- Add hardware-side in-memory managers for alarm snooze scheduling and prime completion notifications; integrate into device status and monitor shutdown.
- Extend biometrics schema + migrations with
ambient_light,water_level_readings, andwater_level_alerts, and write water level readings fromDeviceStateSync.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/server/routers/waterLevel.ts | New water level endpoints (history/latest/trend + alerts/dismiss). |
| src/server/routers/system.ts | Adds GET /system/version reading build info from .git-info. |
| src/server/routers/environment.ts | Adds ambient light endpoints backed by ambient_light table. |
| src/server/routers/device.ts | Extends device status with snooze + prime completion notification; adds snooze + prime-dismiss endpoints; cancels snooze on alarm changes. |
| src/server/routers/biometrics.ts | Adds sleep record update/delete mutations; adjusts vitals batch insert reporting. |
| src/server/routers/app.ts | Registers the new waterLevel router. |
| src/server/openapi.ts | Adds “Water Level” OpenAPI tag. |
| src/hardware/snoozeManager.ts | New in-memory snooze timeout manager that re-triggers alarms after a delay. |
| src/hardware/primeNotification.ts | New in-memory prime completion notification tracker + reset/dismiss helpers. |
| src/hardware/deviceStateSync.ts | Writes water level readings to biometrics DB (rate-limited). |
| src/hardware/dacMonitor.instance.ts | Tracks priming transitions; cancels snoozes and resets priming state on shutdown. |
| src/db/biometrics-schema.ts | Adds Drizzle table definitions for ambient light + water level readings/alerts. |
| src/db/biometrics-migrations/meta/_journal.json | Registers biometrics migration 0004. |
| src/db/biometrics-migrations/meta/0004_snapshot.json | Snapshot for migration 0004 (schema state). |
| src/db/biometrics-migrations/0004_peaceful_mordo.sql | Migration creating the 3 new biometrics tables + indexes. |
| scripts/generate-git-info.mjs | New build-time script generating .git-info. |
| package.json | Runs git-info generation via prebuild. |
| modules/common/calibration.py | Adds CapSense2 calibration + presence detection logic for Pod 5. |
| modules/calibrator/main.py | Prefers CapSense2 calibrator when capSense2 RAW records exist. |
| .gitignore | Ignores generated .git-info. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| .where(and( | ||
| gte(waterLevelReadings.timestamp, midpoint), | ||
| sql`${waterLevelReadings.level} = 'low'`, | ||
| )) |
| ) | ||
|
|
||
| # Find quietest 5-min window across sensing channels only | ||
| window = min(self.MIN_WINDOW_S, len(timestamps)) |
| "water_level_alerts": { | ||
| "name": "water_level_alerts", | ||
| "columns": { | ||
| "id": { | ||
| "name": "id", | ||
| "type": "integer", | ||
| "primaryKey": true, | ||
| "notNull": true, | ||
| "autoincrement": true | ||
| }, | ||
| "type": { | ||
| "name": "type", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": true, | ||
| "autoincrement": false | ||
| }, | ||
| "started_at": { | ||
| "name": "started_at", | ||
| "type": "integer", | ||
| "primaryKey": false, | ||
| "notNull": true, | ||
| "autoincrement": false | ||
| }, | ||
| "dismissed_at": { | ||
| "name": "dismissed_at", | ||
| "type": "integer", | ||
| "primaryKey": false, | ||
| "notNull": false, | ||
| "autoincrement": false | ||
| }, | ||
| "message": { | ||
| "name": "message", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false, | ||
| "autoincrement": false | ||
| }, | ||
| "created_at": { | ||
| "name": "created_at", | ||
| "type": "integer", | ||
| "primaryKey": false, | ||
| "notNull": true, | ||
| "autoincrement": false | ||
| } | ||
| }, | ||
| "indexes": {}, | ||
| "foreignKeys": {}, | ||
| "compositePrimaryKeys": {}, | ||
| "uniqueConstraints": {}, | ||
| "checkConstraints": {} | ||
| }, | ||
| "water_level_readings": { | ||
| "name": "water_level_readings", | ||
| "columns": { | ||
| "id": { | ||
| "name": "id", | ||
| "type": "integer", | ||
| "primaryKey": true, | ||
| "notNull": true, | ||
| "autoincrement": true | ||
| }, | ||
| "timestamp": { | ||
| "name": "timestamp", | ||
| "type": "integer", | ||
| "primaryKey": false, | ||
| "notNull": true, | ||
| "autoincrement": false | ||
| }, | ||
| "level": { | ||
| "name": "level", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": true, | ||
| "autoincrement": false | ||
| } | ||
| }, | ||
| "indexes": { | ||
| "idx_water_level_timestamp": { | ||
| "name": "idx_water_level_timestamp", | ||
| "columns": [ | ||
| "timestamp" | ||
| ], | ||
| "isUnique": false | ||
| } |
| .meta({ openapi: { method: 'GET', path: '/water-level/history', protect: false, tags: ['Water Level'] } }) | ||
| .input(z.object({ | ||
| startDate: z.date().optional(), |
- Replace raw SQL fragments with eq() in waterLevel trend queries - Regenerate migration snapshot (fixes uniqueIndex + adds alerts index) - Add corrective migration 0005 for index fixes - Wire cancelSnooze into GestureActionHandler dismiss path - Add unit tests for snoozeManager (6 tests) and primeNotification (6 tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The static import of snoozeManager pulls in dacMonitor.instance which imports @/src/db — breaking gestureActionHandler.test.ts in CI where the DB module can't resolve. Use dynamic import() instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
🎉 This PR is included in version 1.1.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Implements 7 features from free-sleep beta, credited to @Geczy, @throwaway31265, and nikita.
GET /system/versionreturns{branch, commitHash, commitTitle, buildDate}from.git-infogenerated at build timePOST /device/alarm/snoozeclears alarm immediately, restarts after configurable duration (60-1800s). Cancel-on-dismiss prevents ghost re-triggersPUT/DELETE /biometrics/sleep-recordswith automaticsleepDurationSecondsrecalculation on timestamp editsisPrimingtransitions in DacMonitor; addsprimeCompletedNotificationto device status +POST /device/prime/dismissambient_lighttable +GET /environment/ambient-light,/latest,/summaryendpoints (OPT4001 sensor data)water_level_readings+water_level_alertstables. DeviceStateSync writes readings (rate-limited to 60s). Trend analysis endpoint computesstable/declining/rising. Alert dismiss endpoint0004) for the 3 new tablesTest plan
pnpm tscpassespnpm build🤖 Generated with Claude Code
Summary by CodeRabbit