Skip to content
Merged
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
78 changes: 78 additions & 0 deletions docs/superpowers/plans/2026-07-29-body-metrics-timezone-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Body Metrics Timezone Filter TDD Plan

> **For agentic workers:** Use the repository's test-driven-development and
> integration-test readiness workflows. Steps use checkbox (`- [ ]`) syntax
> for tracking.

**Goal:** Make exact-range body-metric reads filter the native ClickHouse
`DateTime64` value before serializing timestamps for the API.

**Behavior:** `BodyRepository.listRange()` returns measurements whose timestamps
fall inside the requested inclusive local-date range without passing a `String`
alias to `toTimeZone`.

**Scope:** Change the body repository query and add a real ClickHouse regression
test. Do not change the serving view, MCP contract, schemas, or unrelated body
queries.

**Docs:** [ClickHouse explains that aliases can be reused throughout a query and
recommends a subquery as the portable way to scope derived
expressions](https://presentations.clickhouse.com/meetup70/modern_sql/).

---

## Current Evidence

- `BodyRepository.listRange()` projects
`toString(recorded_at) AS recorded_at` while its `WHERE` clause calls
`toTimeZone(recorded_at, ...)`.
- ClickHouse resolves the `WHERE` identifier to the projected `String` alias
and raises `Illegal type String of argument of function toTimezone`.
- `BodyRepository.list()` already avoids this collision by filtering raw
timestamps in an inner `body_measurements` query and serializing them only in
the outer projection.

## Test Strategy

- Unit: Retain the existing repository mapping and parameter tests.
- Integration: Seed isolated ClickHouse body samples on opposite sides of a
local-date boundary, call `listRange()` through the real query runner, and
assert that only the in-range local date is returned.
- UI/mobile/web parity: Not applicable; the MCP and router share this
server-side repository behavior.

## File Structure

- Create:
`packages/server/src/repositories/body-repository.integration.test.ts` -
execute the range query against an isolated real ClickHouse database.
- Modify: `packages/server/src/repositories/body-repository.ts` - scope raw
timestamp filtering to an inner query.

## Tasks

### Task 1: Add the Failing Integration Test

- [ ] Seed two measurements straddling the requested local-date boundary.
- [ ] Run
`rtk bash -lc 'set -a; . ./.env.local; set +a; pnpm vitest run --project integration packages/server/src/repositories/body-repository.integration.test.ts'`.
- [ ] Confirm the query fails because `toTimeZone` receives the stringified
`recorded_at` alias.

### Task 2: Implement the Minimal Query Fix

- [ ] Filter raw `recorded_at` values inside an inner `body_measurements`
subquery.
- [ ] Serialize `recorded_at`, `created_at`, IDs, and the remaining fields only
in the outer projection.
- [ ] Re-run the focused integration test and confirm the local-date boundary
behavior passes.

### Task 3: Final Verification

- [ ] Run the existing body repository unit tests.
- [ ] Run the relevant lint, typecheck, and changed-test commands.
- [ ] Review the sibling body queries for the same alias collision without
broadening the change beyond confirmed occurrences.
- [ ] Commit, push, open the linked PR, monitor CI and reviews, and merge only
after every required check passes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { setupTestDatabase, type TestContext } from "../../../../src/db/test-helpers.ts";
import {
createClickHouseTestActivitySensorStore,
executeClickHouseTestCommand,
getClickHouseTestClient,
} from "../routers/clickhouse-integration-test-helpers.ts";
import type { BodyClickHouseStore } from "./body-clickhouse.ts";
import { BodyRepository } from "./body-repository.ts";

describe("BodyRepository exact local-date range", () => {
let store: BodyClickHouseStore;
let testContext: TestContext;

beforeAll(async () => {
testContext = await setupTestDatabase();
store = await createClickHouseTestActivitySensorStore(testContext);
}, 120_000);

afterAll(async () => {
await testContext?.cleanup();
});

it("filters native timestamps before serializing rows", async () => {
const client = getClickHouseTestClient(testContext);
const userId = randomUUID();
const priorLocalDateId = randomUUID();
const matchingLocalDateId = randomUUID();

for (const [index, row] of [
{
id: priorLocalDateId,
recordedAt: "2026-05-29 06:55:00",
},
{
id: matchingLocalDateId,
recordedAt: "2026-05-29 07:05:00",
},
].entries()) {
await client.command({
query: `INSERT INTO analytics.body_measurement_sample (
id, provider_id, user_id, recorded_at, channel, external_id, device_id,
source_type, scalar, _peerdb_synced_at, _peerdb_is_deleted, _peerdb_version
) VALUES (
{id:UUID}, 'withings', {userId:UUID}, {recordedAt:DateTime64(6, 'UTC')},
'body_weight', {externalId:String}, 'Body Scale', 'api', 80,
now64(9), 0, {version:Int64}
)`,
query_params: {
externalId: `body-range-${index}`,
id: row.id,
recordedAt: row.recordedAt,
userId,
version: index + 1,
},
});
}

await executeClickHouseTestCommand(
testContext,
"REBUILD TEST ANALYTICS TABLE analytics.v_body_measurement",
);

const repository = new BodyRepository(store, userId, "America/Los_Angeles");
const rows = await repository.listRange("2026-05-29", "2026-05-29");

expect(rows.map((row) => row.id)).toEqual([matchingLocalDateId]);
});
});
4 changes: 4 additions & 0 deletions packages/server/src/repositories/body-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ describe("BodyRepository", () => {
endDate: "2024-01-15",
},
);
const queryText = query.mock.calls[0]?.[1] ?? "";
expect(queryText).toContain("toString(body_measurements.recorded_at) AS recorded_at");
expect(queryText).toContain(") AS body_measurements");
expect(queryText).toContain("ORDER BY body_measurements.recorded_at ASC");
expect(result[0]).toBeInstanceOf(BodyMeasurement);
expect(result[0]?.id).toBe("bm-range-1");
});
Expand Down
34 changes: 28 additions & 6 deletions packages/server/src/repositories/body-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export class BodyRepository {
`
SELECT
toString(id) AS id,
toString(recorded_at) AS recorded_at,
toString(body_measurements.recorded_at) AS recorded_at,
provider_id,
toString(user_id) AS user_id,
external_id,
Expand All @@ -188,11 +188,33 @@ export class BodyRepository {
temperature_c,
source_name,
toString(created_at) AS created_at
FROM analytics.v_body_measurement
WHERE user_id = {userId:UUID}
AND toDate(toTimeZone(recorded_at, {timezone:String})) >= toDate({startDate:String})
AND toDate(toTimeZone(recorded_at, {timezone:String})) <= toDate({endDate:String})
ORDER BY recorded_at ASC
FROM (
SELECT
id,
recorded_at,
provider_id,
user_id,
external_id,
weight_kg,
body_fat_pct,
muscle_mass_kg,
bone_mass_kg,
water_pct,
bmi,
height_cm,
waist_circumference_cm,
systolic_bp,
diastolic_bp,
heart_pulse,
temperature_c,
source_name,
created_at
FROM analytics.v_body_measurement
WHERE user_id = {userId:UUID}
AND toDate(toTimeZone(recorded_at, {timezone:String})) >= toDate({startDate:String})
AND toDate(toTimeZone(recorded_at, {timezone:String})) <= toDate({endDate:String})
) AS body_measurements
ORDER BY body_measurements.recorded_at ASC
`,
{ userId: this.#userId, timezone: this.#timezone, startDate, endDate },
);
Expand Down
Loading