Skip to content

Route-based reports

View as Markdown

A Sapporta report is an app-owned API route plus a React screen. This page builds the project-progress report from its shared contract through its protected UI. You will learn how the route, scoped read, pure dataset mapper, typed client, URL filters, and renderer fit together. The same structure supports ledgers, aging reports, operational summaries, and other read-only projections.

Build a protected project-progress report in this Sapporta app. Use a shared GET contract, scope projects and tasks on the server, map a GridDataset in a pure function, add a typed client and protected screen, keep filters in the URL, and prove the mounted route and dataset shape.

Compact, shareable filters fit a GET query. Large nested report input can use a POST body instead. Create packages/shared/src/contracts/project-progress.ts and re-export it from the shared contracts index:

import { initContract } from "@sapporta/rest-core";
import { errorBodySchema } from "@sapporta/shared/contracts";
import { gridDatasetSchema } from "@sapporta/shared/grid-dataset";
import { z } from "zod";
const c = initContract();
export const projectProgressQuerySchema = z.object({
project_id: z.coerce.number().int().positive().optional(),
});
export type ProjectProgressQuery = z.output<typeof projectProgressQuerySchema>;
export const projectProgressContract = c.router({
projectProgress: c.query({
method: "GET",
path: "/reports/project-progress",
summary: "Project progress",
metadata: { tags: ["reports"] },
query: projectProgressQuerySchema,
responses: {
200: gridDatasetSchema,
400: errorBodySchema,
401: errorBodySchema,
403: errorBodySchema,
},
}),
});

The contract path omits /api. packages/api/app.ts mounts app routes under that prefix.

Create packages/api/app/project-progress.ts. The route resolves permission and workspace data authority, reads only visible base rows, and hands ordinary objects to a pure mapper:

import { eq } from "drizzle-orm";
import { Temporal } from "@sapporta/shared/temporal";
import { TsRestApi, type SapportaEnv } from "@sapporta/server";
import { projectProgressContract } from "task-app-shared";
import { requireAuthorizedWorkspaceData } from "../project-auth/index.js";
import { projects, projectsTable } from "../schema/projects.js";
import { tasks, tasksTable } from "../schema/tasks.js";
import { projectProgressDataset } from "../modules/reports/project-progress.js";
const api = new TsRestApi<SapportaEnv>();
api.register(
"projectProgress",
projectProgressContract.projectProgress,
async ({ c, request }) => {
const auth = requireAuthorizedWorkspaceData(c, {
action: "read",
subject: "project_progress",
});
const db = c.get("db");
const projectAccess = auth.rowSecurity.forTable(projects);
const taskAccess = auth.rowSecurity.forTable(tasks);
const projectId = request.query.project_id;
const visibleProjects = await db
.select()
.from(projectsTable)
.where(
projectAccess.ownedRows(
projectId === undefined ? undefined : eq(projectsTable.id, projectId),
),
);
const visibleTasks = await db
.select()
.from(tasksTable)
.where(
taskAccess.ownedRows(
projectId === undefined
? undefined
: eq(tasksTable.project_id, projectId),
),
);
return {
status: 200,
body: projectProgressDataset({
projects: visibleProjects,
tasks: visibleTasks,
today: Temporal.PlainDate.from("2026-07-10"),
}),
};
},
);
export default api;

Grant signed-in workspace members read on project_progress in packages/api/authz/ability.ts. Then mount the sub-app:

import projectProgressApi from "./app/project-progress.js";
export function loadApp(app: TsRestApi<SapportaEnv>, options: LoadAppOptions) {
app.route("/", projectProgressApi);
app.extend(projectProgressApi);
// Keep the existing route mounts here.
}

route() mounts the Hono handlers. extend() adds the sub-app’s contract emitters to the combined OpenAPI document.

The frontend imports the same contract:

import { createApiClient } from "@sapporta/shared/client";
import { getApiBase } from "@sapporta/frontend/platform";
import { projectProgressContract } from "task-app-shared";
export const projectProgressApi = createApiClient(projectProgressContract, {
baseUrl: getApiBase,
});

Keep the selected project in useSearchParams, call projectProgressApi.projectProgress({ query }), and render the successful body with ReportGridDataset:

<ReportScreenFrame
title="Project progress"
subtitle="Task status and overdue work by project."
>
<ReportToolbar>{/* project filter and Run button */}</ReportToolbar>
{dataset ? (
<ReportGridDataset
dataset={dataset}
links={projectProgressLinks}
linkContext={{ input: query }}
/>
) : null}
</ReportScreenFrame>

Register /reports/project-progress in appProtectedRoutes and add a navigation item. The URL query is the report state, so a filtered result can be reloaded, bookmarked, or shared with another authorized user.

Terminal window
pnpm build
pnpm exec sapporta endpoints show "GET /api/reports/project-progress"
pnpm exec sapporta api get /api/reports/project-progress
pnpm exec sapporta api get /api/reports/project-progress --query '{"project_id":1}'

The unfiltered canonical dataset contains two projects, five tasks, two completed tasks, one overdue task, and 40% completion. The filtered call contains only the selected project’s visible rows. Parse route-test responses with gridDatasetSchema so the API cannot drift from the renderer contract.

The report is now a normal typed application slice: OpenAPI describes its mounted route, auth protects its data, a pure mapper owns its wire result, and React owns filter state and navigation. Keep each additional report in its own vertical slice unless two reports genuinely share domain query logic.