diff --git a/CLAUDE.md b/CLAUDE.md index 76f1db7..ea47770 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,8 @@ npm run lint # ESLint npm run format # Prettier ``` +**Important:** The globally installed `crono` command runs from `dist/`, not `src/`. After changing source files, always run `npm run build` before testing with `crono`. Use `npm run dev -- ` to skip the build step during development. + ## Adding New Commands 1. Create `src/commands/.ts` diff --git a/README.md b/README.md index d471724..9717e2b 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,69 @@ crono quick-add -p 30 -c 100 -f 20 crono quick-add -p 30 -c 50 -f 15 --meal Dinner ``` +### `crono add custom-food` + +Create a custom food in Cronometer with specified macros. + +```bash +crono add custom-food [options] +``` + +**Options:** + +| Flag | Long | Description | +| ---- | --------------- | ------------------------------------------- | +| `-p` | `--protein ` | Grams of protein | +| `-c` | `--carbs ` | Grams of carbohydrates | +| `-f` | `--fat ` | Grams of fat | +| | `--log [meal]` | Also log to diary (optionally specify meal) | + +At least one macro flag (`-p`, `-c`, or `-f`) is required. + +**Examples:** + +```bash +# Create a custom food with all macros +crono add custom-food "Wendy's Chicken Sandwich" -p 50 -c 100 -f 50 + +# Just protein and carbs +crono add custom-food "Post-Workout Shake" -p 40 -c 60 + +# Create and immediately log to Uncategorized +crono add custom-food "Wendy's Chicken Sandwich" -p 50 -c 100 -f 50 --log + +# Create and immediately log to Dinner +crono add custom-food "Wendy's Chicken Sandwich" -p 50 -c 100 -f 50 --log Dinner +``` + +### `crono log` + +Log a saved food to your diary by name. Works with custom foods, custom recipes, and database items. + +```bash +crono log [options] +``` + +**Options:** + +| Flag | Long | Description | +| ---- | -------------------- | ------------------------------------------------ | +| `-m` | `--meal ` | Meal category (Breakfast, Lunch, Dinner, Snacks) | +| `-s` | `--servings ` | Number of servings (default: 1) | + +**Examples:** + +```bash +# Log a custom food +crono log "Wendy's Chicken Sandwich" + +# Log to a specific meal +crono log "Wendy's Chicken Sandwich" -m Dinner + +# Log multiple servings +crono log "Post-Workout Shake" -s 2 -m Snacks +``` + ### `crono weight` Check your weight from Cronometer. Defaults to today if no date or range is specified. diff --git a/docs/prds/07-commands-add-log.md b/docs/prds/07-commands-add-log.md new file mode 100644 index 0000000..dcdbe83 --- /dev/null +++ b/docs/prds/07-commands-add-log.md @@ -0,0 +1,243 @@ +# PRD: add & log Commands + +## Overview + +Two new commands for working with named food items in Cronometer: + +- **`crono add custom-food`** — Create a custom food in Cronometer with specified macros +- **`crono log`** — Search for a saved food (custom food, recipe, or database item) and log it to the diary + +These complement `quick-add` (anonymous macros) by letting users work with real, named food entries. The primary use case is automation: users who eat the same meals repeatedly can script logging them by name instead of re-entering macros each time. + +## User Stories + +- As a user, I want to create a custom food from the CLI so I don't have to open the Cronometer web app +- As a user, I want to create a custom food and immediately log it in one command +- As a user, I want to log a saved food by name so I can script my daily meal logging +- As a user, I want to log a food to a specific meal category (Breakfast, Lunch, Dinner, Snacks) +- As a user, I want to specify the number of servings when logging a food + +## Command Specifications + +### `crono add custom-food` + +```bash +crono add custom-food [options] +``` + +Creates a new custom food in Cronometer's "Custom Foods" section. + +#### Options + +| Flag | Long Form | Type | Required | Default | Description | +| ---- | ----------- | ------ | ----------- | ------- | ------------------------------------------- | +| `-p` | `--protein` | number | conditional | - | Grams of protein | +| `-c` | `--carbs` | number | conditional | - | Grams of carbohydrates | +| `-f` | `--fat` | number | conditional | - | Grams of fat | +| | `--log` | string | no | - | Also log to diary (optionally specify meal) | + +**Validation:** At least one macro is required. Name is required (positional). + +**Scope:** Only protein, carbs, and fat are supported initially. Cronometer's custom food form supports many additional nutrients (fiber, sodium, cholesterol, vitamins, etc.) — these can be added as flags in the future. + +`--log` accepts an optional meal argument. If `--log` is passed with no argument, the food is logged to Uncategorized. If a meal is specified (e.g. `--log Dinner`), it's logged to that category. Valid meals: Breakfast, Lunch, Dinner, Snacks (case-insensitive). + +#### Examples + +```bash +# Create a custom food with all macros +crono add custom-food "Wendy's Chicken Sandwich" -p 50 -c 100 -f 50 + +# Just protein and carbs +crono add custom-food "Post-Workout Shake" -p 40 -c 60 + +# Create and immediately log to Uncategorized +crono add custom-food "Wendy's Chicken Sandwich" -p 50 -c 100 -f 50 --log + +# Create and immediately log to Dinner +crono add custom-food "Wendy's Chicken Sandwich" -p 50 -c 100 -f 50 --log Dinner +``` + +#### Success Output + +``` +┌ 🍎 crono add +│ +◇ Done. +│ +└ Created custom food: Wendy's Chicken Sandwich (P: 50g | C: 100g | F: 50g) +``` + +With `--log`: + +``` +┌ 🍎 crono add +│ +◇ Done. +│ +└ Created and logged: Wendy's Chicken Sandwich (P: 50g | C: 100g | F: 50g) → Dinner +``` + +### `crono log` + +```bash +crono log [options] +``` + +Searches for a food by name in the diary's "Add Food" dialog and logs it. Works with custom foods, custom recipes, and database items. + +#### Options + +| Flag | Long Form | Type | Required | Default | Description | +| ---- | ------------ | ------ | -------- | ------------- | ------------------ | +| `-m` | `--meal` | string | no | uncategorized | Meal category | +| `-s` | `--servings` | number | no | 1 | Number of servings | + +#### Meal Categories + +Same as `quick-add`: Breakfast, Lunch, Dinner, Snacks (case-insensitive). + +#### Examples + +```bash +# Log a custom food +crono log "Wendy's Chicken Sandwich" + +# Log to a specific meal +crono log "Wendy's Chicken Sandwich" -m Dinner + +# Log multiple servings +crono log "Post-Workout Shake" -s 2 -m Snacks +``` + +#### Success Output + +``` +┌ 🍎 crono log +│ +◇ Done. +│ +└ Logged: Wendy's Chicken Sandwich → Dinner +``` + +## Architecture + +Both commands use Kernel.sh browser automation, same as `quick-add`. + +``` +src/ +├── commands/ +│ ├── add.ts # `crono add` command handler (dispatches to subcommands) +│ └── log.ts # `crono log` command handler +├── kernel/ +│ ├── add-custom-food.ts # Playwright codegen for custom food creation +│ └── log-food.ts # Playwright codegen for food search + diary add +``` + +### Kernel Client Extensions + +Add two new methods to `KernelClient` in `src/kernel/client.ts`: + +```typescript +interface KernelClient { + // existing... + addCustomFood( + entry: CustomFoodEntry, + onStatus?: (msg: string) => void + ): Promise; + logFood(entry: LogFoodEntry, onStatus?: (msg: string) => void): Promise; +} +``` + +## Cronometer UI Flows + +### Custom Food Creation + +URL: `https://cronometer.com/#foods` + +The Custom Foods page (visible in the sidebar under Foods → Custom Foods) has a "CREATE FOOD" button and a list of existing custom foods. + +1. Navigate to `cronometer.com/#foods` (Custom Foods page) +2. Click "+ CREATE FOOD" button +3. Fill in the food name field +4. Enter macro values in the nutrition facts form: + - Find the "Protein" row → enter grams + - Find the "Carbs" / "Total Carbs" row → enter grams + - Find the "Fat" / "Total Fat" row → enter grams +5. Click "Save" / "Save Food" button +6. Verify the food appears in the list + +**Notes:** + +- The create food form is a nutrition label editor — it has fields for many nutrients, but we only fill in the macros the user provides +- Serving size defaults to 1 serving — we use that default +- GWT-compatible input handling is required (same as quick-add: native setter + event dispatch) +- When `--log` is passed, the same browser session continues to the Food Logging flow below (no need to create a second browser) + +### Food Logging (Diary Add) + +URL: `https://cronometer.com/#diary` + +This reuses the same "Add Food to Diary" flow as `quick-add`, but instead of searching for "Quick Add, Protein", it searches for the user-specified food name. + +1. Navigate to `cronometer.com/#diary` +2. Right-click the meal category (e.g. "Dinner") +3. Click "Add Food..." in context menu +4. Type the food name in the search bar +5. Click SEARCH +6. Select the first matching result +7. If servings != 1, update the serving size input +8. Click "ADD TO DIARY" + +**Notes:** + +- The search matches custom foods, custom recipes, and Cronometer's database +- If no result is found, the command should fail with a clear error message +- Serving size handling: for custom foods, 1 serving = the full food as defined. The `-s` flag multiplies this. + +## Registration in `src/index.ts` + +`add` is a command group with subcommands: + +```typescript +const addCmd = program + .command("add") + .description("Add items to Cronometer"); + +addCmd + .command("custom-food ") + .description("Create a custom food with macros") + .option("-p, --protein ", "Grams of protein", parseFloat) + .option("-c, --carbs ", "Grams of carbohydrates", parseFloat) + .option("-f, --fat ", "Grams of fat", parseFloat) + .option("--log [meal]", "Also log to diary (optionally specify meal)") + .action(async (name, options) => { ... }); + +program + .command("log ") + .description("Log a food to your diary by name") + .option("-m, --meal ", "Meal category (Breakfast, Lunch, Dinner, Snacks)") + .option("-s, --servings ", "Number of servings", parseFloat) + .action(async (name, options) => { ... }); +``` + +## Error Handling + +| Error | User Message | +| ------------------------------- | -------------------------------------------------------- | +| No name provided | Commander handles this (required positional arg) | +| No macros provided (add) | "At least one macro (-p, -c, or -f) is required" | +| Invalid meal (log or --log) | "Invalid meal. Use: Breakfast, Lunch, Dinner, or Snacks" | +| Food not found in search (log) | "No food found matching \"\"" | +| Invalid servings (log) | "Servings must be a positive number" | +| Not logged in | "Please log in first. Run: crono login" | +| Custom food name already exists | Let Cronometer handle it (it allows duplicates) | + +## Future Enhancements + +- `crono add custom-recipe` — Create a custom recipe (multi-ingredient) +- `crono add custom-meal` — Create a custom meal +- `crono log --fuzzy` — Fuzzy search with interactive selection when multiple results match +- `crono log --date` — Log to a specific date +- Additional nutrient flags for `add custom-food` (fiber, sodium, cholesterol, sugars, saturated fat, etc.) +- `crono foods list` — List all custom foods from the CLI diff --git a/src/commands/add.ts b/src/commands/add.ts new file mode 100644 index 0000000..f9c5e42 --- /dev/null +++ b/src/commands/add.ts @@ -0,0 +1,87 @@ +import * as p from "@clack/prompts"; +import { getKernelClient } from "../kernel/client.js"; + +export interface AddCustomFoodOptions { + protein?: number; + carbs?: number; + fat?: number; + total?: number; + log?: string | boolean; +} + +const VALID_MEALS = ["breakfast", "lunch", "dinner", "snacks"]; + +export async function addCustomFood( + name: string, + options: AddCustomFoodOptions +): Promise { + // Validate at least one macro is provided + if (!options.protein && !options.carbs && !options.fat) { + p.log.error("At least one macro (-p, -c, or -f) is required"); + process.exit(1); + } + + // Validate --log meal if specified as a string + if (typeof options.log === "string") { + const normalizedMeal = options.log.toLowerCase(); + if (!VALID_MEALS.includes(normalizedMeal)) { + p.log.error( + `Invalid meal "${options.log}". Use: Breakfast, Lunch, Dinner, or Snacks` + ); + process.exit(1); + } + } + + // Calculate total calories + const calories = + options.total ?? + (options.protein ?? 0) * 4 + + (options.carbs ?? 0) * 4 + + (options.fat ?? 0) * 9; + + // Build macro display + const macroParts: string[] = []; + macroParts.push(`${calories} cal`); + if (options.protein) macroParts.push(`P: ${options.protein}g`); + if (options.carbs) macroParts.push(`C: ${options.carbs}g`); + if (options.fat) macroParts.push(`F: ${options.fat}g`); + const macroDisplay = macroParts.join(" | "); + + const logMeal = options.log + ? typeof options.log === "string" + ? options.log.charAt(0).toUpperCase() + options.log.slice(1).toLowerCase() + : "Uncategorized" + : null; + + p.intro("🍎 crono add"); + + const s = p.spinner(); + s.start("Connecting..."); + + try { + const kernel = await getKernelClient(); + await kernel.addCustomFood( + { + name, + protein: options.protein, + carbs: options.carbs, + fat: options.fat, + calories, + log: options.log, + }, + (msg) => s.message(msg) + ); + + s.stop("Done."); + + if (logMeal) { + p.outro(`Created and logged: ${name} (${macroDisplay}) → ${logMeal}`); + } else { + p.outro(`Created custom food: ${name} (${macroDisplay})`); + } + } catch (error) { + s.stop("Failed."); + p.log.error(`Failed to create custom food: ${error}`); + process.exit(1); + } +} diff --git a/src/commands/log.ts b/src/commands/log.ts new file mode 100644 index 0000000..daa9809 --- /dev/null +++ b/src/commands/log.ts @@ -0,0 +1,56 @@ +import * as p from "@clack/prompts"; +import { getKernelClient } from "../kernel/client.js"; + +export interface LogOptions { + meal?: string; + servings?: number; +} + +const VALID_MEALS = ["breakfast", "lunch", "dinner", "snacks"]; + +export async function log(name: string, options: LogOptions): Promise { + // Validate meal if provided + if (options.meal) { + const normalizedMeal = options.meal.toLowerCase(); + if (!VALID_MEALS.includes(normalizedMeal)) { + p.log.error( + `Invalid meal "${options.meal}". Use: Breakfast, Lunch, Dinner, or Snacks` + ); + process.exit(1); + } + } + + // Validate servings if provided + if (options.servings !== undefined && options.servings <= 0) { + p.log.error("Servings must be a positive number"); + process.exit(1); + } + + const mealLabel = options.meal + ? options.meal.charAt(0).toUpperCase() + options.meal.slice(1).toLowerCase() + : "Uncategorized"; + + p.intro("🍎 crono log"); + + const s = p.spinner(); + s.start("Connecting..."); + + try { + const kernel = await getKernelClient(); + await kernel.logFood( + { + name, + meal: options.meal, + servings: options.servings, + }, + (msg) => s.message(msg) + ); + + s.stop("Done."); + p.outro(`Logged: ${name} → ${mealLabel}`); + } catch (error) { + s.stop("Failed."); + p.log.error(`Failed to log food: ${error}`); + process.exit(1); + } +} diff --git a/src/cronometer/auth.ts b/src/cronometer/auth.ts index 6fdddd7..0571794 100644 --- a/src/cronometer/auth.ts +++ b/src/cronometer/auth.ts @@ -9,6 +9,9 @@ const BASE_URL = "https://cronometer.com"; +// GWT permutation and header hashes are public client-side artifacts visible to +// anyone inspecting Cronometer's network requests — they are not secrets. +// These may need updating if Cronometer deploys a new GWT build. const DEFAULT_GWT_PERMUTATION = "7B121DC5483BF272B1BC1916DA9FA963"; const DEFAULT_GWT_HEADER = "2D6A926E3729946302DC68073CB0D550"; diff --git a/src/index.ts b/src/index.ts index 6917b38..7261aa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ import { Command } from "commander"; import { login } from "./commands/login.js"; import { quickAdd } from "./commands/quick-add.js"; +import { addCustomFood } from "./commands/add.js"; +import { log } from "./commands/log.js"; import { diary } from "./commands/diary.js"; import { weight } from "./commands/weight.js"; import { exportCmd } from "./commands/export.js"; @@ -35,6 +37,36 @@ program await quickAdd(options); }); +const addCmd = program.command("add").description("Add items to Cronometer"); + +addCmd + .command("custom-food ") + .description("Create a custom food with macros") + .option("-p, --protein ", "Grams of protein", parseFloat) + .option("-c, --carbs ", "Grams of carbohydrates", parseFloat) + .option("-f, --fat ", "Grams of fat", parseFloat) + .option( + "-t, --total ", + "Total calories (auto-calculated from macros if omitted)", + parseFloat + ) + .option("--log [meal]", "Also log to diary (optionally specify meal)") + .action(async (name, options) => { + await addCustomFood(name, options); + }); + +program + .command("log ") + .description("Log a food to your diary by name") + .option( + "-m, --meal ", + "Meal category (Breakfast, Lunch, Dinner, Snacks)" + ) + .option("-s, --servings ", "Number of servings", parseFloat) + .action(async (name, options) => { + await log(name, options); + }); + program .command("weight") .description("Check your weight from Cronometer") diff --git a/src/kernel/add-custom-food.ts b/src/kernel/add-custom-food.ts new file mode 100644 index 0000000..2a50e67 --- /dev/null +++ b/src/kernel/add-custom-food.ts @@ -0,0 +1,324 @@ +/** + * Playwright code generator for Cronometer custom food creation. + * + * Returns a code string that executes remotely via + * kernel.browsers.playwright.execute(). The code has access to + * `page`, `context`, and `browser` from the Playwright environment. + */ + +import type { CustomFoodEntry } from "./client.js"; + +/** + * Nutrient labels as they appear in Cronometer's nutrition facts editor. + */ +export const NUTRIENT_LABELS: Record = { + calories: "Calories", + protein: "Protein", + carbs: "Total Carbohydrate", + fat: "Total Fat", +}; + +/** + * Generate Playwright code for creating a custom food in Cronometer. + * + * Flow: + * navigate to #foods → expand sidebar → click Custom Foods → + * click CREATE FOOD → fill name → click "-" to reveal nutrient inputs → + * fill macros → Save Changes → optionally log to diary + */ +export function buildAddCustomFoodCode(entry: CustomFoodEntry): string { + const { name, protein, carbs, fat, calories, log } = entry; + + const logMeal = + log === true ? "Uncategorized" : typeof log === "string" ? log : null; + const mealLabel = logMeal + ? logMeal.charAt(0).toUpperCase() + logMeal.slice(1).toLowerCase() + : null; + + // Calculate calories: use explicit value or derive from macros (P*4 + C*4 + F*9) + const totalCalories = + calories ?? (protein ?? 0) * 4 + (carbs ?? 0) * 4 + (fat ?? 0) * 9; + + const nutrients: { label: string; value: number }[] = []; + nutrients.push({ label: NUTRIENT_LABELS.calories, value: totalCalories }); + if (protein !== undefined) + nutrients.push({ label: NUTRIENT_LABELS.protein, value: protein }); + if (carbs !== undefined) + nutrients.push({ label: NUTRIENT_LABELS.carbs, value: carbs }); + if (fat !== undefined) + nutrients.push({ label: NUTRIENT_LABELS.fat, value: fat }); + + const nutrientsJson = JSON.stringify(nutrients); + const foodName = JSON.stringify(name); + const mealLabelJson = JSON.stringify(mealLabel); + + return ` + const foodName = ${foodName}; + const nutrients = ${nutrientsJson}; + const mealLabel = ${mealLabelJson}; + + // Navigate directly to Custom Foods page + await page.goto('https://cronometer.com/#custom-foods', { waitUntil: 'domcontentloaded', timeout: 15000 }); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + + // Verify we're logged in + const url = page.url(); + if (url.includes('/login') || url.includes('/signin')) { + return { success: false, error: 'Not logged in. Login may have failed.' }; + } + + // Helper: find and click an element from a list of selectors + async function clickFirst(selectors, description) { + for (const sel of selectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click(); + return true; + } + } catch {} + } + return false; + } + + await page.waitForTimeout(2000); + + // Click "CREATE FOOD" button + const createClicked = await clickFirst([ + 'button:has-text("CREATE FOOD")', + 'text="CREATE FOOD"', + ], 'CREATE FOOD button'); + if (!createClicked) { + return { success: false, error: 'Could not find "CREATE FOOD" button on Custom Foods page' }; + } + await page.waitForTimeout(3000); + + // Fill in the food name using keyboard.type() for GWT compatibility. + // Find the visible input.text-box via evaluate (many hidden inputs on page). + const nameClicked = await page.evaluate(() => { + const inputs = document.querySelectorAll('input.text-box'); + for (const inp of inputs) { + if (inp.offsetParent !== null) { + inp.focus(); + inp.select(); + return true; + } + } + return false; + }); + if (nameClicked) { + await page.waitForTimeout(200); + await page.keyboard.type(foodName, { delay: 50 }); + } else { + return { success: false, error: 'Could not find food name input field' }; + } + await page.waitForTimeout(500); + + // Enter macro values in the nutrition facts label + // Each nutrient row has a display div showing "-" and a hidden input. + // Clicking "-" reveals the input, then we fill it. + for (const nutrient of nutrients) { + // Step 1: Click the "-" display div to reveal the hidden input + const revealed = await page.evaluate((label) => { + const rows = document.querySelectorAll('tr'); + for (const row of rows) { + const nameDiv = row.querySelector('div'); + if (nameDiv && nameDiv.textContent?.trim() === label && nameDiv.offsetParent !== null) { + const valueDivs = row.querySelectorAll('div'); + for (const div of valueDivs) { + if (div.textContent?.trim() === '-' && div.offsetParent !== null) { + div.click(); + return true; + } + } + } + } + return false; + }, nutrient.label); + + if (!revealed) { + return { success: false, error: 'Could not find nutrient row for "' + nutrient.label + '"' }; + } + await page.waitForTimeout(500); + + // Step 2: Fill the now-visible input in the same row + const filled = await page.evaluate(({ label, value }) => { + const rows = document.querySelectorAll('tr'); + for (const row of rows) { + const nameDiv = row.querySelector('div'); + if (nameDiv && nameDiv.textContent?.trim() === label && nameDiv.offsetParent !== null) { + const inputs = row.querySelectorAll('input'); + for (const inp of inputs) { + if (inp.offsetParent !== null) { + inp.focus(); + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + ).set; + nativeSetter.call(inp, String(value)); + inp.dispatchEvent(new Event('input', { bubbles: true })); + inp.dispatchEvent(new Event('change', { bubbles: true })); + inp.dispatchEvent(new Event('blur', { bubbles: true })); + return true; + } + } + } + } + return false; + }, nutrient); + + if (!filled) { + return { success: false, error: 'Could not fill input for "' + nutrient.label + '"' }; + } + await page.waitForTimeout(300); + } + + // Click "Save Changes" button (appears after edits are made) + await page.waitForTimeout(500); + const saveClicked = await clickFirst([ + 'button:has-text("Save Changes")', + 'button:has-text("Save")', + 'button:has-text("SAVE")', + ], 'Save Changes button'); + if (!saveClicked) { + return { success: false, error: 'Could not find "Save Changes" button' }; + } + await page.waitForTimeout(1000); + + // If --log is set, continue to log the food to diary + if (mealLabel) { + // Navigate to diary + await page.goto('https://cronometer.com/#diary', { waitUntil: 'domcontentloaded', timeout: 15000 }); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(500); + + // Helper: right-click an element from a list of selectors + async function rightClickFirst(selectors, description) { + for (const sel of selectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click({ button: 'right' }); + return true; + } + } catch {} + } + return false; + } + + // Right-click the meal category + const mealClicked = await rightClickFirst([ + 'text="' + mealLabel + '"', + ':has-text("' + mealLabel + '")', + ], 'meal category'); + if (!mealClicked) { + return { success: false, error: 'Food created but could not find meal category "' + mealLabel + '" in diary' }; + } + await page.waitForSelector('text="Add Food..."', { timeout: 3000 }).catch(() => + page.waitForSelector('text="Add Food"', { timeout: 2000 }).catch(() => {}) + ); + + // Click "Add Food..." in context menu + const addFoodClicked = await clickFirst([ + 'text="Add Food..."', + 'text="Add Food\u2026"', + 'text="Add Food"', + '[role="menuitem"]:has-text("Add Food")', + ], 'Add Food menu item'); + if (!addFoodClicked) { + return { success: false, error: 'Food created but could not find "Add Food" in context menu' }; + } + await page.waitForTimeout(200); + + // Wait for "Add Food to Diary" dialog + try { + await page.waitForSelector('text="Add Food to Diary"', { timeout: 5000 }); + } catch { + return { success: false, error: 'Food created but Add Food to Diary dialog did not appear' }; + } + await page.waitForTimeout(300); + + // Search for the just-created food + const searchSelectors = [ + 'input[placeholder*="Search all foods" i]', + 'input[placeholder*="Search" i]', + 'input[placeholder*="food" i]', + 'input.gwt-TextBox', + 'input[type="text"]', + 'input[type="search"]', + ]; + let searched = false; + for (const sel of searchSelectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click(); + await page.waitForTimeout(200); + await el.first().fill(''); + await page.keyboard.type(foodName, { delay: 50 }); + searched = true; + break; + } + } catch {} + } + if (!searched) { + return { success: false, error: 'Food created but could not find search bar in Add Food dialog' }; + } + await page.waitForTimeout(300); + + // Click SEARCH + await clickFirst([ + 'text="SEARCH"', + 'button:has-text("SEARCH")', + 'button:has-text("Search")', + ], 'SEARCH button'); + await page.waitForSelector('td:has-text("' + foodName + '")', { timeout: 8000 }).catch(() => {}); + + // Select the search result + const resultSelectors = [ + 'td:has-text("' + foodName + '")', + 'tr:has-text("' + foodName + '") td', + '.gwt-HTML:has-text("' + foodName + '")', + 'div:has-text("' + foodName + '"):not(:has(input))', + ]; + let resultClicked = false; + for (const sel of resultSelectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click(); + resultClicked = true; + break; + } + } catch {} + } + if (!resultClicked) { + return { success: false, error: 'Food created but no search result found for "' + foodName + '"' }; + } + await page.waitForTimeout(200); + + // Wait for serving size panel + try { + await page.waitForSelector('text="Serving Size"', { timeout: 5000 }); + } catch { + return { success: false, error: 'Food created but Serving Size panel did not appear' }; + } + await page.waitForTimeout(500); + + // Click "ADD TO DIARY" + const addClicked = await clickFirst([ + 'button:has-text("ADD TO DIARY")', + 'button:has-text("Add to Diary")', + 'text="ADD TO DIARY"', + 'text="Add to Diary"', + 'button[type="submit"]', + ], 'ADD TO DIARY button'); + if (!addClicked) { + return { success: false, error: 'Food created but could not find "Add to Diary" button' }; + } + await page.waitForSelector('text="Add Food to Diary"', { state: 'hidden', timeout: 8000 }).catch(() => {}); + await page.waitForTimeout(300); + } + + return { success: true }; + `; +} diff --git a/src/kernel/client.ts b/src/kernel/client.ts index fbcbb78..329336d 100644 --- a/src/kernel/client.ts +++ b/src/kernel/client.ts @@ -17,6 +17,8 @@ import { buildNavigateToLoginCode, } from "./login.js"; import { buildQuickAddCode } from "./quick-add.js"; +import { buildAddCustomFoodCode } from "./add-custom-food.js"; +import { buildLogFoodCode } from "./log-food.js"; import { buildDiaryCode } from "./diary.js"; import { buildWeightCode } from "./weight.js"; @@ -41,6 +43,21 @@ export interface DiaryData { fat: number; } +export interface CustomFoodEntry { + name: string; + protein?: number; + carbs?: number; + fat?: number; + calories?: number; + log?: string | boolean; +} + +export interface LogFoodEntry { + name: string; + meal?: string; + servings?: number; +} + export interface KernelClient { addQuickEntry( entry: MacroEntry, @@ -54,6 +71,11 @@ export interface KernelClient { dates: string[], onStatus?: (msg: string) => void ): Promise; + addCustomFood( + entry: CustomFoodEntry, + onStatus?: (msg: string) => void + ): Promise; + logFood(entry: LogFoodEntry, onStatus?: (msg: string) => void): Promise; } /** @@ -83,6 +105,10 @@ export async function getKernelClient(): Promise { getWeight(kernel, dates, onStatus), getDiary: (dates: string[], onStatus?: (msg: string) => void) => getDiary(kernel, dates, onStatus), + addCustomFood: (entry: CustomFoodEntry, onStatus?: (msg: string) => void) => + addCustomFood(kernel, entry, onStatus), + logFood: (entry: LogFoodEntry, onStatus?: (msg: string) => void) => + logFood(kernel, entry, onStatus), }; } @@ -247,6 +273,106 @@ async function getDiary( } } +/** + * Execute the custom food creation automation on Cronometer. + * Creates a browser, logs in, creates the food, optionally logs it, then tears down. + */ +async function addCustomFood( + kernel: Kernel, + entry: CustomFoodEntry, + onStatus?: (msg: string) => void +): Promise { + const username = getCredential("cronometer-username"); + const password = getCredential("cronometer-password"); + const hasAutoCreds = !!(username && password); + + const browser = await kernel.browsers.create({ + headless: hasAutoCreds, + stealth: true, + timeout_seconds: hasAutoCreds ? 120 : 300, + }); + + try { + if (hasAutoCreds) { + await autoLogin(kernel, browser.session_id, username, password, onStatus); + } else { + await manualLogin(kernel, browser); + } + + onStatus?.("Creating custom food..."); + const result = await kernel.browsers.playwright.execute( + browser.session_id, + { code: buildAddCustomFoodCode(entry), timeout_sec: 120 } + ); + + if (!result.success) { + throw new Error(`Automation failed: ${result.error ?? "Unknown error"}`); + } + + const data = result.result as { success: boolean; error?: string }; + if (!data.success) { + throw new Error( + `Custom food creation failed: ${data.error ?? "Unknown error"}` + ); + } + } finally { + try { + await kernel.browsers.deleteByID(browser.session_id); + } catch { + // Browser may already be cleaned up by Kernel + } + } +} + +/** + * Execute the food logging automation on Cronometer. + * Creates a browser, logs in, searches for the food, logs it, then tears down. + */ +async function logFood( + kernel: Kernel, + entry: LogFoodEntry, + onStatus?: (msg: string) => void +): Promise { + const username = getCredential("cronometer-username"); + const password = getCredential("cronometer-password"); + const hasAutoCreds = !!(username && password); + + const browser = await kernel.browsers.create({ + headless: hasAutoCreds, + stealth: true, + timeout_seconds: hasAutoCreds ? 120 : 300, + }); + + try { + if (hasAutoCreds) { + await autoLogin(kernel, browser.session_id, username, password, onStatus); + } else { + await manualLogin(kernel, browser); + } + + onStatus?.("Logging food to diary..."); + const result = await kernel.browsers.playwright.execute( + browser.session_id, + { code: buildLogFoodCode(entry), timeout_sec: 60 } + ); + + if (!result.success) { + throw new Error(`Automation failed: ${result.error ?? "Unknown error"}`); + } + + const data = result.result as { success: boolean; error?: string }; + if (!data.success) { + throw new Error(`Food logging failed: ${data.error ?? "Unknown error"}`); + } + } finally { + try { + await kernel.browsers.deleteByID(browser.session_id); + } catch { + // Browser may already be cleaned up by Kernel + } + } +} + /** * Auto-login using stored Cronometer credentials. */ @@ -269,11 +395,26 @@ async function autoLogin( loggedIn: boolean; url: string; error?: string; + loginError?: string | null; }; if (!result.success || !data.loggedIn) { + const pageError = data.loginError?.toLowerCase() ?? ""; + const isRateLimited = + pageError.includes("too many") || + pageError.includes("rate limit") || + pageError.includes("try again later") || + pageError.includes("temporarily"); + + if (isRateLimited) { + throw new Error( + `Cronometer is rate-limiting login attempts. ${data.loginError}\n` + + "Please wait a few minutes and try again." + ); + } + throw new Error( - `Auto-login failed: ${data.error ?? "Login verification failed"}.\n` + + `Auto-login failed: ${data.error ?? data.loginError ?? "Login verification failed"}.\n` + "Your credentials may be incorrect. Run `crono login` to update them." ); } diff --git a/src/kernel/log-food.ts b/src/kernel/log-food.ts new file mode 100644 index 0000000..34ad4ee --- /dev/null +++ b/src/kernel/log-food.ts @@ -0,0 +1,235 @@ +/** + * Playwright code generator for Cronometer food logging. + * + * Returns a code string that executes remotely via + * kernel.browsers.playwright.execute(). The code has access to + * `page`, `context`, and `browser` from the Playwright environment. + */ + +import type { LogFoodEntry } from "./client.js"; + +/** + * Generate Playwright code for logging a food to the Cronometer diary. + * + * Flow: + * navigate to #diary → right-click meal → "Add Food" → search food name → + * select result → set servings → "Add to Diary" + */ +export function buildLogFoodCode(entry: LogFoodEntry): string { + const { name, meal, servings } = entry; + + const mealLabel = meal + ? meal.charAt(0).toUpperCase() + meal.slice(1).toLowerCase() + : "Uncategorized"; + + const foodName = JSON.stringify(name); + const servingCount = servings ?? 1; + + return ` + const foodName = ${foodName}; + const mealLabel = ${JSON.stringify(mealLabel)}; + const servingCount = ${servingCount}; + + // Navigate to diary + await page.goto('https://cronometer.com/#diary', { waitUntil: 'domcontentloaded', timeout: 15000 }); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + + // Verify we're logged in + const url = page.url(); + if (url.includes('/login') || url.includes('/signin')) { + return { success: false, error: 'Not logged in. Login may have failed.' }; + } + + // Helper: find and click an element from a list of selectors + async function clickFirst(selectors, description) { + for (const sel of selectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click(); + return true; + } + } catch {} + } + return false; + } + + // Helper: right-click an element from a list of selectors + async function rightClickFirst(selectors, description) { + for (const sel of selectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click({ button: 'right' }); + return true; + } + } catch {} + } + return false; + } + + // Right-click the meal category + const clicked = await rightClickFirst([ + 'text="' + mealLabel + '"', + ':has-text("' + mealLabel + '")', + ], 'meal category'); + if (!clicked) { + return { success: false, error: 'Could not find meal category "' + mealLabel + '" in diary' }; + } + await page.waitForSelector('text="Add Food..."', { timeout: 3000 }).catch(() => + page.waitForSelector('text="Add Food"', { timeout: 2000 }).catch(() => {}) + ); + + // Click "Add Food..." in context menu + const addFoodClicked = await clickFirst([ + 'text="Add Food..."', + 'text="Add Food\u2026"', + 'text="Add Food"', + '[role="menuitem"]:has-text("Add Food")', + ], 'Add Food menu item'); + if (!addFoodClicked) { + return { success: false, error: 'Could not find "Add Food" in context menu' }; + } + await page.waitForTimeout(200); + + // Wait for "Add Food to Diary" dialog + try { + await page.waitForSelector('text="Add Food to Diary"', { timeout: 5000 }); + } catch { + return { success: false, error: 'Add Food to Diary dialog did not appear' }; + } + await page.waitForTimeout(300); + + // Search for the food + const searchSelectors = [ + 'input[placeholder*="Search all foods" i]', + 'input[placeholder*="Search" i]', + 'input[placeholder*="food" i]', + 'input.gwt-TextBox', + 'input[type="text"]', + 'input[type="search"]', + ]; + let searched = false; + for (const sel of searchSelectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click(); + await page.waitForTimeout(200); + await el.first().fill(''); + await page.keyboard.type(foodName, { delay: 50 }); + searched = true; + break; + } + } catch {} + } + if (!searched) { + return { success: false, error: 'Could not find food search bar in Add Food dialog' }; + } + await page.waitForTimeout(300); + + // Click SEARCH + await clickFirst([ + 'text="SEARCH"', + 'button:has-text("SEARCH")', + 'button:has-text("Search")', + ], 'SEARCH button'); + + // Wait for results + try { + await page.waitForSelector('td:has-text("' + foodName + '")', { timeout: 8000 }); + } catch { + return { success: false, error: 'No food found matching "' + foodName + '"' }; + } + + // Select the search result + const resultSelectors = [ + 'td:has-text("' + foodName + '")', + 'tr:has-text("' + foodName + '") td', + '.gwt-HTML:has-text("' + foodName + '")', + 'div:has-text("' + foodName + '"):not(:has(input))', + ]; + let resultClicked = false; + for (const sel of resultSelectors) { + try { + const el = page.locator(sel); + if (await el.count() > 0) { + await el.first().click(); + resultClicked = true; + break; + } + } catch {} + } + if (!resultClicked) { + return { success: false, error: 'No food found matching "' + foodName + '"' }; + } + await page.waitForTimeout(200); + + // Wait for the detail panel with serving size + try { + await page.waitForSelector('text="Serving Size"', { timeout: 5000 }); + } catch { + return { success: false, error: 'Serving Size panel did not appear for "' + foodName + '"' }; + } + await page.waitForTimeout(500); + + // If servings != 1, update the serving size input + if (servingCount !== 1) { + let servingFilled = false; + try { + servingFilled = await page.evaluate((count) => { + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { acceptNode: (node) => + node.textContent && node.textContent.trim() === 'Serving Size' + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT + } + ); + const textNode = walker.nextNode(); + if (!textNode) return false; + + let container = textNode.parentElement; + for (let i = 0; i < 5 && container; i++) { + const input = container.querySelector('input'); + if (input) { + input.focus(); + input.select(); + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + ).set; + nativeSetter.call(input, String(count)); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + return true; + } + container = container.parentElement; + } + return false; + }, servingCount); + } catch {} + + if (!servingFilled) { + return { success: false, error: 'Could not update serving size for "' + foodName + '"' }; + } + await page.waitForTimeout(500); + } + + // Click "ADD TO DIARY" + const addClicked = await clickFirst([ + 'button:has-text("ADD TO DIARY")', + 'button:has-text("Add to Diary")', + 'text="ADD TO DIARY"', + 'text="Add to Diary"', + 'button[type="submit"]', + ], 'ADD TO DIARY button'); + if (!addClicked) { + return { success: false, error: 'Could not find "Add to Diary" button' }; + } + await page.waitForSelector('text="Add Food to Diary"', { state: 'hidden', timeout: 8000 }).catch(() => {}); + await page.waitForTimeout(300); + + return { success: true }; + `; +} diff --git a/src/kernel/login.ts b/src/kernel/login.ts index f6b1c0f..93f7b45 100644 --- a/src/kernel/login.ts +++ b/src/kernel/login.ts @@ -123,6 +123,29 @@ export function buildAutoLoginCode(username: string, password: string): string { const url = page.url(); const loggedIn = !url.includes('/login') && !url.includes('/signin'); - return { success: true, loggedIn, url }; + + // If still on login page, check for error messages (rate limit, wrong creds, etc.) + let loginError = null; + if (!loggedIn) { + loginError = await page.evaluate(() => { + const selectors = [ + '.error-message', '.alert', '.notification', + '[class*="error"]', '[class*="alert"]', + '.gwt-HTML', + ]; + for (const sel of selectors) { + const els = document.querySelectorAll(sel); + for (const el of els) { + const text = el.textContent?.trim(); + if (text && text.length > 5 && text.length < 300 && el.offsetParent !== null) { + return text; + } + } + } + return null; + }); + } + + return { success: true, loggedIn, url, loginError }; `; } diff --git a/tests/add.test.ts b/tests/add.test.ts new file mode 100644 index 0000000..ea91464 --- /dev/null +++ b/tests/add.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; + +describe("add custom-food", () => { + it("should require at least one macro", () => { + // Validation: at least one of -p, -c, -f must be provided + const hasAnyMacro = (options: { + protein?: number; + carbs?: number; + fat?: number; + }) => !!(options.protein || options.carbs || options.fat); + + expect(hasAnyMacro({})).toBe(false); + expect(hasAnyMacro({ protein: 30 })).toBe(true); + expect(hasAnyMacro({ carbs: 50 })).toBe(true); + expect(hasAnyMacro({ fat: 20 })).toBe(true); + expect(hasAnyMacro({ protein: 30, carbs: 50, fat: 20 })).toBe(true); + }); + + it("should validate meal names for --log", () => { + const validMeals = ["breakfast", "lunch", "dinner", "snacks"]; + const isValidMeal = (meal: string) => + validMeals.includes(meal.toLowerCase()); + + expect(isValidMeal("Breakfast")).toBe(true); + expect(isValidMeal("Dinner")).toBe(true); + expect(isValidMeal("snacks")).toBe(true); + expect(isValidMeal("brunch")).toBe(false); + expect(isValidMeal("Midnight Snack")).toBe(false); + }); + + it("should format macro display correctly", () => { + const formatDisplay = (options: { + protein?: number; + carbs?: number; + fat?: number; + total?: number; + }) => { + const calories = + options.total ?? + (options.protein ?? 0) * 4 + + (options.carbs ?? 0) * 4 + + (options.fat ?? 0) * 9; + const parts: string[] = []; + parts.push(`${calories} cal`); + if (options.protein) parts.push(`P: ${options.protein}g`); + if (options.carbs) parts.push(`C: ${options.carbs}g`); + if (options.fat) parts.push(`F: ${options.fat}g`); + return parts.join(" | "); + }; + + expect(formatDisplay({ protein: 50, carbs: 100, fat: 50 })).toBe( + "1050 cal | P: 50g | C: 100g | F: 50g" + ); + expect(formatDisplay({ protein: 30 })).toBe("120 cal | P: 30g"); + expect(formatDisplay({ protein: 40, carbs: 60 })).toBe( + "400 cal | P: 40g | C: 60g" + ); + }); + + it("should use explicit total calories when provided", () => { + const calcCalories = (options: { + protein?: number; + carbs?: number; + fat?: number; + total?: number; + }) => + options.total ?? + (options.protein ?? 0) * 4 + + (options.carbs ?? 0) * 4 + + (options.fat ?? 0) * 9; + + expect(calcCalories({ protein: 30, total: 500 })).toBe(500); + expect(calcCalories({ protein: 30, carbs: 50, fat: 10 })).toBe(410); + expect(calcCalories({ protein: 30 })).toBe(120); + }); + + it("should handle --log with no meal as Uncategorized", () => { + const getMealLabel = (log: string | boolean | undefined) => { + if (!log) return null; + return typeof log === "string" + ? log.charAt(0).toUpperCase() + log.slice(1).toLowerCase() + : "Uncategorized"; + }; + + expect(getMealLabel(true)).toBe("Uncategorized"); + expect(getMealLabel("dinner")).toBe("Dinner"); + expect(getMealLabel("BREAKFAST")).toBe("Breakfast"); + expect(getMealLabel(undefined)).toBe(null); + }); +}); diff --git a/tests/kernel/add-custom-food.test.ts b/tests/kernel/add-custom-food.test.ts new file mode 100644 index 0000000..925c4f5 --- /dev/null +++ b/tests/kernel/add-custom-food.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { + buildAddCustomFoodCode, + NUTRIENT_LABELS, +} from "../../src/kernel/add-custom-food.js"; + +describe("buildAddCustomFoodCode", () => { + it("should navigate to the custom foods page", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain("cronometer.com/#custom-foods"); + }); + + it("should click CREATE FOOD button", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain("CREATE FOOD"); + }); + + it("should include the food name", () => { + const code = buildAddCustomFoodCode({ + name: "Wendy's Chicken Sandwich", + protein: 50, + }); + expect(code).toContain("Wendy's Chicken Sandwich"); + }); + + it("should include protein value when provided", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain('"Protein"'); + expect(code).toContain("30"); + }); + + it("should include carbs value when provided", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", carbs: 100 }); + expect(code).toContain('"Total Carbohydrate"'); + expect(code).toContain("100"); + }); + + it("should include fat value when provided", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", fat: 20 }); + expect(code).toContain('"Total Fat"'); + expect(code).toContain("20"); + }); + + it("should include all macros when all provided", () => { + const code = buildAddCustomFoodCode({ + name: "Test Food", + protein: 30, + carbs: 100, + fat: 20, + }); + expect(code).toContain('"Protein"'); + expect(code).toContain('"Total Carbohydrate"'); + expect(code).toContain('"Total Fat"'); + }); + + it("should only include provided macros", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain('"Protein"'); + expect(code).not.toContain('"Total Carbohydrate"'); + expect(code).not.toContain('"Total Fat"'); + }); + + it("should click Save Changes button", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain("Save Changes"); + }); + + it("should set mealLabel to null when log is not set", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain("const mealLabel = null"); + }); + + it("should include diary logging when log is true", () => { + const code = buildAddCustomFoodCode({ + name: "Test Food", + protein: 30, + log: true, + }); + expect(code).toContain("cronometer.com/#diary"); + expect(code).toContain("ADD TO DIARY"); + expect(code).toContain("Uncategorized"); + }); + + it("should include diary logging with meal when log is a meal name", () => { + const code = buildAddCustomFoodCode({ + name: "Test Food", + protein: 30, + log: "Dinner", + }); + expect(code).toContain("cronometer.com/#diary"); + expect(code).toContain("ADD TO DIARY"); + expect(code).toContain("Dinner"); + }); + + it("should normalize meal name in log to title case", () => { + const code = buildAddCustomFoodCode({ + name: "Test Food", + protein: 30, + log: "breakfast", + }); + expect(code).toContain("Breakfast"); + }); + + it("should check for login redirect", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain("Not logged in"); + }); + + it("should return success result", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + expect(code).toContain("return { success: true }"); + }); + + it("should auto-calculate calories from macros", () => { + const code = buildAddCustomFoodCode({ + name: "Test Food", + protein: 30, + carbs: 50, + fat: 10, + }); + // 30*4 + 50*4 + 10*9 = 120 + 200 + 90 = 410 + expect(code).toContain('"Calories"'); + expect(code).toContain("410"); + }); + + it("should use explicit calories when provided", () => { + const code = buildAddCustomFoodCode({ + name: "Test Food", + protein: 30, + calories: 500, + }); + expect(code).toContain('"Calories"'); + expect(code).toContain("500"); + }); + + it("should always include calories even with only one macro", () => { + const code = buildAddCustomFoodCode({ name: "Test Food", protein: 30 }); + // 30*4 = 120 + expect(code).toContain('"Calories"'); + expect(code).toContain("120"); + }); +}); diff --git a/tests/kernel/log-food.test.ts b/tests/kernel/log-food.test.ts new file mode 100644 index 0000000..8651156 --- /dev/null +++ b/tests/kernel/log-food.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { buildLogFoodCode } from "../../src/kernel/log-food.js"; + +describe("buildLogFoodCode", () => { + it("should navigate to the diary page", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("cronometer.com/#diary"); + }); + + it("should include the food name in search", () => { + const code = buildLogFoodCode({ name: "Wendy's Chicken Sandwich" }); + expect(code).toContain("Wendy's Chicken Sandwich"); + }); + + it("should right-click meal category", () => { + const code = buildLogFoodCode({ name: "Test Food", meal: "dinner" }); + expect(code).toContain("button: 'right'"); + expect(code).toContain("Dinner"); + }); + + it("should default to Uncategorized when no meal provided", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("Uncategorized"); + }); + + it("should normalize meal name to title case", () => { + const code = buildLogFoodCode({ name: "Test Food", meal: "BREAKFAST" }); + expect(code).toContain("Breakfast"); + }); + + it("should handle specified meals", () => { + const code = buildLogFoodCode({ name: "Test Food", meal: "snacks" }); + expect(code).toContain("Snacks"); + }); + + it("should click Add Food from context menu", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("Add Food"); + expect(code).toContain("addFoodClicked"); + }); + + it("should search in the food search bar", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("SEARCH"); + expect(code).toContain("foodName"); + }); + + it("should click Add to Diary", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("ADD TO DIARY"); + }); + + it("should default to 1 serving", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("servingCount = 1"); + }); + + it("should handle custom servings", () => { + const code = buildLogFoodCode({ name: "Test Food", servings: 2 }); + expect(code).toContain("servingCount = 2"); + expect(code).toContain("servingCount !== 1"); + }); + + it("should check for login redirect", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("Not logged in"); + }); + + it("should return success result", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("return { success: true }"); + }); + + it("should fail with clear error when no results found", () => { + const code = buildLogFoodCode({ name: "Test Food" }); + expect(code).toContain("No food found matching"); + }); +}); diff --git a/tests/log.test.ts b/tests/log.test.ts new file mode 100644 index 0000000..f2159f5 --- /dev/null +++ b/tests/log.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; + +describe("log", () => { + it("should validate meal names", () => { + const validMeals = ["breakfast", "lunch", "dinner", "snacks"]; + const isValidMeal = (meal: string) => + validMeals.includes(meal.toLowerCase()); + + expect(isValidMeal("Breakfast")).toBe(true); + expect(isValidMeal("Lunch")).toBe(true); + expect(isValidMeal("dinner")).toBe(true); + expect(isValidMeal("SNACKS")).toBe(true); + expect(isValidMeal("brunch")).toBe(false); + expect(isValidMeal("Second Breakfast")).toBe(false); + }); + + it("should require servings to be positive", () => { + const isValidServings = (servings: number) => servings > 0; + + expect(isValidServings(1)).toBe(true); + expect(isValidServings(2)).toBe(true); + expect(isValidServings(0.5)).toBe(true); + expect(isValidServings(0)).toBe(false); + expect(isValidServings(-1)).toBe(false); + }); + + it("should normalize meal names to title case", () => { + const normalize = (meal: string) => + meal.charAt(0).toUpperCase() + meal.slice(1).toLowerCase(); + + expect(normalize("dinner")).toBe("Dinner"); + expect(normalize("BREAKFAST")).toBe("Breakfast"); + expect(normalize("LuNcH")).toBe("Lunch"); + expect(normalize("snacks")).toBe("Snacks"); + }); + + it("should default to Uncategorized when no meal provided", () => { + const getMealLabel = (meal?: string) => + meal + ? meal.charAt(0).toUpperCase() + meal.slice(1).toLowerCase() + : "Uncategorized"; + + expect(getMealLabel()).toBe("Uncategorized"); + expect(getMealLabel(undefined)).toBe("Uncategorized"); + expect(getMealLabel("dinner")).toBe("Dinner"); + }); +});