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
72 changes: 70 additions & 2 deletions docs/decisions/report-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
93 changes: 93 additions & 0 deletions scripts/screenshot/specs/reports.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
54 changes: 53 additions & 1 deletion src-tauri/src/reports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ 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",
"image",
"line",
"rectangle",
"table",
"chart",
];

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
Expand Down Expand Up @@ -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!(
Expand All @@ -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")));
Expand Down Expand Up @@ -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<String> = 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();
Expand Down
7 changes: 5 additions & 2 deletions src/dashboards/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DashboardComponent, "x" | "y" | "groupBy">;

/**
* 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[] = [];
Expand Down Expand Up @@ -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 => {
Expand Down
33 changes: 31 additions & 2 deletions src/reports/PageView.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -85,6 +96,24 @@ function Item({
))}
</>
);
case "path":
return (
<path
d={svgPath(item.ops)}
fill={item.fill ?? "none"}
stroke={item.stroke && item.lineWidth > 0 ? item.stroke : "none"}
strokeWidth={item.stroke && item.lineWidth > 0 ? item.lineWidth : undefined}
strokeLinejoin="round"
/>
);
case "chart":
return (
<g role="img" aria-label={item.title}>
{item.marks.map((mark, i) => (
<Item key={i} item={mark} imageUrl={imageUrl} />
))}
</g>
);
case "image": {
const href = imageUrl(item.assetId);
if (!href)
Expand Down
6 changes: 3 additions & 3 deletions src/reports/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -140,7 +140,7 @@ export async function loadReportData(
const components = bandEntries(report).flatMap((entry) => entry.band.components);
const tables: Record<string, Row[]> = {};
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 };
Expand Down
2 changes: 2 additions & 0 deletions src/reports/designer/BandCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
Expand Down
Loading
Loading