Skip to content
Open
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
18 changes: 14 additions & 4 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,11 +1079,14 @@ list subcommand
**Usage**

```bash
npx thermoworks journal list
npx thermoworks journal list [--meat TEXT] [--min-rating N] [--search TEXT]
```

**Options**

- `--meat TEXT` - Only include entries whose meat field contains this text.
- `--min-rating N` - Only include entries rated at least N stars.
- `--search TEXT` - Search entry titles and notes.
- `--json` - Output machine-readable JSON.

### `thermoworks journal show`
Expand All @@ -1107,11 +1110,14 @@ cost subcommand
**Usage**

```bash
npx thermoworks journal cost
npx thermoworks journal cost [--meat TEXT] [--min-rating N] [--search TEXT]
```

**Options**

- `--meat TEXT` - Only include entries whose meat field contains this text.
- `--min-rating N` - Only include entries rated at least N stars.
- `--search TEXT` - Search entry titles and notes.
- `--json` - Output machine-readable JSON.

### `thermoworks journal import`
Expand All @@ -1135,12 +1141,16 @@ export subcommand
**Usage**

```bash
npx thermoworks journal export
npx thermoworks journal export [--format json|csv] [--output PATH] [--meat TEXT] [--min-rating N] [--search TEXT]
```

**Options**

None.
- `--format json|csv` - Output format.
- `--output PATH` - Write output to a file.
- `--meat TEXT` - Only include entries whose meat field contains this text.
- `--min-rating N` - Only include entries rated at least N stars.
- `--search TEXT` - Search entry titles and notes.

### `thermoworks journal rm`

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,10 +855,10 @@ export const commandDefinitions: readonly CommandDefinition[] = [
"journal add Log a finished cook to a local logbook",
" --cost-meat N Meat cost for the cook",
" --cost-fuel N Fuel cost for the cook",
"journal list List logbook entries (newest first)",
"journal list List logbook entries (filters: --meat, --min-rating, --search)",
"journal show <id> Show one logbook entry",
"journal cost Summarize cook costs across the logbook",
"journal export Export the local logbook as JSON or CSV",
"journal cost Summarize cook costs across matching logbook entries",
"journal export Export matching logbook entries as JSON or CSV",
"journal rm <id> Remove one logbook entry",
],
subcommands: journalCommands,
Expand Down
100 changes: 91 additions & 9 deletions packages/cli/src/commands/journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,66 @@ export function sortJournalEntries(entries: JournalEntry[]): JournalEntry[] {
return [...entries].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

export interface JournalFilterOptions {
meat?: string;
minRating?: number;
search?: string;
}

export function parseJournalFilterArgs(args: string[]): JournalFilterOptions | { error: string } {
const filters: JournalFilterOptions = {};

for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (arg === "--meat") {
const value = args[++i];
if (value === undefined) return { error: "--meat requires a value" };
filters.meat = value;
} else if (arg === "--min-rating") {
const value = args[++i];
if (value === undefined) return { error: "--min-rating requires a value" };
const rating = Number.parseInt(value, 10);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { error: `--min-rating must be an integer from 1 to 5, got "${value}"` };
}
filters.minRating = rating;
} else if (arg === "--search") {
const value = args[++i];
if (value === undefined) return { error: "--search requires a value" };
filters.search = value;
} else {
return { error: `Unknown option: ${arg}` };
}
}

return filters;
}

function includesText(value: string | undefined, needle: string): boolean {
return (value ?? "").toLocaleLowerCase().includes(needle.toLocaleLowerCase());
}

export function filterJournalEntries(
entries: JournalEntry[],
filters: JournalFilterOptions,
): JournalEntry[] {
return entries.filter((entry) => {
if (filters.meat && !includesText(entry.meat, filters.meat)) return false;
if (filters.minRating != null && (entry.rating == null || entry.rating < filters.minRating)) {
return false;
}
if (
filters.search &&
!includesText(entry.title, filters.search) &&
!includesText(entry.notes, filters.search)
) {
return false;
}
return true;
});
}

