Skip to content

Report datasets and formatting

View as Markdown

GridDataset is the wire shape consumed by Sapporta’s report renderer. The report read owns authorization and row scope. The mapper receives ordinary visible rows and turns them into stable nodes, raw values, presentation metadata, and footers.

Pass the date baseline explicitly along with the visible rows:

import { Temporal } from "@sapporta/shared/temporal";
import type { GridDataset } from "@sapporta/shared/grid-dataset";
type ProjectRow = { id: number; name: string };
type TaskRow = {
id: number;
project_id: number;
status: "open" | "in_progress" | "completed";
due_date: Temporal.PlainDate | null;
};
type ProjectProgressInput = {
projects: readonly ProjectRow[];
tasks: readonly TaskRow[];
asOf: Temporal.PlainDate;
};
export function projectProgressDataset({
projects,
tasks,
asOf,
}: ProjectProgressInput): GridDataset {
const tasksByProject = new Map<number, TaskRow[]>();
for (const task of tasks) {
const projectTasks = tasksByProject.get(task.project_id) ?? [];
projectTasks.push(task);
tasksByProject.set(task.project_id, projectTasks);
}
const rows = projects.map((project) => {
const projectTasks = tasksByProject.get(project.id) ?? [];
const completed = projectTasks.filter(
(task) => task.status === "completed",
).length;
const open = projectTasks.filter((task) => task.status === "open").length;
const inProgress = projectTasks.filter(
(task) => task.status === "in_progress",
).length;
const overdue = projectTasks.filter(
(task) =>
task.status !== "completed" &&
task.due_date !== null &&
Temporal.PlainDate.compare(task.due_date, asOf) < 0,
).length;
return {
project,
total: projectTasks.length,
open,
inProgress,
completed,
overdue,
completion:
projectTasks.length === 0 ? 0 : completed / projectTasks.length,
};
});
const totals = rows.reduce(
(sum, row) => ({
total: sum.total + row.total,
open: sum.open + row.open,
inProgress: sum.inProgress + row.inProgress,
completed: sum.completed + row.completed,
overdue: sum.overdue + row.overdue,
}),
{ total: 0, open: 0, inProgress: 0, completed: 0, overdue: 0 },
);
return {
name: "project-progress",
label: `Project progress as of ${asOf.toString()}`,
rootLevel: "project",
levels: {
project: {
label: "Projects",
columns: [
{
id: "project_id",
label: "Project ID",
kind: "number",
visuallyHidden: true,
},
{ id: "project", label: "Project", kind: "text", minWidth: 40 },
{ id: "total", label: "Tasks", kind: "number" },
{ id: "open", label: "Open", kind: "number", zeroDisplay: "dot" },
{
id: "in_progress",
label: "In progress",
kind: "number",
zeroDisplay: "dot",
},
{
id: "completed",
label: "Completed",
kind: "number",
zeroDisplay: "dot",
},
{
id: "overdue",
label: "Overdue",
kind: "number",
colorRule: "negative",
zeroDisplay: "dot",
},
{
id: "completion",
label: "Completion",
kind: "number",
displayFormat: "percentage",
},
],
childLevels: [],
},
},
nodes: rows.map((row) => ({
rowKey: `project:${row.project.id}`,
levelName: "project",
columns: {
project_id: row.project.id,
project: row.project.name,
total: row.total,
open: row.open,
in_progress: row.inProgress,
completed: row.completed,
overdue: row.overdue,
completion: row.completion,
},
})),
footerRows: [
{
rowKey: "grand-total",
columns: {
project: "Grand total",
total: totals.total,
open: totals.open,
in_progress: totals.inProgress,
completed: totals.completed,
overdue: totals.overdue,
completion: totals.total === 0 ? 0 : totals.completed / totals.total,
},
},
],
};
}

The overdue rule is readable from the function: a task is not completed, has a due date, and that date is before asOf. The mapper does not read the database, auth context, HTTP request, or Temporal.Now, so the same inputs always produce the same dataset.

A time zone is one of those inputs. A mapper that turns an instant into a day takes the zone as an argument, the same way it takes asOf; the route reads it from the request with workspaceTimeZone(auth) and passes it down. Reading an ambient zone inside the mapper would make the same rows produce two datasets on two machines.

The name remains project-progress; the renderer uses it with the level name for persisted column-sizing identity. The label can vary because it is display text. Render that label as the screen heading above ReportGridDataset.

Percentage values are ratios, so 0.4 renders as 40%. visuallyHidden keeps project_id in each node for link resolvers without displaying it. Hidden data is still present in the response and must already be authorized. Both rowKey and the hidden ID derive from stable domain identity, not the project label or the row’s array position.

displayFormat, textDisplay, zeroDisplay, colorRule, and the width hints describe presentation. They do not replace source numbers, booleans, dates, or timestamps with formatted strings. Widths are approximate displayed-character counts, not pixels; minWidth: 40 is a reasonable starting point for a project name.

