From 3664c99fe9cbd2e0492726c79f1fdcdf233400d3 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:41:00 -0700 Subject: [PATCH] Add export summaries for archived cooks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 340b4dd3-b4b3-4f1a-9163-66b16d96fa81 --- packages/cli/src/command-registry.ts | 8 +-- packages/cli/src/commands/export.ts | 100 ++++++++++++++++++++++++--- packages/cli/tests/export.test.ts | 67 ++++++++++++++++++ 3 files changed, 163 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 9c6eace..05d1a40 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -671,17 +671,17 @@ export const commandDefinitions: readonly CommandDefinition[] = [ }, { name: "export", - summary: "Export archive readings to CSV, JSON, or InfluxDB", + summary: "Export archive readings to CSV, JSON, InfluxDB, or summary stats", usage: "export SERIAL [--archive ID] [--format FMT] [--output PATH]", usageLines: [ - "export SERIAL Export archive readings to CSV, JSON, or InfluxDB", + "export SERIAL Export archive readings to CSV, JSON, InfluxDB, or summary stats", " --archive ID Export a specific archive (default: latest)", - " --format FMT Output format: csv, json, or influx (default: json)", + " --format FMT Output format: csv, json, influx, or summary (default: json)", " --output PATH Write to file (default: stdout)", ], arguments: [serial], supportsJson: true, - handler: ({ args }: CommandContext) => exportData(args), + handler: ({ args, options }: CommandContext) => exportData(args, options), }, { name: "backup", diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index debb416..4129768 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -4,7 +4,7 @@ import type { Archive } from "thermoworks-sdk"; import { ThermoworksCloud } from "thermoworks-sdk"; import { getCredentials } from "../credentials.js"; -import { maybeRedact } from "../output.js"; +import { maybeRedact, type OutputOptions } from "../output.js"; /** A flattened reading row for export output. */ export interface ExportRow { @@ -18,26 +18,26 @@ export interface ExportRow { export interface ExportOptions { serial: string; archiveId?: string; - format: "csv" | "json" | "influx"; + format: "csv" | "json" | "influx" | "summary"; output?: string; downsample?: number; } /** * Parse export-specific CLI args from remaining argv after global flags. - * Expected: export SERIAL [--archive ID] [--format csv|json|influx] [--output PATH] [--downsample SECONDS] + * Expected: export SERIAL [--archive ID] [--format csv|json|influx|summary] [--output PATH] [--downsample SECONDS] */ export function parseExportArgs(args: string[]): ExportOptions { // args[0] is "export", args[1] is SERIAL const serial = args[1]; if (!serial || serial.startsWith("--")) { throw new Error( - "Usage: thermoworks export SERIAL [--archive ID] [--format csv|json|influx] [--output PATH] [--downsample SECONDS]", + "Usage: thermoworks export SERIAL [--archive ID] [--format csv|json|influx|summary] [--output PATH] [--downsample SECONDS]", ); } let archiveId: string | undefined; - let format: "csv" | "json" | "influx" = "json"; + let format: "csv" | "json" | "influx" | "summary" = "json"; let output: string | undefined; let downsample: number | undefined; @@ -50,8 +50,8 @@ export function parseExportArgs(args: string[]): ExportOptions { case "--format": { const val = args[++i]; - if (val !== "csv" && val !== "json" && val !== "influx") { - throw new Error("--format must be 'csv', 'json', or 'influx'"); + if (val !== "csv" && val !== "json" && val !== "influx" && val !== "summary") { + throw new Error("--format must be 'csv', 'json', 'influx', or 'summary'"); } format = val; } @@ -131,6 +131,80 @@ export function downsampleRows(rows: ExportRow[], intervalSeconds: number): Expo return out; } +/** Summary statistics for one archive channel. */ +export interface ExportSummaryRow { + channel: string; + units: string; + count: number; + minimum: number | null; + maximum: number | null; + average: number | null; + firstTimestamp: string | null; + lastTimestamp: string | null; + durationSeconds: number | null; +} + +/** Build per-channel summary statistics from flattened export rows. */ +export function summarizeRows(rows: ExportRow[]): ExportSummaryRow[] { + const groups = new Map(); + for (const row of rows) { + const key = `${row.channel}\0${row.units}`; + const group = groups.get(key); + if (group) { + group.push(row); + } else { + groups.set(key, [row]); + } + } + + return [...groups.values()].map((group) => { + const first = group[0]; + const last = group[group.length - 1]; + const values = group.map((row) => row.value).filter(Number.isFinite); + const average = + values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : null; + const firstMs = first ? new Date(first.timestamp).getTime() : Number.NaN; + const lastMs = last ? new Date(last.timestamp).getTime() : Number.NaN; + const durationSeconds = + Number.isNaN(firstMs) || Number.isNaN(lastMs) ? null : Math.max(0, (lastMs - firstMs) / 1000); + return { + channel: first?.channel ?? "unknown", + units: first?.units ?? "", + count: group.length, + minimum: values.length > 0 ? Math.min(...values) : null, + maximum: values.length > 0 ? Math.max(...values) : null, + average, + firstTimestamp: first?.timestamp ?? null, + lastTimestamp: last?.timestamp ?? null, + durationSeconds, + }; + }); +} + +function formatNumber(value: number | null): string { + return value == null ? "" : Number(value.toFixed(2)).toString(); +} + +/** Format summary rows as a tab-separated table. */ +export function formatSummary(rows: ExportSummaryRow[]): string { + const header = + "channel\tunits\tcount\tminimum\tmaximum\taverage\tfirstTimestamp\tlastTimestamp\tdurationSeconds"; + const lines = rows.map((row) => + [ + row.channel, + row.units, + String(row.count), + formatNumber(row.minimum), + formatNumber(row.maximum), + formatNumber(row.average), + row.firstTimestamp ?? "", + row.lastTimestamp ?? "", + formatNumber(row.durationSeconds), + ].join("\t"), + ); + return `${[header, ...lines].join("\n")}\n`; +} + /** Format rows as CSV with header line. */ export function formatCsv(rows: ExportRow[]): string { const header = "timestamp,channel,value,units"; @@ -201,7 +275,10 @@ export function formatInflux(rows: ExportRow[], serial: string): string { } /** Main export command handler. */ -export async function exportData(args: string[]): Promise { +export async function exportData( + args: string[], + outputOptions: OutputOptions = { json: false }, +): Promise { const options = parseExportArgs(args); const creds = await getCredentials(); @@ -236,6 +313,13 @@ export async function exportData(args: string[]): Promise { } else if (options.format === "influx") { const serialTag = maybeRedact({ serial: options.serial }).serial; content = formatInflux(rows, serialTag); + } else if (options.format === "summary") { + const summary = summarizeRows(rows); + if (outputOptions.json) { + content = `${JSON.stringify(summary, null, 2)}\n`; + } else { + content = formatSummary(summary); + } } else { content = formatJson(rows); } diff --git a/packages/cli/tests/export.test.ts b/packages/cli/tests/export.test.ts index d8cee97..e1e4a2a 100644 --- a/packages/cli/tests/export.test.ts +++ b/packages/cli/tests/export.test.ts @@ -36,7 +36,9 @@ import { formatCsv, formatInflux, formatJson, + formatSummary, parseExportArgs, + summarizeRows, } from "../src/commands/export.js"; import { getCredentials } from "../src/credentials.js"; @@ -137,6 +139,11 @@ describe("parseExportArgs", () => { expect(result.format).toBe("json"); }); + it("parses --format summary", () => { + const result = parseExportArgs(["export", "ABC123", "--format", "summary"]); + expect(result.format).toBe("summary"); + }); + it("parses --format influx", () => { const result = parseExportArgs(["export", "ABC123", "--format", "influx"]); expect(result.format).toBe("influx"); @@ -725,3 +732,63 @@ describe("exportData", () => { expect(parsed[1].value).toBe(230); }); }); + +// ============================================================================= +// summarizeRows / formatSummary +// ============================================================================= + +describe("summarizeRows", () => { + it("calculates per-channel summary statistics", () => { + const rows: ExportRow[] = [ + { timestamp: "2026-01-15T12:00:00.000Z", channel: "Pit", value: 225, units: "F" }, + { timestamp: "2026-01-15T12:05:00.000Z", channel: "Pit", value: 275, units: "F" }, + { timestamp: "2026-01-15T12:10:00.000Z", channel: "Meat", value: 150, units: "F" }, + ]; + + const summary = summarizeRows(rows); + + expect(summary).toEqual([ + { + channel: "Pit", + units: "F", + count: 2, + minimum: 225, + maximum: 275, + average: 250, + firstTimestamp: "2026-01-15T12:00:00.000Z", + lastTimestamp: "2026-01-15T12:05:00.000Z", + durationSeconds: 300, + }, + { + channel: "Meat", + units: "F", + count: 1, + minimum: 150, + maximum: 150, + average: 150, + firstTimestamp: "2026-01-15T12:10:00.000Z", + lastTimestamp: "2026-01-15T12:10:00.000Z", + durationSeconds: 0, + }, + ]); + }); + + it("formats summary rows as tab-separated text", () => { + const text = formatSummary([ + { + channel: "Pit", + units: "F", + count: 2, + minimum: 225, + maximum: 275, + average: 250, + firstTimestamp: "2026-01-15T12:00:00.000Z", + lastTimestamp: "2026-01-15T12:05:00.000Z", + durationSeconds: 300, + }, + ]); + + expect(text).toContain("channel\tunits\tcount\tminimum\tmaximum\taverage"); + expect(text).toContain("Pit\tF\t2\t225\t275\t250"); + }); +});