ReviewOS

stacks/reportshq

Auto-created reports: template engine

#14
Closed chris opened this 8 days ago ·
8 days ago

Context

The headline promise: integrate a best-practices Laravel or Stacks app (#7, #8) and reports appear on their own. A template registry maps the standard event taxonomy (docs/events.md, #6) to prebuilt report definitions; when a project first receives matching events, the matching reports are provisioned automatically.

Scope

  • Template registry app/Reports/templates/: typed report definitions (name, blocks with query + viz + layout, required event names, minimum property requirements), versioned with a template_key + version. Ship at minimum:
    • Commerce overview: revenue over time, orders, AOV, refund rate, top products, conversion funnel (commerce.*).
    • Customers: new customers, repeat rate, revenue per customer cohort (needs user_key on commerce events).
    • Users: signups over time, DAU/WAU/MAU from user.login/activity, simple weekly retention grid (heatmap block).
    • Content (if cms.* events present): posts published, views by post if provided.
  • Provisioning: a queued job (triggered from ingest on first-seen event names, debounced) instantiates matching templates as Report rows with origin: 'template' + template_key (#9) - idempotent (never re-create one the user deleted; store a per-project template ledger, as a model).
  • User control: auto-created reports are fully editable (editing forks them to origin: 'user' semantics - decide and document the exact fork/update behavior); a project setting disables auto-creation; deleting is respected forever.
  • Template upgrades: bumping a template version must not clobber user edits; untouched template reports may offer a visible "update available" refresh.
  • Templates render correctly against partial data (missing optional events degrade blocks to their designed empty states, #11).
  • The builder's empty state (#12) offers these same templates as manual starting points.
  • Feature tests: first-event provisioning, idempotency after delete, debounce under a burst of first events, version-bump behavior.

Acceptance

  • Point the demo Laravel app (#8) or a seeded Stacks emitter (#7) at a fresh project: the Commerce and Users reports appear within a minute, render with real numbers, and deleting one keeps it deleted after more events arrive.

Depends on #6, #9, #10; exercised by #7, #8. Part of #1 (M5 Integrations and auto-reports). Ground rules: see #1.

8 days ago

Shipped in 8148d3e. 250 tests, CI green.

Four templates keyed on the taxonomy in docs/events.md: Commerce overview, Customers, Users and Content. Every block is written against documented event names, so a template and the SDKs that feed it cannot drift: if a template needs a property, the taxonomy has to document it first.

Idempotent, and permanently so. This is the part worth reviewing. The check looks for a report carrying the template key including soft-deleted ones, which makes reports.template_key the ledger and means there is no second table to keep in step with it. Someone who deletes the Commerce overview has said they do not want it, and re-creating it on the next order would be the product arguing with them.

A template is offered only when all of its required events have arrived, not any of them. A Commerce overview built from orders alone shows four empty blocks and reads as broken rather than as waiting, which is a worse first impression than no report at all.

Reports are published immediately. One that appeared on its own and then asked to be published would be a chore nobody asked for; the whole promise is that it is ready when you look.

Provisioning runs on a schedule, not from the ingest path. Ingest is the one request that must stay cheap, and this reads every distinct event name in a project and then writes a report with a dozen blocks. Doing that inside a batch would make the first event of a busy morning the slowest one. A report appearing a few minutes after the first order reads exactly as well as one appearing 200ms after it, and the job is cheap enough to run every ten minutes because provisioning is idempotent.

The tests worth reading are the ones about restraint, since the failure modes here are all "the product does something the person did not ask for":

  • a deleted template report stays deleted even as more qualifying events arrive
  • running provisioning twice creates nothing the second time
  • a project can turn auto-reports off, and that is checked before any work is done
  • only the templates whose events exist are created (a project sending signups gets Users, not Commerce)
  • a second template appears later without disturbing the first

There are also two structural tests: every shipped block passes the engine's own validation, and no two blocks in a template overlap on the grid. A template is JSON in the repository that nothing validates at write time, unlike a builder edit; without those, the first person to discover a malformed template would be a customer whose auto-report renders an error.

And one that proves the actual promise rather than the mechanism: provisioning against a project with two orders totalling 100 produces a Revenue block whose query returns 100. The report is right the first time it is looked at.

Not done here: template versioning in the upgrade sense. template_version is stored, so a bumped template can be detected, but nothing yet offers "update available" on an untouched template report or protects an edited one. That wants the fork-versus-update semantics discussed in this issue, and it is a smaller piece now that the ledger and the provisioning path exist.

8 days ago

Correction to the comment above: CI was red on 8148d3e, not green. app/Reports/templates.ts had an unused options parameter that pickier rejects. Fixed in a783e58, and CI is green there.

Worth recording why local lint missed it, because it was not carelessness and it will happen to the next person: buddy lint discovered files with git ls-files, which lists tracked files only. templates.ts was brand new and unstaged when I linted it, so lint reported the project clean without ever opening the file. One commit later it was tracked, and CI read it for the first time.

Fixed at the source in stacksjs/stacks@064e38b: discovery now passes --others --exclude-standard, so untracked files are linted and .gitignore is still honoured, with tests covering new files, staged-and-modified files not being listed twice, ignored paths, and a non-git directory. Until that reaches this app's installed @stacksjs/actions, bunx pickier lint . is the accurate local check.

8 days ago

Done in a5f0df9 (and 8148d3e / a783e58 before it). CI green, 260 tests.

What ships

Four templates, defined against the taxonomy in docs/events.md so a template and the SDKs that feed it cannot drift: commerce.overview, customers, users, content. Each declares the events it requires, and all of them must have been seen, not any: a Commerce overview built from orders alone shows four empty blocks and reads as broken rather than as waiting.

Provisioning is permanent, not just idempotent. The check for 'already offered' includes soft-deleted reports. Somebody who deletes the Commerce overview has said they do not want it, and re-creating it on the next order would be the product arguing with them. reports.template_key is the ledger, so there is no second table to keep in step.

Reports are published immediately. One that appeared on its own and then asked to be published would be a chore nobody asked for.

Version upgrades. A template carries a version, and now something acts on it. An untouched report is rewritten onto the current template; an edited one never is. 'Edited' is read from the revision log, counting only the reasons the builder writes (autosave, restore). The engine files its own writes under a new upgrade reason, which is why the first upgrade does not make a report look edited and freeze every later one; that was the trap in the obvious 'count the revisions' version. The reason is a widened enum on the ReportRevision model, with the migration generated from it, no hand-written SQL.

The previous layout is snapshotted before blocks are replaced. The upgrade is the engine's judgement rather than the person's, and an automatic change that cannot be undone is one nobody agreed to make permanent. Edited reports stay listed by upgradableReports() with automatic: false, so a person can see why theirs differs from a colleague's and upgrade it deliberately via force.

Where it runs. ProvisionReports every ten minutes, not on the ingest path. Ingest is the one request that must stay cheap, and provisioning reads every distinct event name in a project and then writes a report with a dozen blocks. A report that appears within a few minutes of the first order reads as magic; one that appears 200ms later reads exactly the same.

23 tests in tests/feature/templates.test.ts, including a static pass over the shipped templates themselves (every block would be accepted by the engine, nothing overlaps, nothing leaves the 12-column grid), because a template ships as data and nothing else validates it at write time. The first person to find a malformed one would otherwise be a customer looking at an error.

Sign in to comment on this issue.