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
33 changes: 20 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ Each command creates a fresh browser, logs in, performs the action, and tears do
- [x] @clack/prompts CLI UX (spinners, styled output, cancel handling)
- [x] `weight` command (date/range support, JSON output)
- [x] `diary` command (daily nutrition totals, date/range support, JSON output)
- [x] Date utilities (`src/utils/date.ts`) shared across read commands
- [x] Date utilities (`src/utils/date.ts`) shared across commands (includes `resolveDate` for quick-add)
- [x] `quick-add --date` flag (retroactive logging via prev-day arrow navigation)
- [x] `quick-add --alcohol` flag (alcohol grams as separate macro)
- [x] npm publishing (`@milldr/crono`) with trusted publishing via OIDC
- [x] Release Drafter + automated publish workflow
- [x] Branch protection (PRs required against main)
Expand All @@ -79,32 +81,37 @@ Each command creates a fresh browser, logs in, performs the action, and tears do
### Quick Add Command

```bash
crono quick-add -p 30 -c 100 -f 20 -m Dinner
crono quick-add -p 30 -c 100 -f 20 -a 14 -m Dinner -d yesterday
```

Flags:

- `-p, --protein <g>` — Grams of protein
- `-c, --carbs <g>` — Grams of carbs
- `-f, --fat <g>` — Grams of fat
- `-a, --alcohol <g>` — Grams of alcohol
- `-m, --meal <name>` — Breakfast, Lunch, Dinner, Snacks (default: Uncategorized)
- `-d, --date <date>` — Target date (YYYY-MM-DD, yesterday, -Nd; default: today)

Validation: At least one macro required.
Validation: At least one macro required (-p, -c, -f, or -a).

### Cronometer UI Flow (automated)

Each macro (protein, carbs, fat) is added as a separate "Quick Add" food item:
Each macro (protein, carbs, fat, alcohol) is added as a separate "Quick Add" food item:

1. Navigate to `cronometer.com/#diary`
2. Right-click the meal category (e.g. "Dinner")
3. Click "Add Food..." in context menu
4. Search for the macro (e.g. "Quick Add, Protein")
5. Click SEARCH, select the result
6. Enter serving size in grams
7. Click "ADD TO DIARY"
8. Repeat for each macro

Uses event-driven Playwright waits (networkidle, waitForSelector) instead of fixed timeouts where possible. GWT-compatible input handling via native setter + event dispatch.
2. If `--date` is set, click prev-day arrow to reach the target date (2s wait per click for GWT re-render)
3. Right-click the meal category (e.g. "Dinner")
4. Click "Add Food..." in context menu
5. Search for the macro (e.g. "Quick Add, Protein")
6. Click SEARCH, select the result
7. Enter serving size in grams
8. Click "ADD TO DIARY"
9. Repeat for each macro

**Date navigation note:** Cronometer's GWT hash routing does not support `?date=` query params. Date changes use prev-day arrow clicks (same approach as `diary` and `weight` commands), capped at 90 days back.

Uses event-driven Playwright waits (networkidle, waitForSelector) with a 2s stabilization wait after initial navigation for GWT rendering. GWT-compatible input handling via native setter + event dispatch.

### Config Location

Expand Down
99 changes: 99 additions & 0 deletions docs/prds/08-quick-add-date-alcohol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# PRD: quick-add Date & Alcohol Flags

## Overview

Two new flags for the `quick-add` command: `-d/--date` for retroactive diary logging and `-a/--alcohol` for tracking alcohol grams as a separate macro.

