From e0e01ce19b477e10767d0aa1b8a811d8434e7e05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:27:04 +0000 Subject: [PATCH 1/4] Add running sums, conditional formatting and charts to reports Running sums accumulate a field or calculated value over the rows or group instances printed so far, over the enclosing group or the whole report. Text components take conditional formatting rules (bold, text gray, fill) evaluated with the component's value. A chart component draws bar, line, area, pie, donut and scatter charts with the dashboard chart data and geometry, as one chart item of paths and text that preview and PDF draw alike. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CCUeHtApaLW2nLJEdDErJd --- docs/decisions/report-engine.md | 72 ++++- src-tauri/src/reports.rs | 54 +++- src/dashboards/data.ts | 7 +- src/reports/PageView.tsx | 33 ++- src/reports/data.ts | 6 +- src/reports/designer/BandCanvas.tsx | 2 + src/reports/designer/ComponentProperties.tsx | 47 +++- src/reports/designer/ReportDesigner.tsx | 12 +- src/reports/designer/fields.tsx | 5 +- src/reports/engine/document.ts | 65 +++++ src/reports/engine/grow.ts | 6 +- src/reports/engine/layout.ts | 79 ++++-- src/reports/engine/render.ts | 32 +-- src/reports/engine/split.ts | 6 +- src/reports/export.ts | 14 +- src/reports/model.ts | 10 +- src/reports/pdf.ts | 51 +++- src/reports/types.ts | 58 +++- tests/integration/report-phase6.test.tsx | 76 +++++ tests/unit/report-phase6.test.ts | 282 +++++++++++++++++++ 20 files changed, 845 insertions(+), 72 deletions(-) create mode 100644 tests/integration/report-phase6.test.tsx create mode 100644 tests/unit/report-phase6.test.ts diff --git a/docs/decisions/report-engine.md b/docs/decisions/report-engine.md index 2f4e547b..d1a4ad3e 100644 --- a/docs/decisions/report-engine.md +++ b/docs/decisions/report-engine.md @@ -209,7 +209,62 @@ Print renders every page as an SVG sized in points into a container appended to Styles carry gray levels, not colors: text gray, border width, and fill gray. Table headers use a 0.9 gray fill with black text, which stays readable on a -monochrome printer. +monochrome printer. Chart marks are the one exception (see Charts). + +### Running sums (Phase 6) + +A field or calculated component with `runningSum` prints the sum of its +expression over every instance printed so far instead of the current value, +like Access's Running Sum property. In the detail band an instance is a row. +In a group header or footer it is one group instance, so +`sum(rows.amount)` with a running sum in a group footer gives a cumulative +subtotal. `"all"` never restarts. `"group"` restarts at each instance of the +enclosing group: the innermost group for the detail band, the next outer group +for a group band, and never for the outermost group's bands. The format +applies to the sum. Null adds nothing and booleans add 1 or 0. Any other +non-number prints `#Error` with a diagnostic, and the sum stays an error until +it restarts. Sums add to 15 significant digits like `src/expr`. + +Sums are computed once while bands are flattened, in print order, so +pagination, repeated headers and split bands never count a row twice. In +report and page bands `runningSum` has no effect and the component prints its +plain value; the designer hides the option on page bands. + +### Conditional formatting (Phase 6) + +Static text, field and calculated components hold `conditions`: an ordered +list of rules, each a boolean expression and a style of bold, text gray and +fill gray. The first rule that holds applies its style on top of the +component's own. Rules see the band's scope plus `value`, the component's value +(after a running sum, before formatting; the text for static text). A rule +that fails to evaluate counts as false and adds a diagnostic. Styles stay gray +levels so conditional output prints on a monochrome printer. The style applies +before can-grow measuring, so a rule that makes text bold can make its box +grow. + +### Charts (Phase 6) + +A `chart` component draws bar, line, area, pie, donut and scatter charts with +the dashboard chart code (PRD §15, §16): `chartData`/`scatterData` from +`src/dashboards/data.ts` for series, `src/dashboards/charts/geometry.ts` for +bars, lines and areas, and the dashboard palette. Rows come from a saved query +(`queryId`, loaded with the report like table queries) or the band's `rows`, +so a chart in a group header charts that group. `xField`, `yFields`, `groupBy` +and `stacked` mean what `x`, `y`, `groupBy` and `stacked` mean on a dashboard +chart; `x` and `y` are a component's position, hence the different names. + +`engine/chart.ts` turns that geometry into one `chart` item holding `path` +marks (move, line, cubic Bézier and close, in page points) and ordinary text +items, sized to the component. Pie and donut arcs are cubic Béziers of at most +a quarter turn each, since PDF has no arc operator. The preview draws the item +as an SVG group labelled with the chart title, and the PDF writes the paths +with RGB fill and stroke operators; text goes through the same font runs as +other text. A chart never grows and never splits: split pagination treats it +like an image and moves the whole chart to the next piece. + +Series are drawn in the dashboard palette, in color in the preview and the +PDF. Labels, legend text and gridlines stay gray, and every chart with two or +more series has a legend; pie and donut legends list each slice's share. ## Deferred @@ -223,7 +278,8 @@ arbitrary HTML or CSS. This engine also leaves these for later: band - caching font subsets between exports - image formats other than JPEG and PNG, which print as placeholders in the PDF -- color +- color outside chart marks +- summary charts, and running sums in table columns - content stream compression and PDF/A ## Proof @@ -263,8 +319,20 @@ arbitrary HTML or CSS. This engine also leaves these for later: previews it, prints it, and exports two identical PDFs through the Rust command. It also sets the pagination options in the designer and checks that Add table is blocked on page header and footer bands. +- `tests/unit/report-phase6.test.ts`: running sums over all rows, per group + and across group footers, formats, nulls and errors; first-match + conditional styles, conditions in grown text, failing conditions; charts of + every type from band rows and saved queries, legends, "No data", a chart + moving whole to the next page, RGB paths in the PDF, and the Bézier arc + helpers. +- `tests/integration/report-phase6.test.tsx`: sets a running sum, a + conditional bold rule and a bar chart in the designer and checks the + preview. ## Audit log +- 2026-10-10: Added running sums, conditional formatting and charts (PRD + Phase 6), with `tests/unit/report-phase6.test.ts` and + `tests/integration/report-phase6.test.tsx` as proof. - 2026-10-05: Said which options are omitted when off (`keepTogether` is always stored) and added the page-band table checks to the proof. diff --git a/src-tauri/src/reports.rs b/src-tauri/src/reports.rs index f8a4ad6b..7845ff15 100644 --- a/src-tauri/src/reports.rs +++ b/src-tauri/src/reports.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; /// Component kinds the canvas supports. Anything else is rejected by `validate`. -pub const COMPONENT_KINDS: [&str; 7] = [ +pub const COMPONENT_KINDS: [&str; 8] = [ "staticText", "field", "calculated", @@ -18,6 +18,7 @@ pub const COMPONENT_KINDS: [&str; 7] = [ "line", "rectangle", "table", + "chart", ]; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] @@ -225,6 +226,8 @@ fn band_issues( } if c.kind == "table" { tables += 1; + } + if c.kind == "table" || c.kind == "chart" { if let Some(q) = c.extra.get("queryId").and_then(|v| v.as_str()) { if !q.is_empty() && !config.saved_queries.iter().any(|s| s.id == q) { issues.push(err(format!( @@ -233,6 +236,22 @@ fn band_issues( } } } + if c.kind == "chart" { + let chart_type = c.extra.get("chartType").and_then(|v| v.as_str()); + if !matches!( + chart_type, + Some("bar" | "line" | "area" | "pie" | "donut" | "scatter") + ) { + issues.push(err(format!("{what} has an unsupported chart type"))); + } + } + if let Some(sum) = c.extra.get("runningSum") { + if !matches!(sum.as_str(), Some("group" | "all")) { + issues.push(err(format!( + "{what} running sum must be \"group\" or \"all\"" + ))); + } + } } if tables > 1 { issues.push(err(format!("{label} has more than one table"))); @@ -530,6 +549,39 @@ mod tests { assert!(has("Report footer has more than one table"), "{text:?}"); } + #[test] + fn reports_validate_charts_and_running_sums() { + let mut r = valid_report(); + let mut ok = component("ch1", "chart", 0.0, 0.0, 200.0, 100.0); + ok.extra.insert("chartType".into(), json!("bar")); + ok.extra.insert("queryId".into(), json!("q1")); + ok.extra.insert("xField".into(), json!("region")); + ok.extra.insert("yFields".into(), json!(["amount"])); + let mut bad = component("ch2", "chart", 0.0, 100.0, 200.0, 100.0); + bad.extra.insert("chartType".into(), json!("radar")); + bad.extra.insert("queryId".into(), json!("nope")); + let mut sum = component("s1", "calculated", 0.0, 0.0, 50.0, 20.0); + sum.extra.insert("runningSum".into(), json!("group")); + let mut wrong = component("s2", "field", 50.0, 0.0, 50.0, 20.0); + wrong.extra.insert("runningSum".into(), json!("page")); + r.bands.report_footer = Band { + height: 200.0, + components: vec![ok, bad], + ..Default::default() + }; + r.bands.detail.components.extend([sum, wrong]); + let issues = validate(&config_with(r)); + let text: Vec = issues.iter().map(|i| i.message.clone()).collect(); + assert_eq!(text.len(), 3, "{text:?}"); + let has = |s: &str| text.iter().any(|m| m.contains(s)); + assert!(has("ch2 has an unsupported chart type"), "{text:?}"); + assert!( + has("ch2 uses a saved query that does not exist"), + "{text:?}" + ); + assert!(has("s2 running sum must be"), "{text:?}"); + } + #[test] fn reports_validate_warns_about_tables_in_page_bands() { let mut r = valid_report(); diff --git a/src/dashboards/data.ts b/src/dashboards/data.ts index d888a4bd..795a9c92 100644 --- a/src/dashboards/data.ts +++ b/src/dashboards/data.ts @@ -54,12 +54,15 @@ export function foldSeries(series: Series[]): Series[] { return [...kept, { name: OTHER_LABEL, values: other }]; } +/** The columns chart data reads; dashboards and report charts both supply them. */ +export type SeriesSpec = Pick; + /** * Category chart data. Without `groupBy`, each y column is a series and rows with the * same x are summed. With `groupBy`, the first y column is split into one series per * group value. Categories and series keep first-appearance order. */ -export function chartData(component: DashboardComponent, rows: Row[]): CategoryData { +export function chartData(component: SeriesSpec, rows: Row[]): CategoryData { const x = component.x ?? ""; const ys = (component.y ?? []).filter(Boolean); const categories: string[] = []; @@ -102,7 +105,7 @@ export function chartData(component: DashboardComponent, rows: Row[]): CategoryD } /** Scatter data: numeric x against each y column, or the first y split by `groupBy`. */ -export function scatterData(component: DashboardComponent, rows: Row[]): ScatterData { +export function scatterData(component: SeriesSpec, rows: Row[]): ScatterData { const x = component.x ?? ""; const ys = (component.y ?? []).filter(Boolean); const point = (row: Row, y: string): Point | null => { diff --git a/src/reports/PageView.tsx b/src/reports/PageView.tsx index 67dfe71c..10f1bbb3 100644 --- a/src/reports/PageView.tsx +++ b/src/reports/PageView.tsx @@ -1,4 +1,11 @@ -import { type Page, type PositionedItem, type ReportDocument, textRuns } from "./engine"; +import { + type Page, + type PathItem, + type PathOp, + type PositionedItem, + type ReportDocument, + textRuns, +} from "./engine"; /** Gray level (0 black … 1 white) as a CSS color. */ const grayCss = (level: number) => { @@ -33,11 +40,15 @@ function runsOf(text: string, fontSize: number, bold: boolean, x: number) { )); } +/** SVG path data of chart path ops. */ +const svgPath = (ops: PathOp[]) => + ops.map((op) => (op[0] === "Z" ? "Z" : `${op[0]}${op.slice(1).join(" ")}`)).join(""); + function Item({ item, imageUrl, }: { - item: PositionedItem; + item: PositionedItem | PathItem; imageUrl: (id: string) => string | undefined; }) { switch (item.kind) { @@ -85,6 +96,24 @@ function Item({ ))} ); + case "path": + return ( + 0 ? item.stroke : "none"} + strokeWidth={item.stroke && item.lineWidth > 0 ? item.lineWidth : undefined} + strokeLinejoin="round" + /> + ); + case "chart": + return ( + + {item.marks.map((mark, i) => ( + + ))} + + ); case "image": { const href = imageUrl(item.assetId); if (!href) diff --git a/src/reports/data.ts b/src/reports/data.ts index aea610be..de8ba9e5 100644 --- a/src/reports/data.ts +++ b/src/reports/data.ts @@ -109,8 +109,8 @@ export interface ReportData { } /** - * Loads everything a report needs: dataset rows, rows of table components' - * saved queries, and image assets. Aborting `signal` interrupts the running + * Loads everything a report needs: dataset rows, rows of table and chart + * components' saved queries, and image assets. Aborting `signal` interrupts the running * query in DuckDB (`cancelQuery`) and rejects with `ReportCancelled`. */ export async function loadReportData( @@ -140,7 +140,7 @@ export async function loadReportData( const components = bandEntries(report).flatMap((entry) => entry.band.components); const tables: Record = {}; for (const c of components) { - if (c.kind !== "table" || !c.queryId || tables[c.queryId]) continue; + if ((c.kind !== "table" && c.kind !== "chart") || !c.queryId || tables[c.queryId]) continue; const query = config.savedQueries.find((q) => q.id === c.queryId); if (!query) continue; const options = { limit: REPORT_ROW_LIMIT, runId }; diff --git a/src/reports/designer/BandCanvas.tsx b/src/reports/designer/BandCanvas.tsx index 70285cb9..208f75f4 100644 --- a/src/reports/designer/BandCanvas.tsx +++ b/src/reports/designer/BandCanvas.tsx @@ -25,6 +25,8 @@ function summary(c: ReportComponent): string { return `Table (${c.columns.length} columns)`; case "image": return c.assetId ? "Image" : "Image (none)"; + case "chart": + return `(${c.chartType}${c.yFields.length ? `: ${c.yFields.filter(Boolean).join(", ")}` : ""})`; default: return ""; } diff --git a/src/reports/designer/ComponentProperties.tsx b/src/reports/designer/ComponentProperties.tsx index 40e10f31..4a1ee1e5 100644 --- a/src/reports/designer/ComponentProperties.tsx +++ b/src/reports/designer/ComponentProperties.tsx @@ -3,15 +3,11 @@ import { newId } from "../../lib/utils"; import type { SavedQuery } from "../../query/types"; import type { AssetSummary } from "../api"; import { fieldExpression, KIND_LABELS } from "../model"; -import type { ComponentStyle, ReportComponent, TableColumn, TextAlign } from "../types"; +import type { ComponentStyle, ReportComponent, RunningSum, TableColumn, TextAlign } from "../types"; +import { ChartProperties } from "./ChartProperties"; +import { ConditionsEditor } from "./ConditionsEditor"; import { ExpressionInput, PointInput } from "./fields"; - -const FILLS: [string, number | null][] = [ - ["None", null], - ["Light gray", 0.9], - ["Gray", 0.75], - ["Dark gray", 0.5], -]; +import { FILLS } from "./styles"; interface Props { component: ReportComponent; @@ -87,8 +83,33 @@ export function ComponentProperties({ onChange={(e) => onChange({ format: e.target.value } as Partial)} /> + {!pageBand && ( + + )} )} + {c.kind === "chart" && ( + onChange(patch as Partial)} + /> + )} {c.kind === "image" && ( )} + {isText && ( + + onChange({ + conditions: conditions.length ? conditions : undefined, + } as Partial) + } + /> + )} diff --git a/src/reports/designer/ReportDesigner.tsx b/src/reports/designer/ReportDesigner.tsx index 92c6003e..52e5fae9 100644 --- a/src/reports/designer/ReportDesigner.tsx +++ b/src/reports/designer/ReportDesigner.tsx @@ -1,4 +1,13 @@ -import { Calculator, Image, Minus, Square, Table2, TextCursorInput, Type } from "lucide-react"; +import { + Calculator, + ChartColumn, + Image, + Minus, + Square, + Table2, + TextCursorInput, + Type, +} from "lucide-react"; import { useEffect, useState } from "react"; import type { SavedQuery } from "../../query/types"; import { type AssetSummary, listAssets } from "../api"; @@ -27,6 +36,7 @@ const PALETTE: [ComponentKind, string, typeof Type][] = [ ["line", "Add line", Minus], ["rectangle", "Add rectangle", Square], ["table", "Add table", Table2], + ["chart", "Add chart", ChartColumn], ]; type Change = (fn: (report: Report) => Report, label?: string) => void; diff --git a/src/reports/designer/fields.tsx b/src/reports/designer/fields.tsx index c7aefbb2..be45be97 100644 --- a/src/reports/designer/fields.tsx +++ b/src/reports/designer/fields.tsx @@ -8,14 +8,17 @@ export function ExpressionInput({ value, onChange, placeholder, + names = REPORT_SCOPE_NAMES, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; + /** Names the expression may use. */ + names?: string[]; }) { const id = useId(); - const problems = value.trim() ? check(value, REPORT_SCOPE_NAMES) : []; + const problems = value.trim() ? check(value, names) : []; return (