From e830a8ae04cd3e40604bef911caafbfc585b0025 Mon Sep 17 00:00:00 2001 From: NilsLeo Date: Fri, 22 May 2026 15:58:23 +0200 Subject: [PATCH 1/5] Add biometrics logging command for weight, body fat, and blood pressure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a new `biometrics` command to the CLI that allows logging weight, body fat percentage, and blood pressure to Cronometer. Features: - Weight logging with unit support (kg or lbs) - Body fat percentage logging - Blood pressure logging (systolic/diastolic) - Date support (YYYY-MM-DD, yesterday, -Nd) - Integration with existing automation framework Implementation: - Created src/commands/biometrics.ts - CLI command handler - Created src/kernel/biometrics.ts - Playwright automation code generator - Updated src/automation/types.ts - Added BiometricEntry interface - Updated src/automation/runner.ts - Added logBiometric method - Updated src/index.ts - Registered biometrics command Technical details: - Navigates to dashboard (where BIOMETRIC button is located) - Uses getByRole selectors for reliable element targeting - Implements Tab key press after input to trigger form validation - Handles different input patterns for each biometric type All three biometric types have been tested successfully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/automation/runner.ts | 21 ++++ src/automation/types.ts | 13 +++ src/commands/biometrics.ts | 117 ++++++++++++++++++++++ src/index.ts | 14 +++ src/kernel/biometrics.ts | 197 +++++++++++++++++++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 src/commands/biometrics.ts create mode 100644 src/kernel/biometrics.ts diff --git a/src/automation/runner.ts b/src/automation/runner.ts index 8c942f0..981de38 100644 --- a/src/automation/runner.ts +++ b/src/automation/runner.ts @@ -1,6 +1,7 @@ import * as p from "@clack/prompts"; import { getCredential } from "../credentials.js"; import { buildAddCustomFoodCode } from "../kernel/add-custom-food.js"; +import { buildBiometricsCode } from "../kernel/biometrics.js"; import { buildDiaryCode } from "../kernel/diary.js"; import { buildLogFoodCode } from "../kernel/log-food.js"; import { buildRecipesCode } from "../kernel/recipes.js"; @@ -15,6 +16,7 @@ import type { AutomationClient, AutomationRuntime, AutomationRuntimeFactory, + BiometricEntry, CustomFoodEntry, DiaryData, LogFoodEntry, @@ -114,6 +116,25 @@ export function createAutomationClient( } return data.recipes ?? []; }), + logBiometric: (entry: BiometricEntry, onStatus?: (msg: string) => void) => + withLoggedInRuntime(createRuntime, onStatus, async (runtime) => { + const typeLabel = + entry.type === "weight" + ? "weight" + : entry.type === "bodyfat" + ? "body fat" + : "blood pressure"; + onStatus?.(`Logging ${typeLabel}...`); + const data = await executeAutomation<{ + success: boolean; + error?: string; + }>(runtime, buildBiometricsCode(entry), 60); + if (!data.success) { + throw new Error( + `Biometric logging failed: ${data.error ?? "Unknown error"}` + ); + } + }), }; } diff --git a/src/automation/types.ts b/src/automation/types.ts index db3bbfc..5c22e27 100644 --- a/src/automation/types.ts +++ b/src/automation/types.ts @@ -49,6 +49,15 @@ export interface RecipeData { name: string; } +export interface BiometricEntry { + type: "weight" | "bodyfat" | "bloodpressure"; + value?: number; + unit?: string; + systolic?: number; + diastolic?: number; + date: string; +} + export interface AutomationClient { addQuickEntry( entry: MacroEntry, @@ -69,6 +78,10 @@ export interface AutomationClient { ): Promise; logFood(entry: LogFoodEntry, onStatus?: (msg: string) => void): Promise; getRecipes(onStatus?: (msg: string) => void): Promise; + logBiometric( + entry: BiometricEntry, + onStatus?: (msg: string) => void + ): Promise; } export interface PlaywrightExecutionResponse { diff --git a/src/commands/biometrics.ts b/src/commands/biometrics.ts new file mode 100644 index 0000000..3f2c749 --- /dev/null +++ b/src/commands/biometrics.ts @@ -0,0 +1,117 @@ +import * as p from "@clack/prompts"; +import { getAutomationClient } from "../automation/client.js"; +import { formatKernelError } from "../kernel/errors.js"; +import { parseDate, todayStr } from "../utils/date.js"; + +export interface BiometricsOptions { + weight?: number; + bodyFat?: number; + systolic?: number; + diastolic?: number; + date?: string; + unit?: string; +} + +export async function biometrics(options: BiometricsOptions): Promise { + // Validate at least one biometric is provided + if ( + options.weight === undefined && + options.bodyFat === undefined && + (options.systolic === undefined || options.diastolic === undefined) + ) { + p.log.error( + "At least one biometric required: --weight, --body-fat, or both --systolic and --diastolic" + ); + process.exit(1); + } + + // Validate blood pressure requires both values + if ( + (options.systolic !== undefined && options.diastolic === undefined) || + (options.systolic === undefined && options.diastolic !== undefined) + ) { + p.log.error("Blood pressure requires both --systolic and --diastolic"); + process.exit(1); + } + + // Validate weight unit + const unit = options.unit?.toLowerCase() || "kg"; + if (unit !== "kg" && unit !== "lbs") { + p.log.error('Weight unit must be "kg" or "lbs"'); + process.exit(1); + } + + // Resolve date + let targetDate: string; + try { + targetDate = options.date ? parseDate(options.date) : todayStr(); + } catch (error) { + p.log.error(String(error instanceof Error ? error.message : error)); + process.exit(1); + } + + p.intro("🍎 crono biometrics"); + + const s = p.spinner(); + s.start("Connecting..."); + + try { + const client = await getAutomationClient(); + + // Log weight if provided + if (options.weight !== undefined) { + await client.logBiometric( + { + type: "weight", + value: options.weight, + unit, + date: targetDate, + }, + (msg) => s.message(msg) + ); + } + + // Log body fat if provided + if (options.bodyFat !== undefined) { + await client.logBiometric( + { + type: "bodyfat", + value: options.bodyFat, + date: targetDate, + }, + (msg) => s.message(msg) + ); + } + + // Log blood pressure if provided + if (options.systolic !== undefined && options.diastolic !== undefined) { + await client.logBiometric( + { + type: "bloodpressure", + systolic: options.systolic, + diastolic: options.diastolic, + date: targetDate, + }, + (msg) => s.message(msg) + ); + } + + s.stop("Done."); + + const logged: string[] = []; + if (options.weight !== undefined) + logged.push(`Weight: ${options.weight} ${unit}`); + if (options.bodyFat !== undefined) + logged.push(`Body Fat: ${options.bodyFat}%`); + if (options.systolic !== undefined && options.diastolic !== undefined) + logged.push( + `Blood Pressure: ${options.systolic}/${options.diastolic} mmHg` + ); + + p.outro(`Logged biometrics for ${targetDate}:\n ${logged.join("\n ")}`); + } catch (error) { + s.stop("Failed."); + p.log.error(`Failed to log biometrics: ${formatKernelError(error)}`); + process.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts index 840d4a5..fec1fd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { diary } from "./commands/diary.js"; import { weight } from "./commands/weight.js"; import { exportCmd } from "./commands/export.js"; import { recipes } from "./commands/recipes.js"; +import { biometrics } from "./commands/biometrics.js"; const require = createRequire(import.meta.url); const { version } = require("../package.json"); @@ -120,4 +121,17 @@ program await recipes(options); }); +program + .command("biometrics") + .description("Log biometrics to Cronometer (weight, body fat, blood pressure)") + .option("-w, --weight ", "Weight value", parseFloat) + .option("-b, --body-fat ", "Body fat percentage", parseFloat) + .option("-s, --systolic ", "Systolic blood pressure (mmHg)", parseFloat) + .option("-d, --diastolic ", "Diastolic blood pressure (mmHg)", parseFloat) + .option("--date ", "Date (YYYY-MM-DD, yesterday, -1d)") + .option("-u, --unit ", "Weight unit (kg or lbs)", "kg") + .action(async (options) => { + await biometrics(options); + }); + program.parse(); diff --git a/src/kernel/biometrics.ts b/src/kernel/biometrics.ts new file mode 100644 index 0000000..65dc12d --- /dev/null +++ b/src/kernel/biometrics.ts @@ -0,0 +1,197 @@ +/** + * Playwright code generator for Cronometer biometrics logging. + * + * Supports logging weight, body fat percentage, and blood pressure. + */ + +export interface BiometricEntry { + type: "weight" | "bodyfat" | "bloodpressure"; + value?: number; + unit?: string; + systolic?: number; + diastolic?: number; + date: string; +} + +/** + * Generate Playwright code for logging a biometric to Cronometer. + * Navigates to the diary, clicks BIOMETRIC button, selects the biometric type, + * fills in values, and adds to diary. + */ +export function buildBiometricsCode(entry: BiometricEntry): string { + const { type, value, unit, systolic, diastolic, date } = entry; + + // Calculate days back from today + const targetDateObj = new Date(date + "T00:00:00"); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const daysBack = Math.floor( + (today.getTime() - targetDateObj.getTime()) / (1000 * 60 * 60 * 24) + ); + + const biometricName = + type === "weight" + ? "Weight" + : type === "bodyfat" + ? "Body Fat" + : "Blood Pressure"; + + return ` + // Navigate to dashboard first (where the BIOMETRIC button is) + await page.goto('https://cronometer.com/#dashboard', { waitUntil: 'domcontentloaded', timeout: 15000 }); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(2000); + + // Check if logged in + const url = page.url(); + if (url.includes('/login') || url.includes('/signin')) { + return { success: false, error: 'Not logged in. Login may have failed.' }; + } + + // Wait for the quick add panel to appear + try { + await page.waitForSelector('text="Quick Add to Diary"', { timeout: 10000 }); + } catch { + return { success: false, error: 'Dashboard quick add panel not found' }; + } + await page.waitForTimeout(500); + + // Wait for and click BIOMETRIC button + try { + await page.waitForSelector('button:has-text("BIOMETRIC"), button:has-text("Biometric")', { timeout: 10000 }); + } catch { + return { success: false, error: 'Could not find BIOMETRIC button' }; + } + + const biometricButton = page.getByRole('button', { name: /BIOMETRIC/i }); + if (await biometricButton.count() === 0) { + return { success: false, error: 'BIOMETRIC button not found' }; + } + await biometricButton.first().click(); + await page.waitForTimeout(1000); + + // Wait for biometric dialog + try { + await page.waitForSelector('text="Add Biometric"', { timeout: 5000 }); + } catch { + return { success: false, error: 'Add Biometric dialog did not appear' }; + } + await page.waitForTimeout(300); + + // Click the biometric type (Weight, Body Fat, or Blood Pressure) + // Look for the biometric item by finding text with the name and clicking its container + const biometricItem = page.locator('.biometric-content-panel.pretty-row').filter({ hasText: '${biometricName}' }).first(); + const itemCount = await biometricItem.count(); + + if (itemCount === 0) { + return { success: false, error: 'Could not find ${biometricName} in biometric list' }; + } + + await biometricItem.click(); + await page.waitForTimeout(1000); + + ${type === "weight" ? buildWeightInputCode(value!, unit!) : ""} + ${type === "bodyfat" ? buildBodyFatInputCode(value!) : ""} + ${type === "bloodpressure" ? buildBloodPressureInputCode(systolic!, diastolic!) : ""} + + // Wait for ADD TO DIARY button to become enabled, then click it + const addButton = page.getByRole('button', { name: /add.*diary/i }); + + // Wait for button to be enabled + await page.waitForTimeout(500); + const isEnabled = await addButton.isEnabled().catch(() => false); + if (!isEnabled) { + return { success: false, error: 'Add to Diary button is disabled. Value may not have been filled correctly.' }; + } + + await addButton.click(); + + // Wait for dialog to close + await page.waitForSelector('text="Add Biometric"', { state: 'hidden', timeout: 5000 }).catch(() => {}); + await page.waitForTimeout(500); + + return { success: true }; + `; +} + +function buildWeightInputCode(value: number, unit: string): string { + return ` + // Fill weight value using getByRole + const weightInput = page.getByRole('textbox', { name: 'Weight' }); + if (await weightInput.count() === 0) { + return { success: false, error: 'Could not find weight input field' }; + } + await weightInput.fill(String(${value})); + // Press Tab to trigger validation and move focus + await weightInput.press('Tab'); + await page.waitForTimeout(500); + + // Select unit if not kg + if ('${unit}' === 'lbs') { + const unitButton = page.getByRole('button', { name: /Unit.*kg/i }); + if (await unitButton.count() > 0) { + await unitButton.click(); + await page.waitForTimeout(300); + // Select lbs from dropdown + const lbsOption = page.getByText('lbs', { exact: true }); + if (await lbsOption.count() > 0) { + await lbsOption.click(); + } + } + } + await page.waitForTimeout(500); + `; +} + +function buildBodyFatInputCode(value: number): string { + return ` + // Fill body fat percentage using getByRole + const bodyFatInput = page.getByRole('textbox', { name: 'Body Fat' }); + if (await bodyFatInput.count() === 0) { + return { success: false, error: 'Could not find body fat input field' }; + } + await bodyFatInput.fill(String(${value})); + // Press Tab to trigger validation and move focus + await bodyFatInput.press('Tab'); + await page.waitForTimeout(500); + `; +} + +function buildBloodPressureInputCode( + systolic: number, + diastolic: number +): string { + return ` + // Fill blood pressure values + // After clicking "Blood Pressure", there should be exactly 2 visible textbox inputs + const allTextboxes = page.getByRole('textbox'); + const visibleInputs = []; + + for (let i = 0; i < await allTextboxes.count(); i++) { + const input = allTextboxes.nth(i); + if (await input.isVisible()) { + const placeholder = await input.getAttribute('placeholder'); + // Skip the search box + if (!placeholder || !placeholder.toLowerCase().includes('search')) { + visibleInputs.push(i); + } + } + } + + if (visibleInputs.length < 2) { + return { success: false, error: 'Could not find systolic and diastolic input fields. Found ' + visibleInputs.length + ' inputs.' }; + } + + // Fill systolic (first BP input) + const systolicInput = allTextboxes.nth(visibleInputs[0]); + await systolicInput.fill(String(${systolic})); + await systolicInput.press('Tab'); + await page.waitForTimeout(300); + + // Fill diastolic (second BP input) + const diastolicInput = allTextboxes.nth(visibleInputs[1]); + await diastolicInput.fill(String(${diastolic})); + await diastolicInput.press('Tab'); + await page.waitForTimeout(500); + `; +} From e9c50240b57fe8b1863390b8126e6e820c7dbd76 Mon Sep 17 00:00:00 2001 From: NilsLeo Date: Fri, 22 May 2026 16:27:29 +0200 Subject: [PATCH 2/5] Fix prettier formatting in index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/index.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index fec1fd0..75e01b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,11 +123,21 @@ program program .command("biometrics") - .description("Log biometrics to Cronometer (weight, body fat, blood pressure)") + .description( + "Log biometrics to Cronometer (weight, body fat, blood pressure)" + ) .option("-w, --weight ", "Weight value", parseFloat) .option("-b, --body-fat ", "Body fat percentage", parseFloat) - .option("-s, --systolic ", "Systolic blood pressure (mmHg)", parseFloat) - .option("-d, --diastolic ", "Diastolic blood pressure (mmHg)", parseFloat) + .option( + "-s, --systolic ", + "Systolic blood pressure (mmHg)", + parseFloat + ) + .option( + "-d, --diastolic ", + "Diastolic blood pressure (mmHg)", + parseFloat + ) .option("--date ", "Date (YYYY-MM-DD, yesterday, -1d)") .option("-u, --unit ", "Weight unit (kg or lbs)", "kg") .action(async (options) => { From 32e7614e24109be01564c85587201bd1b8982a5b Mon Sep 17 00:00:00 2001 From: NilsLeo Date: Fri, 22 May 2026 16:29:29 +0200 Subject: [PATCH 3/5] Fix eslint error: remove unused daysBack variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/kernel/biometrics.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/kernel/biometrics.ts b/src/kernel/biometrics.ts index 65dc12d..f618c7c 100644 --- a/src/kernel/biometrics.ts +++ b/src/kernel/biometrics.ts @@ -19,15 +19,7 @@ export interface BiometricEntry { * fills in values, and adds to diary. */ export function buildBiometricsCode(entry: BiometricEntry): string { - const { type, value, unit, systolic, diastolic, date } = entry; - - // Calculate days back from today - const targetDateObj = new Date(date + "T00:00:00"); - const today = new Date(); - today.setHours(0, 0, 0, 0); - const daysBack = Math.floor( - (today.getTime() - targetDateObj.getTime()) / (1000 * 60 * 60 * 24) - ); + const { type, value, unit, systolic, diastolic } = entry; const biometricName = type === "weight" From 373b3fd86fb094d4b754abc958304b9a1dbf605b Mon Sep 17 00:00:00 2001 From: NilsLeo Date: Sat, 23 May 2026 15:46:31 +0200 Subject: [PATCH 4/5] Add biometrics and recipes commands to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the new biometrics command for logging weight, body fat, and blood pressure, as well as the recipes command for listing custom recipes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index 9bdaa10..aa7b50b 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,50 @@ crono diary -r 7d --json # → [{"date":"2026-02-11","calories":1847,"protein":168,"carbs":142,"fat":58}, ...] ``` +### `crono biometrics` + +Log biometrics to Cronometer (weight, body fat percentage, blood pressure). Defaults to today if no date is specified. + +```bash +crono biometrics [options] +``` + +**Options:** + +| Flag | Long | Description | +| ---- | --------------------- | ------------------------------------ | +| `-w` | `--weight ` | Weight value | +| `-b` | `--body-fat ` | Body fat percentage | +| `-s` | `--systolic ` | Systolic blood pressure (mmHg) | +| `-d` | `--diastolic ` | Diastolic blood pressure (mmHg) | +| | `--date ` | Date (YYYY-MM-DD, yesterday, -1d) | +| `-u` | `--unit ` | Weight unit (kg or lbs), default: kg | + +At least one biometric value is required. Blood pressure requires both systolic and diastolic values. + +**Examples:** + +```bash +# Log weight in kg (default) +crono biometrics --weight 90 + +# Log weight in lbs +crono biometrics --weight 198 --unit lbs + +# Log body fat percentage +crono biometrics --body-fat 19 + +# Log blood pressure +crono biometrics --systolic 130 --diastolic 76 + +# Log weight for yesterday +crono biometrics --weight 90 --date yesterday + +# Log multiple biometrics (requires multiple commands) +crono biometrics --weight 90 +crono biometrics --body-fat 19 +``` + ### `crono recipes` List your custom recipes from Cronometer. From 255f52bb033c6149a598641bcb435da5ec29f9a7 Mon Sep 17 00:00:00 2001 From: NilsLeo Date: Sat, 23 May 2026 16:59:51 +0200 Subject: [PATCH 5/5] Trigger CI re-run