Implements: [#13](https://github.com/milldr/crono/issues/13), [#14](https://github.com/milldr/crono/issues/14)

## User Stories

- As a user, I want to log macros to a past date so I can backfill entries I forgot to track
- As a user, I want to track alcohol grams separately since alcohol has its own caloric density (7 cal/g)

## Command Specification

### New Options

| Flag | Long Form | Type | Required | Default | Description |
| ---- | ----------- | ------ | -------- | ------- | ------------------------- |
| `-d` | `--date` | string | no | today | Target date for the entry |
| `-a` | `--alcohol` | number | no | - | Grams of alcohol |

### Date Formats

The `--date` flag accepts three formats:

| Format | Example | Description |
| ------------ | ------------ | --------------------- |
| `yesterday` | `yesterday` | Shorthand for -1d |
| `-Nd` | `-1d`, `-7d` | N days ago from today |
| `YYYY-MM-DD` | `2026-02-10` | Absolute ISO date |

Invalid dates produce an error message and exit.

### Updated Validation

At least one of `-p`, `-c`, `-f`, or `-a` must be provided (alcohol now counts as a valid macro).

## Examples

```bash
# Log to yesterday
crono quick-add -p 30 -d yesterday -m Dinner

# Log to 3 days ago
crono quick-add -p 25 -c 50 -d -3d -m Lunch

# Log to a specific date
crono quick-add -f 15 -d 2026-02-10

# Log alcohol
crono quick-add -a 14 -m Dinner

# Combine all flags
crono quick-add -p 30 -c 50 -f 10 -a 14 -d yesterday -m Dinner
```

## Implementation Notes

### Date Navigation

Cronometer's GWT hash routing does not support `?date=` query parameters. Date navigation uses the prev-day arrow approach (same as `diary` and `weight` commands):

1. Navigate to `cronometer.com/#diary`
2. Wait 2000ms for GWT to fully render
3. Click `i.diary-date-previous` arrow N times (with 2000ms stabilization between clicks)

This is capped at 90 days back to prevent runaway loops.

### Alcohol Macro

Alcohol uses the same "Quick Add" mechanism as protein/carbs/fat:

- Search term: `"Quick Add, Alcohol"` (a built-in Cronometer food item)
- Serving size entered in grams, same as other macros

### Date Resolution

The `resolveDate()` utility in `src/utils/date.ts` handles all three input formats, reusing the existing `parseDate()` and `formatDate()` functions for validation and formatting.

## Success Output

```
✓ Added: 30g protein, 14g alcohol → Dinner on 2026-02-15
```

The date suffix only appears when `--date` is provided.

## Files Changed

| File | Changes |
| -------------------------------- | ------------------------------------------------------------ |
| `src/utils/date.ts` | Added `resolveDate()` function |
| `src/kernel/client.ts` | Added `alcohol` and `date` to `MacroEntry` interface |
| `src/kernel/quick-add.ts` | Arrow-based date navigation; alcohol in `MACRO_SEARCH_NAMES` |
| `src/commands/quick-add.ts` | New options, validation, date resolution, passthrough |
| `src/index.ts` | Registered `-d` and `-a` CLI options |
| `tests/utils/date.test.ts` | `resolveDate()` tests |
| `tests/kernel/quick-add.test.ts` | Date navigation and alcohol macro tests |
19 changes: 19 additions & 0 deletions docs/prds/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# PRDs (Product Requirement Documents)

This folder contains the product requirement documents for crono. Each PRD is a historical record of a feature or decision at the time it was planned and built.

**PRDs are immutable.** Once a feature ships, its PRD is not updated. New features that extend or modify existing behavior get their own PRD. This preserves the development journey and makes it easy to understand why things were built the way they were.

## Index

| PRD | Description |
| ----------------------------------------------------------- | ------------------------------------------ |
| [00-overview](./00-overview.md) | Project goals, tech stack, architecture |
| [01-command-login](./01-command-login.md) | `crono login` — credential setup |
| [02-command-quick-add](./02-command-quick-add.md) | `crono quick-add` — log raw macros |
| [03-command-weight](./03-command-weight.md) | `crono weight` — read weight data |
| [04-command-diary](./04-command-diary.md) | `crono diary` — daily nutrition totals |
| [05-guideline-clack](./05-guideline-clack.md) | @clack/prompts UX guideline |
| [06-command-export](./06-command-export.md) | `crono export` — data export |
| [07-commands-add-log](./07-commands-add-log.md) | `crono add` and `crono log` — custom foods |
| [08-quick-add-date-alcohol](./08-quick-add-date-alcohol.md) | `quick-add` date and alcohol flags |
24 changes: 21 additions & 3 deletions src/commands/quick-add.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import * as p from "@clack/prompts";
import { getKernelClient } from "../kernel/client.js";
import { resolveDate } from "../utils/date.js";

export interface QuickAddOptions {
protein?: number;
carbs?: number;
fat?: number;
alcohol?: number;
meal?: string;
date?: string;
}

const VALID_MEALS = ["breakfast", "lunch", "dinner", "snacks"];

export async function quickAdd(options: QuickAddOptions): Promise<void> {
// 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");
if (!options.protein && !options.carbs && !options.fat && !options.alcohol) {
p.log.error("At least one macro (-p, -c, -f, or -a) is required");
process.exit(1);
}

Expand All @@ -28,11 +31,23 @@ export async function quickAdd(options: QuickAddOptions): Promise<void> {
}
}

// Resolve date if provided
let resolvedDate: string | undefined;
if (options.date) {
try {
resolvedDate = resolveDate(options.date);
} catch (err) {
p.log.error(`${err instanceof Error ? err.message : err}`);
process.exit(1);
}
}

// Build entry description
const parts: string[] = [];
if (options.protein) parts.push(`${options.protein}g protein`);
if (options.carbs) parts.push(`${options.carbs}g carbs`);
if (options.fat) parts.push(`${options.fat}g fat`);
if (options.alcohol) parts.push(`${options.alcohol}g alcohol`);

const mealLabel = options.meal
? options.meal.charAt(0).toUpperCase() + options.meal.slice(1).toLowerCase()
Expand All @@ -50,13 +65,16 @@ export async function quickAdd(options: QuickAddOptions): Promise<void> {
protein: options.protein,
carbs: options.carbs,
fat: options.fat,
alcohol: options.alcohol,
meal: options.meal,
date: resolvedDate,
},
(msg) => s.message(msg)
);