/** Format the list view (newest first). */
export function formatList(entries: JournalEntry[]): string {
if (entries.length === 0) {
Expand Down Expand Up @@ -247,12 +307,14 @@ export function formatCostSummary(summary: CostSummary): string {
export interface JournalExportOptions {
format: "json" | "csv";
output?: string;
filters: JournalFilterOptions;
}

/** Parse args after `journal export`. Returns an error message on failure. */
export function parseJournalExportArgs(args: string[]): JournalExportOptions | { error: string } {
let format: "json" | "csv" = "json";
let output: string | undefined;
const filterArgs: string[] = [];

for (let i = 0; i < args.length; i++) {
const arg = args[i];
Expand All @@ -268,12 +330,19 @@ export function parseJournalExportArgs(args: string[]): JournalExportOptions | {
const value = args[++i];
if (value === undefined) return { error: "--output requires a file path" };
output = value;
} else if (arg === "--meat" || arg === "--min-rating" || arg === "--search") {
filterArgs.push(arg);
const value = args[++i];
if (value === undefined) return { error: `${arg} requires a value` };
filterArgs.push(value);
} else {
return { error: `Unknown option: ${arg}` };
}
}

return { format, output };
const filters = parseJournalFilterArgs(filterArgs);
if ("error" in filters) return filters;
return { format, output, filters };
}

function escapeCsvField(value: string): string {
Expand Down Expand Up @@ -344,11 +413,13 @@ async function journalExport(args: string[]): Promise<void> {
}

const entries = await loadJournal();
const content = parsed.format === "csv" ? formatJournalCsv(entries) : formatJournalJson(entries);
const filtered = filterJournalEntries(entries, parsed.filters);
const content =
parsed.format === "csv" ? formatJournalCsv(filtered) : formatJournalJson(filtered);
if (parsed.output) {
await writeFile(parsed.output, content, "utf8");
console.error(
`Exported ${entries.length} journal entr${entries.length === 1 ? "y" : "ies"} to ${parsed.output}.`,
`Exported ${filtered.length} journal entr${filtered.length === 1 ? "y" : "ies"} to ${parsed.output}.`,
);
return;
}
Expand Down Expand Up @@ -410,11 +481,11 @@ const USAGE = `Usage: thermoworks journal <add|list|show|cost|import|export|rm>
journal add --title "Sunday brisket" [--meat brisket] [--weight 12]
[--rating 4] [--cost-meat 40] [--cost-fuel 8] [--notes "..."]
[--device SN] [--archive ID]
journal list [--json]
journal list [--meat TEXT] [--min-rating N] [--search TEXT] [--json]
journal show <id> [--json]
journal cost [--json]
journal cost [--meat TEXT] [--min-rating N] [--search TEXT] [--json]
journal import [SERIAL] [--limit N] [--dry-run] [--json]
journal export [--format json|csv] [--output PATH]
journal export [--format json|csv] [--output PATH] [--meat TEXT] [--min-rating N] [--search TEXT]
journal rm <id>`;

/** Route `thermoworks journal <subcommand>` to the right handler. */
Expand All @@ -437,12 +508,18 @@ export async function journal(args: string[], options: OutputOptions): Promise<v
break;
}
case "list": {
const filters = parseJournalFilterArgs(args.slice(1));
if ("error" in filters) {
console.error(filters.error);
process.exit(1);
}
const entries = await loadJournal();
const filtered = filterJournalEntries(entries, filters);
if (options.json) {
outputJson([...entries].sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
outputJson([...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
return;
}
process.stdout.write(formatList(entries));
process.stdout.write(formatList(filtered));
break;
}
case "show": {
Expand All @@ -464,8 +541,13 @@ export async function journal(args: string[], options: OutputOptions): Promise<v
break;
}
case "cost": {
const filters = parseJournalFilterArgs(args.slice(1));
if ("error" in filters) {
console.error(filters.error);
process.exit(1);
}
const entries = await loadJournal();
const summary = summarizeCosts(entries);
const summary = summarizeCosts(filterJournalEntries(entries, filters));
if (options.json) {
outputJson(summary);
return;
Expand Down
99 changes: 97 additions & 2 deletions packages/cli/tests/journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,68 @@ describe("formatList", () => {
});
});

describe("journal filters", () => {
it("parses meat, minimum rating, and search filters", async () => {
const { parseJournalFilterArgs } = await import("../src/commands/journal.js");
expect(
parseJournalFilterArgs(["--meat", "brisket", "--min-rating", "4", "--search", "wrap"]),
).toEqual({
meat: "brisket",
minRating: 4,
search: "wrap",
});
});

it("rejects invalid filter options", async () => {
const { parseJournalFilterArgs } = await import("../src/commands/journal.js");
expect(parseJournalFilterArgs(["--min-rating", "6"])).toEqual({
error: '--min-rating must be an integer from 1 to 5, got "6"',
});
expect(parseJournalFilterArgs(["--search"])).toEqual({
error: "--search requires a value",
});
expect(parseJournalFilterArgs(["--bogus"])).toEqual({
error: "Unknown option: --bogus",
});
});

it("filters entries by meat, rating, and title or notes search", async () => {
const { filterJournalEntries } = await import("../src/commands/journal.js");
const entries = [
sampleEntry({
id: "match",
title: "Sunday brisket",
meat: "beef brisket",
rating: 5,
notes: "Wrapped at 165",
}),
sampleEntry({
id: "wrong-rating",
title: "Friday brisket",
meat: "beef brisket",
rating: 3,
notes: "Wrapped early",
}),
sampleEntry({
id: "wrong-meat",
title: "Pork ribs",
meat: "pork ribs",
rating: 5,
notes: "Wrapped at 165",
}),
];

expect(
filterJournalEntries(entries, { meat: "brisket", minRating: 4, search: "wrapped" }).map(
(entry) => entry.id,
),
).toEqual(["match"]);
expect(filterJournalEntries(entries, { search: "sunday" }).map((entry) => entry.id)).toEqual([
"match",
]);
});
});

describe("formatEntry", () => {
it("includes optional fields when present", async () => {
const { formatEntry } = await import("../src/commands/journal.js");
Expand Down Expand Up @@ -352,10 +414,26 @@ describe("formatCostSummary", () => {
describe("journal export helpers", () => {
it("parses export options", async () => {
const { parseJournalExportArgs } = await import("../src/commands/journal.js");
expect(parseJournalExportArgs([])).toEqual({ format: "json", output: undefined });
expect(parseJournalExportArgs(["--format", "csv", "--output", "cooks.csv"])).toEqual({
expect(parseJournalExportArgs([])).toEqual({
format: "json",
output: undefined,
filters: {},
});
expect(
parseJournalExportArgs([
"--format",
"csv",
"--output",
"cooks.csv",
"--meat",
"ribs",
"--min-rating",
"4",
]),
).toEqual({
format: "csv",
output: "cooks.csv",
filters: { meat: "ribs", minRating: 4 },
});
});

Expand Down Expand Up @@ -440,4 +518,21 @@ describe("journal export command", () => {
);
expect(errorSpy).toHaveBeenCalledWith("Exported 2 journal entries to journal.json.");
});

it("filters exported entries", async () => {
mockReadFile.mockResolvedValue(
JSON.stringify([
sampleEntry({ id: "one", title: "Brisket", meat: "brisket", rating: 5 }),
sampleEntry({ id: "two", title: "Ribs", meat: "pork ribs", rating: 5 }),
]) as never,
);
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { journal } = await import("../src/commands/journal.js");
await journal(["export", "--meat", "brisket"], { json: false });

const output = stdoutSpy.mock.calls.map((call) => call[0]).join("");
expect(output).toContain('"id": "one"');
expect(output).not.toContain('"id": "two"');
});
});
Loading