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/scripts/screenshot/specs/reports.spec.tsx b/scripts/screenshot/specs/reports.spec.tsx index 2cb7cada..d0d302b0 100644 --- a/scripts/screenshot/specs/reports.spec.tsx +++ b/scripts/screenshot/specs/reports.spec.tsx @@ -282,3 +282,96 @@ it("prompts for parameters in the Runtime and prints grown Unicode text", async ], }); }); + +it("adds a running sum with conditional formatting and charts, and previews them", async () => { + const user = await renderNewDocument(); + await seedSales(); + await saveQuery( + "Orders with customers", + "SELECT o.id, c.name AS customer, o.status, o.amount FROM orders o JOIN customers c ON c.id = o.customer_id ORDER BY o.status, o.id", + ); + await saveAndReopen(user, "phase6.ixt"); + await openMode(user, "Reports"); + const create = await screen.findByRole("button", { name: "New report" }, LONG); + await waitFor(() => expect(create).toBeEnabled(), LONG); + await user.click(create); + const name = await screen.findByRole("textbox", { name: "Report name" }, LONG); + await user.clear(name); + await user.type(name, "Running totals"); + await user.selectOptions( + screen.getByRole("combobox", { name: "Dataset" }), + "Query: Orders with customers", + ); + await addComponent(user, "Detail", "Add field"); + await user.selectOptions(screen.getByRole("combobox", { name: "Bound field" }), "customer"); + await addComponent(user, "Detail", "Add field"); + await user.selectOptions(screen.getByRole("combobox", { name: "Bound field" }), "status"); + await addComponent(user, "Detail", "Add field"); + await user.selectOptions(screen.getByRole("combobox", { name: "Bound field" }), "amount"); + await user.selectOptions(screen.getByRole("combobox", { name: "Running sum" }), "all"); + await typeInto(user, "Format", "#,##0.00"); + await user.click(screen.getByRole("button", { name: "Add rule" })); + await typeInto(user, "Rule 1 condition", "value > 500"); + await user.selectOptions(screen.getByRole("combobox", { name: "Rule 1 fill" }), "Light gray"); + expect(screen.getByRole("checkbox", { name: "Rule 1 bold" })).toBeChecked(); + await captureDocument(document, { + name: "reports-08-running-sum-rule", + expectations: [ + "The detail band holds customer, status and amount fields; the amount field is selected.", + "The properties panel shows Running sum 'Over all' and conditional formatting rule 1 'value > 500' with Bold checked and a light gray fill.", + ], + }); + + await addComponent(user, "Report footer", "Add chart"); + await typeInto(user, "Chart title", "Amount by status"); + await typeInto(user, "Category column", "status"); + await typeInto(user, "Value columns", "amount"); + await setPoint(user, "Width", 255); + await addComponent(user, "Report footer", "Add chart"); + await user.selectOptions(screen.getByRole("combobox", { name: "Chart type" }), "donut"); + await typeInto(user, "Chart title", "Amount by customer"); + await typeInto(user, "Category column", "customer"); + await typeInto(user, "Value column", "amount"); + await setPoint(user, "Width", 260); + await setPoint(user, "X", 263); + await captureDocument(document, { + name: "reports-09-chart-designer", + expectations: [ + "The report footer band holds two chart components side by side, labelled 'bar chart: amount' and 'donut chart: amount'.", + "The properties panel shows the chart settings: type Donut, title 'Amount by customer', category column customer, value column amount.", + ], + }); + + await user.click(screen.getByRole("tab", { name: "Preview" })); + const page = await screen.findByRole("img", { name: "Page 1 of 1" }, LONG); + for (const total of ["20.00", "140.00", "215.50"]) + expect(within(page).getByText(total)).toHaveAttribute("font-weight", "normal"); + for (const total of ["555.50", "965.50"]) + expect(within(page).getByText(total)).toHaveAttribute("font-weight", "bold"); + const bars = within(page).getByRole("img", { name: "Amount by status" }); + for (const label of ["cancelled", "open", "shipped"]) + expect(within(bars).getByText(label)).toBeInTheDocument(); + const donut = within(page).getByRole("img", { name: "Amount by customer" }); + expect(within(donut).getByText("Northstar Goods (48%)")).toBeInTheDocument(); + await captureDocument(document, { + name: "reports-10-running-sum-charts-preview", + expectations: [ + "The amount column is a running total (20.00, 140.00, 215.50, 555.50, 965.50); the last two are bold on a light gray fill.", + "Below the rows, a blue bar chart 'Amount by status' with bars for cancelled, open and shipped sits beside a donut 'Amount by customer' with a legend of three customers and their shares.", + ], + }); +}); + +async function typeInto(user: User, label: string, text: string) { + const input = screen.getByLabelText(label); + await user.clear(input); + await user.type(input, text); + await waitFor(() => expect(input).toHaveValue(text)); +} + +async function setPoint(user: User, label: string, value: number) { + const input = screen.getByRole("spinbutton", { name: label }); + await user.clear(input); + await user.type(input, String(value)); + await waitFor(() => expect(input).toHaveValue(value)); +} 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..696d6262 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} chart${c.yFields.length ? `: ${c.yFields.filter(Boolean).join(", ")}` : ""}`; default: return ""; } diff --git a/src/reports/designer/ChartProperties.tsx b/src/reports/designer/ChartProperties.tsx new file mode 100644 index 00000000..8fa10293 --- /dev/null +++ b/src/reports/designer/ChartProperties.tsx @@ -0,0 +1,128 @@ +import { useId } from "react"; +import type { SavedQuery } from "../../query/types"; +import { type ChartComponent, REPORT_CHART_TYPES, type ReportChartType } from "../types"; + +const TYPE_LABELS: Record = { + bar: "Bar", + line: "Line", + area: "Area", + pie: "Pie", + donut: "Donut", + scatter: "Scatter", +}; + +type Patch = Partial< + Pick< + ChartComponent, + "chartType" | "queryId" | "xField" | "yFields" | "groupBy" | "stacked" | "format" | "title" + > +>; + +/** Data and display settings of a report chart. Column inputs suggest the dataset's columns. */ +export function ChartProperties({ + chart: c, + columns, + queries, + onChange, +}: { + chart: ChartComponent; + columns: string[]; + queries: SavedQuery[]; + onChange: (patch: Patch) => void; +}) { + const listId = useId(); + const pie = c.chartType === "pie" || c.chartType === "donut"; + return ( +
+ Chart + + {columns.map((col) => ( + + + + + + + {!pie && ( + + )} + {(c.chartType === "bar" || c.chartType === "area") && ( + + )} + +
+ ); +} 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/ConditionsEditor.tsx b/src/reports/designer/ConditionsEditor.tsx new file mode 100644 index 00000000..5a0d7c70 --- /dev/null +++ b/src/reports/designer/ConditionsEditor.tsx @@ -0,0 +1,118 @@ +import { Plus, Trash2 } from "lucide-react"; +import { newId } from "../../lib/utils"; +import { REPORT_SCOPE_NAMES } from "../model"; +import type { ConditionStyle, ReportCondition } from "../types"; +import { ExpressionInput } from "./fields"; +import { FILLS, GRAYS } from "./styles"; + +const CONDITION_NAMES = [...REPORT_SCOPE_NAMES, "value"]; + +/** A select whose empty option leaves the setting unchanged. */ +function StyleSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value: number | null | undefined; + options: [string, number | null][]; + onChange: (value: number | null | undefined) => void; +}) { + return ( + + ); +} + +/** + * Conditional formatting rules of a text component. The first rule whose + * condition holds applies its style; `value` is the component's own value. + */ +export function ConditionsEditor({ + conditions, + onChange, +}: { + conditions: ReportCondition[]; + onChange: (conditions: ReportCondition[]) => void; +}) { + const set = (id: string, patch: Partial) => + onChange(conditions.map((r) => (r.id === id ? { ...r, ...patch } : r))); + const setStyle = (rule: ReportCondition, patch: ConditionStyle) => { + const style: ConditionStyle = { ...rule.style, ...patch }; + for (const key of Object.keys(style) as (keyof ConditionStyle)[]) + if (style[key] === undefined) delete style[key]; + set(rule.id, { style }); + }; + return ( +
+ Conditional formatting + {conditions.map((rule, i) => ( +
+ Rule {i + 1} + set(rule.id, { when })} + /> + +
+ setStyle(rule, { gray: gray ?? undefined })} + /> + setStyle(rule, { fill })} + /> +
+ +
+ ))} + +
+ ); +} 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 (