s.stop("Done.");
p.outro(`Added: ${parts.join(", ")} → ${mealLabel}`);
const dateInfo = resolvedDate ? ` on ${resolvedDate}` : "";
p.outro(`Added: ${parts.join(", ")} → ${mealLabel}${dateInfo}`);
} catch (error) {
s.stop("Failed.");
p.log.error(`Failed to add entry: ${error}`);
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ program
.option("-p, --protein <grams>", "Grams of protein", parseFloat)
.option("-c, --carbs <grams>", "Grams of carbohydrates", parseFloat)
.option("-f, --fat <grams>", "Grams of fat", parseFloat)
.option("-a, --alcohol <grams>", "Grams of alcohol", parseFloat)
.option(
"-m, --meal <name>",
"Meal category (Breakfast, Lunch, Dinner, Snacks)"
)
.option("-d, --date <date>", "Date (YYYY-MM-DD, yesterday, -1d)")
.action(async (options) => {
await quickAdd(options);
});
Expand Down
2 changes: 2 additions & 0 deletions src/kernel/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export interface MacroEntry {
protein?: number;
carbs?: number;
fat?: number;
alcohol?: number;
meal?: string;
date?: string;
}

export interface WeightData {
Expand Down
28 changes: 27 additions & 1 deletion src/kernel/quick-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const MACRO_SEARCH_NAMES: Record<string, string> = {
protein: "Quick Add, Protein",
carbs: "Quick Add, Carbohydrate",
fat: "Quick Add, Fat",
alcohol: "Quick Add, Alcohol",
};

/**
Expand All @@ -26,7 +27,7 @@ export const MACRO_SEARCH_NAMES: Record<string, string> = {
* select result → enter serving size (grams) → "Add to Diary"
*/
export function buildQuickAddCode(entry: MacroEntry): string {
const { protein, carbs, fat, meal } = entry;
const { protein, carbs, fat, alcohol, meal, date } = entry;

const mealLabel = meal
? meal.charAt(0).toUpperCase() + meal.slice(1).toLowerCase()
Expand All @@ -52,23 +53,48 @@ export function buildQuickAddCode(entry: MacroEntry): string {
searchName: MACRO_SEARCH_NAMES.fat,
grams: fat,
});
if (alcohol !== undefined)
macros.push({
name: "alcohol",
searchName: MACRO_SEARCH_NAMES.alcohol,
grams: alcohol,
});

const macrosJson = JSON.stringify(macros);

return `
const macros = ${macrosJson};
const mealLabel = ${JSON.stringify(mealLabel)};
const targetDate = ${JSON.stringify(date ?? null)};

// Navigate to diary — we're already logged in from the same session
await page.goto('https://cronometer.com/#diary', { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});

// Wait for the diary to fully render
await page.waitForTimeout(2000);

// 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.' };
}

// Navigate to the target date using prev-day arrows (same approach as diary/weight)
if (targetDate) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(targetDate + 'T00:00:00');
const daysBack = Math.round((today - target) / (1000 * 60 * 60 * 24));
for (let s = 0; s < daysBack && s < 90; s++) {
const prev = page.locator('i.diary-date-previous').filter({ visible: true });
if (await prev.count() > 0) {
await prev.first().click();
await page.waitForTimeout(2000);
}
}
}

// Helper: find and click an element from a list of selectors
async function clickFirst(selectors, description) {
for (const sel of selectors) {
Expand Down
23 changes: 23 additions & 0 deletions src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ export function todayStr(): string {
return formatDate(new Date());
}

/**
* Resolve a date input string to YYYY-MM-DD.
*
* Supports:
* - "yesterday"
* - Relative: "-1d", "-7d" (N days ago)
* - Absolute: "YYYY-MM-DD"
*/
export function resolveDate(input: string): string {
if (input === "yesterday") {
const d = new Date();
d.setDate(d.getDate() - 1);
return formatDate(d);
}
const relMatch = input.match(/^-(\d+)d$/);
if (relMatch) {
const d = new Date();
d.setDate(d.getDate() - parseInt(relMatch[1], 10));
return formatDate(d);
}
return parseDate(input);
}

/**
* Parse a range spec into start/end date strings.
*
Expand Down
Loading