colorRule: "negative" on the overdue column paints its figures with --sap-numeric-negative, which follows the error colour --sap-negative unless the application sets it. An overdue count is a problem, so the default fits. A ledger whose negative figures are ordinary money out sets --sap-numeric-negative to its ink colour instead; see Theme tokens and scales.

Wire dates use canonical strings. Decode them to Temporal values at the domain boundary, then use Temporal for comparison and arithmetic. A date column renders as 2026-08-23 and a timestamp column as 2026-08-23 16:38 in the active workspace’s zone, from the same canonical string the response carries.

A report whose numbers depend on a zone names it. ReportTimeZoneNote from @sapporta/frontend/report renders Asia/Kolkata UTC+05:30 in the toolbar, including when the zone is UTC.

A column that shows the name of a table row, such as each line’s account, can declare which row it names with reference: { table, idColumn }. The grid then draws the cell with the table’s label slot; see Name table rows in report cells.

For hierarchical reports, add named child levels, put source values in each node’s columns, put computed parent values in rollup, and attach child arrays through children. Root totals belong in footerRows; totals inside a child collection belong in childFooterRows.

Some rows name a parent row of the same kind. For example, each account in an expense report can name its parent account in parent_id. Child levels would show each depth as a nested grid with its own header. Declare tree on the level instead, so all the accounts form one tree under one header:

import type { GridDataset } from "@sapporta/shared/grid-dataset";
type ExpenseAccount = {
id: number;
parent_id: number | null;
name: string;
// The account's own expenses plus those of its sub-accounts.
amount: number;
};
export function expenseBreakdownDataset(
accounts: readonly ExpenseAccount[],
totalExpenses: number,
): GridDataset {
return {
name: "expense-breakdown",
label: "Expense breakdown",
rootLevel: "account",
levels: {
account: {
columns: [
{
id: "parent_id",
label: "Parent ID",
kind: "number",
visuallyHidden: true,
},
{ id: "account", label: "Account", kind: "text" },
{
id: "amount",
label: "Amount",
kind: "number",
displayFormat: "currency",
},
],
childLevels: [],
tree: { parentColumn: "parent_id" },
},
},
nodes: accounts.map((account) => ({
rowKey: String(account.id),
levelName: "account",
columns: {
parent_id: account.parent_id,
account: account.name,
amount: account.amount,
},
})),
footerRows: [
{
rowKey: "total-expenses",
columns: { account: "Total expenses", amount: totalExpenses },
},
],
};
}

The nodes are one flat list. parent_id holds the parent row’s rowKey, and the two are compared as strings. For this reason, the row key is String(account.id); a prefixed key such as account:12 would not match the parent value 12. A row whose parent is not in the list is shown at top level.

The tree is shown in the first visible text column, account. Set tree.column to show it in another visible column. Rows start expanded, and defaultCollapsed: true on the level starts them collapsed. Sorting a column orders the rows among their siblings at every depth, and the footer row stays below the tree.

The renderer does not add up the amounts, so the mapper passes each account’s amount with its sub-accounts included, and the footer’s total.

A standalone mapper test owns every row and its fixed baseline:

import { gridDatasetSchema } from "@sapporta/shared/grid-dataset";
import { Temporal } from "@sapporta/shared/temporal";
it("maps visible project progress rows deterministically", () => {
const projects = [
{ id: 1, name: "Website relaunch" },
{ id: 2, name: "Mobile refresh" },
];
const tasks = [
{
id: 1,
project_id: 1,
status: "completed" as const,
due_date: Temporal.PlainDate.from("2026-07-01"),
},
{
id: 2,
project_id: 1,
status: "open" as const,
due_date: Temporal.PlainDate.from("2026-07-09"),
},
{
id: 3,
project_id: 1,
status: "in_progress" as const,
due_date: Temporal.PlainDate.from("2026-07-20"),
},
{
id: 4,
project_id: 2,
status: "completed" as const,
due_date: null,
},
{
id: 5,
project_id: 2,
status: "open" as const,
due_date: Temporal.PlainDate.from("2026-07-30"),
},
];
const result = gridDatasetSchema.parse(
projectProgressDataset({
projects,
tasks,
asOf: Temporal.PlainDate.from("2026-07-10"),
}),
);
expect(result.name).toBe("project-progress");
expect(result.label).toBe("Project progress as of 2026-07-10");
expect(result.nodes).toHaveLength(2);
expect(result.nodes[0]).toMatchObject({
rowKey: "project:1",
columns: { project_id: 1, overdue: 1 },
});
expect(result.footerRows?.[0]).toMatchObject({
rowKey: "grand-total",
columns: {
total: 5,
completed: 2,
overdue: 1,
completion: 0.4,
},
});
});

The schema parse validates the complete wire result, including the footer. The focused assertions prove the math, hidden ID, stable row key, explicit date, and ratio semantics. Add hierarchy-specific tests when levels have children; schema validity alone does not prove that a hierarchy is coherent.