ReviewOS

stacks/reportshq

App shell: report management, viewer, and onboarding

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

Context

The authenticated app around the builder: navigating projects, browsing reports, and reading a published report well. loghq's dashboard.stx / settings / projects/* views and default.stx layout are the structural reference; the visual language comes from #3.

Scope

  • App layout (resources/layouts/default.stx equivalent): sidebar or topbar nav (projects, reports, integrations, settings, billing), user menu, theme toggle, AuthGuard SSR pattern from loghq.
  • Projects index + create/settings pages (settings themselves largely land with #4; this issue owns the shell and navigation coherence).
  • Reports index per project: published/draft states, origin badges (user vs auto-template from #14), create (goes to builder #12), duplicate, archive (soft delete per #9).
  • Report viewer (/reports/{slug}): renders the published snapshot through the chart library (#11) with a global date-range picker and a global filter bar that override block defaults (server round-trip through #10; state in the URL so views are linkable); per-block "explain" popover showing the block's query in plain words; refresh with last-updated stamp.
  • Integration onboarding surface: a project page showing the ingest key, a live "waiting for first event" listener that flips when data arrives (poll or @stacksjs/realtime if it is genuinely cheap), and copy-paste snippets for curl / Stacks (#7) / Laravel (#8).
  • Empty states everywhere a list can be empty (no projects, no reports, no events yet) - designed per stacks-design-taste, each with exactly one obvious next action.
  • Feature tests for the read surfaces (authz on viewer, URL-state filters affect query calls); stacks-browse QA at mobile/desktop, light/dark.

Acceptance

  • From a fresh seeded login: create project, see onboarding, send a curl event, watch it arrive, open a seeded report, change range/filters via URL, all without touching a browser console error. Pre-flight passes.

Depends on #4, #9, #10, #11. Related to #12, #14. Part of #1 (M4 Builder and viewing). Ground rules: see #1.

8 days ago

Partly shipped in 3338e5d, and I am leaving this open for the rest. What is in: the shell, the projects list, the project overview with onboarding, and the report viewer. What is not: the reports index actions (duplicate, archive), the members and settings screens, and the live "waiting for first event" listener.

Every page authenticates before it renders. A page that paints the shell and then discovers the visitor is a stranger has already shown them the shape of somebody's account. The session comes from the cookie via app/Support/session.ts.

That module exists for one reason worth knowing: stx publishes top-level server bindings into the HTML as bridge data as soon as the binding's name appears anywhere in a client script, comments included. A view that read the cookie itself and named the variable token would ship the session credential in the page source of every render. Keeping the read inside sessionFrom(cookies) makes it a local that cannot be published. loghq learned this the hard way and documents it; I inherited the lesson rather than the bug.

The guard also distinguishes "no cookie" from "bad cookie", because the server cannot tell them apart later and bouncing both identically puts anyone whose token expired into a redirect loop.

The viewer is server-rendered. Every block runs through the engine and draws real SVG, so a report is readable before any JavaScript arrives, which is what #16's share links will need anyway. Blocks run sequentially rather than in parallel: the engine reads one SQLite file, and eight concurrent scans is slower than eight in order.

The range lives in the URL, as plain links. Pasting a link shares what you are looking at rather than a page that resolves differently for the reader, and it survives a reload and the back button for free. The value is matched against a closed list and falls back to the report's own default, so a mistyped link still renders the report instead of erroring.

A block whose query fails renders its error in place rather than taking the report with it. That matters most for a template report whose events stopped arriving: seven working blocks and one explaining itself is far better than a blank page.

Verified against the seeded demo project with a real session cookie: 4 big numbers, a line chart with proper axes, a donut with its total in the hole and its legend, and a three-step funnel, no template errors, in both themes.

Two structural notes for whoever continues:

  • The nav and auth guard are partials, not components. stx drops props passed from one component to another (stacksjs/stx#1937), and a partial is included into the page's own scope, so it reads the page's bindings and cannot lose them. Until that upstream bug is fixed, shared chrome in this app should be partials.
  • @include resolves against partialsDir, so it is /var/www/reviewos-reviewos/releases/3e057f9/resources/components/IssueThread.stx:81:3289: include error: Error loading include file AppNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AppNav.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 81, col 3289) Message: Error loading include file AppNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AppNav.stx' Context:  79: 80: > 81:

    Partly shipped in 3338e5d, and I am leaving this open for the rest. What is in: the shell, the projects list, the project overview with onboarding, and the report viewer. What is not: the reports index actions (duplicate, archive), the members and settings screens, and the live "waiting for first event" listener.

    Every page authenticates before it renders. A page that paints the shell and then discovers the visitor is a stranger has already shown them the shape of somebody's account. The session comes from the cookie via app/Support/session.ts.

    That module exists for one reason worth knowing: stx publishes top-level server bindings into the HTML as bridge data as soon as the binding's name appears anywhere in a client script, comments included. A view that read the cookie itself and named the variable token would ship the session credential in the page source of every render. Keeping the read inside sessionFrom(cookies) makes it a local that cannot be published. loghq learned this the hard way and documents it; I inherited the lesson rather than the bug.

    The guard also distinguishes "no cookie" from "bad cookie", because the server cannot tell them apart later and bouncing both identically puts anyone whose token expired into a redirect loop.

    The viewer is server-rendered. Every block runs through the engine and draws real SVG, so a report is readable before any JavaScript arrives, which is what #16's share links will need anyway. Blocks run sequentially rather than in parallel: the engine reads one SQLite file, and eight concurrent scans is slower than eight in order.

    The range lives in the URL, as plain links. Pasting a link shares what you are looking at rather than a page that resolves differently for the reader, and it survives a reload and the back button for free. The value is matched against a closed list and falls back to the report's own default, so a mistyped link still renders the report instead of erroring.

    A block whose query fails renders its error in place rather than taking the report with it. That matters most for a template report whose events stopped arriving: seven working blocks and one explaining itself is far better than a blank page.

    Verified against the seeded demo project with a real session cookie: 4 big numbers, a line chart with proper axes, a donut with its total in the hole and its legend, and a three-step funnel, no template errors, in both themes.

    Two structural notes for whoever continues:

    • The nav and auth guard are partials, not components. stx drops props passed from one component to another (stacksjs/stx#1937), and a partial is included into the page's own scope, so it reads the page's bindings and cannot lose them. Until that upstream bug is fixed, shared chrome in this app should be partials.
    • @include resolves against partialsDir, so it is /var/www/reviewos-reviewos/releases/3e057f9/resources/components/IssueThread.stx:149:2223: include error: Error loading include file AppNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AppNav.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 149, col 2223) Message: Error loading include file AppNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AppNav.stx' Context:  147: 148: > 149:

      Nearly all of this is now done, across db4af1e, 0dd7215 and 4b0e2a4. CI green, 354 app tests plus 32 in the SDK package.

      Reports index. Create (which goes straight into the builder rather than leaving you on a list to find what you just made), duplicate, archive, and restore for anything archived. Cards show origin badges, draft state, and now 'unpublished changes' - a distinction that only started existing when the builder gained a draft in #12.

      A duplicate is always the person's own: origin becomes user and template_key is dropped even when the original was auto-created, or the template engine would treat the copy as the report it had already provisioned and rewrite somebody's duplicate under them on the next version bump.

      Archiving is a soft delete with the archived list right there, so there is no confirmation dialog. A confirm on a reversible action is how people learn to dismiss the confirms on irreversible ones.

      Onboarding now watches for the first event instead of asking somebody to refresh to find out whether their paste worked. Polled, not pushed: the state changes exactly once per project and is only watched during onboarding, so holding a connection open forever to save a handful of requests is the wrong trade. It gives up after ten minutes and skips while the tab is hidden. It also shows the @reportshq/stacks snippet from #7 beside the curl, since those answer different questions - 'integrate my app' and 'is this key even working'.

      Every block in the viewer explains itself. This was the most worthwhile part. A chart is an answer to a question nobody can see; two blocks titled Revenue can count different things, and until now the only way to find out was to open the builder, which a viewer may not be able to do at all. Each block now carries a plain sentence derived from the same stored config the engine runs, so it cannot drift from what is drawn: 'The total value from order placed, daily, compared with the period before.' Funnels describe themselves as funnels. Caveats appear only where there is one - a unique count says it needs a stable user key - because a note on every block is a note nobody reads.

      The published timestamp is shown too, since the numbers in a report and the moment its layout was frozen are different facts and only one of them is on screen.

      That work exposed a card-sizing bug worth recording: the reserved height sat on the card along with h-full, so a card's height came from the grid row and its own content could not lengthen it. Expanding an explanation spilled text over the block below. Flex variants fail the same way from the other direction, because a flex child under-reports its intrinsic height to the row. Reserving the space on the chart area instead makes the card simply as tall as what is in it.

      Still open: the global filter bar. The range picker is there and lives in the URL, so a view is linkable, but a project-wide dimension filter that overrides block defaults is not built. It is the one scoped item left on this issue and it needs its own pass, since it touches the engine's filter merging as well as the URL state.

      One trap for whoever picks that up, now in AGENTS.md: adding an export to an app module is invisible to stx until you clear storage/framework/stx/cache. Uncaught, stx discards the entire server script, so every binding in the view goes blank at once and you get a [Foreach Error] about an unrelated variable, which sends you hunting in completely the wrong place. If several bindings go blank together, suspect the cache before the template.

      150: 151:
       , not /var/www/reviewos-reviewos/releases/3e057f9/resources/components/IssueThread.stx:217:1495: include error: Error loading include file partials/AppNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/partials/AppNav.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 217, col 1495) Message: Error loading include file partials/AppNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/partials/AppNav.stx' Context:  215: 216: > 217:

      Done in 7949889, completing the work from db4af1e, 0dd7215, 0116ebb and a622738. CI green, 486 tests.

      The filter bar was the last scoped item. Narrow a whole report to one plan, one source, one currency, and every block narrows with it.

      The state is the URL and nothing else - no cookie, no session, no server state - because the point is that a view is linkable. Somebody narrowing a report and pasting the URL into a conversation is sharing what they are looking at, not a page that will resolve differently for the reader. It also means the back button works, which is what people reach for after narrowing too far.

      Filters are bounded to the same allowlist a stored block config uses. These arrive from a query string anybody can edit and end up in a query; reusing isAllowedField rather than writing a second check is what stops the two drifting, since the one that drifts is always the one nobody is looking at. A malformed filter is dropped rather than erroring, so a link with one bad entry still shows the report.

      They narrow a block rather than redefining it, so a block built to count refunds keeps counting refunds. A funnel is left alone: its steps are event names rather than a filtered set, and quietly narrowing one would change what the conversion rate measures without saying so.

      The bar offers the keys and values the project actually sends rather than a text box, because a text box means somebody typing "pro" where the data says "Pro" and concluding the product is broken.

      Verified over HTTP: unfiltered shows 60.0k and 431; plan=pro shows 20.5k and 148.

      Everything else on this issue landed earlier: the app shell and nav, the projects index, reports index with create/duplicate/archive/restore and origin and draft badges, the viewer with a linkable range picker, per-block explanations, the published timestamp, the live first-event listener with integration snippets, and empty states throughout.

      Not carried over: the filter bar is on the signed-in viewer only. Share pages have the range picker but not filters; that belongs to #16, which is still open, and it needs a moment of thought about whether a public link should be narrowable at all or only by whoever created it.

      Two bugs found on the way, both now in AGENTS.md. propertyValuesFor returned nothing because its column was aliased value, which the query driver hands back as null while the same SQL is correct in sqlite. And an edit silently did nothing because the line it anchored on had changed, leaving the template referencing an undeclared variable - which stx reports as an unrelated foreach error on a different variable, with the page still returning 200. Any error in a server script blanks every binding in the view that way.

      218: 219:
       . The path form fails silently: the include simply renders nothing, which is how the auth guard was briefly absent from three pages while they looked fine.
    82: 83:
     , not ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx Message: Error loading include file partials/AppNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/partials/AppNav.stx' . The path form fails silently: the include simply renders nothing, which is how the auth guard was briefly absent from three pages while they looked fine.
8 days ago

Nearly all of this is now done, across db4af1e, 0dd7215 and 4b0e2a4. CI green, 354 app tests plus 32 in the SDK package.

Reports index. Create (which goes straight into the builder rather than leaving you on a list to find what you just made), duplicate, archive, and restore for anything archived. Cards show origin badges, draft state, and now 'unpublished changes' - a distinction that only started existing when the builder gained a draft in #12.

A duplicate is always the person's own: origin becomes user and template_key is dropped even when the original was auto-created, or the template engine would treat the copy as the report it had already provisioned and rewrite somebody's duplicate under them on the next version bump.

Archiving is a soft delete with the archived list right there, so there is no confirmation dialog. A confirm on a reversible action is how people learn to dismiss the confirms on irreversible ones.

Onboarding now watches for the first event instead of asking somebody to refresh to find out whether their paste worked. Polled, not pushed: the state changes exactly once per project and is only watched during onboarding, so holding a connection open forever to save a handful of requests is the wrong trade. It gives up after ten minutes and skips while the tab is hidden. It also shows the @reportshq/stacks snippet from #7 beside the curl, since those answer different questions - 'integrate my app' and 'is this key even working'.

Every block in the viewer explains itself. This was the most worthwhile part. A chart is an answer to a question nobody can see; two blocks titled Revenue can count different things, and until now the only way to find out was to open the builder, which a viewer may not be able to do at all. Each block now carries a plain sentence derived from the same stored config the engine runs, so it cannot drift from what is drawn: 'The total value from order placed, daily, compared with the period before.' Funnels describe themselves as funnels. Caveats appear only where there is one - a unique count says it needs a stable user key - because a note on every block is a note nobody reads.

The published timestamp is shown too, since the numbers in a report and the moment its layout was frozen are different facts and only one of them is on screen.

That work exposed a card-sizing bug worth recording: the reserved height sat on the card along with h-full, so a card's height came from the grid row and its own content could not lengthen it. Expanding an explanation spilled text over the block below. Flex variants fail the same way from the other direction, because a flex child under-reports its intrinsic height to the row. Reserving the space on the chart area instead makes the card simply as tall as what is in it.

Still open: the global filter bar. The range picker is there and lives in the URL, so a view is linkable, but a project-wide dimension filter that overrides block defaults is not built. It is the one scoped item left on this issue and it needs its own pass, since it touches the engine's filter merging as well as the URL state.

One trap for whoever picks that up, now in AGENTS.md: adding an export to an app module is invisible to stx until you clear storage/framework/stx/cache. Uncaught, stx discards the entire server script, so every binding in the view goes blank at once and you get a [Foreach Error] about an unrelated variable, which sends you hunting in completely the wrong place. If several bindings go blank together, suspect the cache before the template.

8 days ago

Done in 7949889, completing the work from db4af1e, 0dd7215, 0116ebb and a622738. CI green, 486 tests.

The filter bar was the last scoped item. Narrow a whole report to one plan, one source, one currency, and every block narrows with it.

The state is the URL and nothing else - no cookie, no session, no server state - because the point is that a view is linkable. Somebody narrowing a report and pasting the URL into a conversation is sharing what they are looking at, not a page that will resolve differently for the reader. It also means the back button works, which is what people reach for after narrowing too far.

Filters are bounded to the same allowlist a stored block config uses. These arrive from a query string anybody can edit and end up in a query; reusing isAllowedField rather than writing a second check is what stops the two drifting, since the one that drifts is always the one nobody is looking at. A malformed filter is dropped rather than erroring, so a link with one bad entry still shows the report.

They narrow a block rather than redefining it, so a block built to count refunds keeps counting refunds. A funnel is left alone: its steps are event names rather than a filtered set, and quietly narrowing one would change what the conversion rate measures without saying so.

The bar offers the keys and values the project actually sends rather than a text box, because a text box means somebody typing "pro" where the data says "Pro" and concluding the product is broken.

Verified over HTTP: unfiltered shows 60.0k and 431; plan=pro shows 20.5k and 148.

Everything else on this issue landed earlier: the app shell and nav, the projects index, reports index with create/duplicate/archive/restore and origin and draft badges, the viewer with a linkable range picker, per-block explanations, the published timestamp, the live first-event listener with integration snippets, and empty states throughout.

Not carried over: the filter bar is on the signed-in viewer only. Share pages have the range picker but not filters; that belongs to #16, which is still open, and it needs a moment of thought about whether a public link should be narrowable at all or only by whoever created it.

Two bugs found on the way, both now in AGENTS.md. propertyValuesFor returned nothing because its column was aliased value, which the query driver hands back as null while the same SQL is correct in sqlite. And an edit silently did nothing because the line it anchored on had changed, leaving the template referencing an undeclared variable - which stx reports as an unrelated foreach error on a different variable, with the page still returning 200. Any error in a server script blanks every binding in the view that way.

Sign in to comment on this issue.