Full design · Apps
Product analytics dashboard
A product analytics workspace: an overview of active users and revenue signal with a date range compare, a funnel builder, a weekly retention cohort grid, a searchable raw events explorer, and a drag to build...
Live preview. Click around, it works.
About this template
A product analytics workspace: an overview of active users and revenue signal with a date range compare, a funnel builder, a weekly retention cohort grid, a searchable raw events explorer, and a drag to build dashboard of chart tiles you can also assemble by keyboard. Admin and analyst roles, sample product usage data included and removable in one step.
What the agents are asked
The first message sent when you use this template. You can edit the plan before anything is built.
Adapt this analytics dashboard starting point to my product: keep the six screens (Overview, Funnels, Retention, Events, Dashboards, Settings), the admin and analyst roles, the date range compare, and the drag to build dashboard with its keyboard alternative. Replace the sample product, its event names and properties, the funnel steps and the team names with mine. Keep the sample data clearly marked Sample and removable in one step. Where I want this wired to my real product, send events to the ingest function schema.sql describes instead of the client SDK you use today, keeping row level security on every table and every key on the server.
A product analytics workspace: an overview with a date range compare, a funnel builder, a weekly retention cohort grid, a searchable raw events explorer, and a dashboard you build by dragging chart tiles onto a canvas (or adding them by keyboard). Two roles: an admin can edit dashboards, the team and settings; an analyst can view and explore everything but cannot change tiles or settings.
"Ridgeline" and "Kōura", the fictional budgeting app whose sample activity fills this workspace, are invented. Every person, event and city in the sample data is fictional; email addresses use the reserved .example domain. This template is not modelled on any real analytics product, and it does not reproduce any competitor's layout or visual system.
- Plain HTML, CSS and one ES module. No dependencies, no build step. Fonts: Fragment Mono (headings, large numbers, the wordmark) and Manrope (interface text). See "Art direction" below.
- Light and dark themes follow the device; the icon button in the top bar, or Settings > Appearance, picks one and remembers it. Every colour is a token at the top of
styles.css. - Phone first: a bottom tab bar under 900px, a sidebar above it. The dashboard canvas drops to two columns under 640px; tables and the retention grid scroll sideways in their own container.
- Routing is
location.hash(#/overview,#/funnels,#/retention,#/events,#/dashboards,#/settings), because the catalogue preview runs in a sandbox where the History API throws.
Screens and roles
| Screen | What it shows | Admin | Analyst |
|---|---|---|---|
| Overview | KPI row with a delta badge per metric, active users over time with a compare overlay, top events, a funnel snapshot, a first-visit welcome card | Sets the date range and compare toggle | Same, read only |
| Funnels | Every funnel's step by step conversion and drop-off, with "Add to dashboard" | Adds a funnel chart to a dashboard | Views, cannot add |
| Retention | A weekly cohort grid, one row per signup week, one column per week since signup | Adds the grid to a dashboard | Views, cannot add |
| Events | A searchable, filterable raw event stream with a property inspector, a simulated live feed, keyboard search and row navigation | Same as analyst | Full access, this screen has no admin-only actions |
| Dashboards | A tray of chart blocks you drag onto a canvas (or add with a button), removable with undo, one date range per dashboard | Creates, edits, deletes dashboards and tiles | Views the active dashboard, no tray or remove controls |
| Settings | Workspace name and timezone, appearance, team, sample data, CSV export, demo billing and API key | Full access | Views team and billing, cannot invite, remove or wipe data |
The three functions I am proudest of
- The drag to build dashboard, with a real keyboard path. Every chart block in the tray has an explicit "Add" button as well as being draggable; dropping or clicking adds the tile with a two degree settle (never a full turn) that eases flat within 300ms, and removing a tile offers Undo. Each dashboard keeps its own date range and compare toggle, so a "Retention watch" dashboard can sit at 90 days while "Growth overview" compares the last 30 to the 30 before it.
- The compare snap, working with or without
document.startViewTransition. Switching the date range redraws the current line immediately and the previous period's line fades in behind it as a dashed twin, the same code path whether or not the browser supports view transitions and whether or not the tab is visible; nothing depends on the transition actually playing; everystartViewTransitioncall has its.readypromise caught, so a skipped transition (a hidden tab, another one already running) never becomes an uncaught error in the console. - Sample data that is never stored. Ridgeline generates its ninety day sample world (roughly 260 people, several thousand events, a real funnel drop-off and a real retention curve) from one seed function on every load, and only ever persists what the visitor actually does: their invited teammates, their own dashboards, their settings. A
sampleVersionbump changes the generator without ever leaving a stale row inlocalStorage, and "Remove sample data" in Settings is a single flag that empties every screen to its real, honest, connect-your-product state, reversible with an Undo toast.
Other depth: real search and multi-filter on Events (query, event name, date range), j/k row navigation and / to focus search, a g then letter chord to jump screens, an inline two-step confirm for destructive actions (no confirm()), a CSV export whose content is verified by reading the generated text in a preview panel rather than by downloading a file, and empty, loading and error states throughout (a skeleton while events "load", a clear-filters empty state, an honest empty overview once sample data is removed).
Motion
One orchestrated moment, "the compare snap": dropping a tile settles it with a two degree rotation eased flat within 300ms; changing the date range redraws the current series, then fades in the comparison series a beat behind it, while every visible delta badge counts from zero to its value. Route changes use document.startViewTransition when it exists and the tab is visible, with .ready.catch(() => {}) so a skipped transition (hidden tab, another one in flight) never surfaces as a console error; everything falls back to an instant, complete redraw otherwise, so nothing is ever left mid-transition or waiting on an animation that will not run. Scroll reveals use animation-timeline: view() inside @supports, with an IntersectionObserver fallback at a 0.2 threshold and a hard setTimeout fallback so content already parked at opacity 0 is never left invisible if the observer never fires. All of it is off under prefers-reduced-motion.
Photography
None. This is an app whose screens are charts, tables and a drag and drop canvas; every visual (the line chart, the bar chart, the retention grid, the funnel bars, the sparklines) is inline SVG drawn from the sample data at media.json's direction (zero images), matching the template plan for this slug.
Sizes
script.js is one file across ten clearly commented sections (constants, utilities, storage, the sample world generator, the store, derived analytics, chart rendering, screens, shell, init()), written and appended in parts so no single write stalled. styles.css is tokens first, then layout, then components. Neither file was measured against a live browser's network panel; both stayed comfortably under the 512KB per file limit check-template.mjs enforces.
Data model and where the real backend goes
schema.sql defines seven tables behind row level security, matching the two-role split above:
workspaces,workspace_members(admin manages the team; a trigger keeps at least one admin).product_users,events: the tracked product's own end users and their raw events. Nobody's browser ever gets an insert or update grant on either table; every write goes throughingest_event(workspace_id, ingest_key, external_id, name, properties), asecurity definerfunction keyed on the workspace's owningest_key, not on anything a caller types about another person. Calling it twice with the sameexternal_idinserts two separate events under the oneproduct_usersrow and only bumpslast_seen_at; it can never overwrite another person's row, because the row it touches is always the caller's own external id (reasoned through in the VERIFY block at the foot ofschema.sql; not run against a live Postgres in this session, since builders do not have database access by design).funnels,dashboards,dashboard_tiles: admin-writable, member-readable.
script.js keeps the same shape in one object (the module-level meta plus the generated world), so wiring this to Supabase means: point the ingest calls a real SDK already makes at ingest_event, replace generateWorld() and the compute* functions with queries against events/product_users filtered by the same date ranges, and keep reading dashboards/dashboard_tiles the same way. Keys stay on the server; the ingest_key is a write credential for a tracking SDK, not a browser secret, and is never selected back to an ordinary team member (revoke select (ingest_key) ... from authenticated). Admins read it with get_ingest_key(workspace_id) and replace it after a leak with rotate_ingest_key(workspace_id); both refuse anyone who is not an admin of that workspace.
Today this template is demo only: script.js has no Supabase mode, no CONFIG.backend and reads and writes localStorage exclusively. schema.sql is ready to apply on its own for a customer who wants a real Postgres backend behind a custom frontend, but wiring script.js to it (the ingest SDK call, the queries above, and the sign in screen below) is separate work, not yet done here.
Going live: the first admin, and adding a team member
This template ships as one analytics workspace per deployed customer (a back office, not a multi-tenant SaaS), even though the schema is technically multi-workspace. Nothing in the schema ever sets workspace_members.user_id by itself, so on a real Supabase project the first person in needs a deliberate step, not a row set by hand in the SQL editor (docs/ops/LESSONS.md L60):
- In Supabase, Authentication > Providers > Email, turn on Confirm email. Every function below trusts
auth.users.email_confirmed_at, not the sign up form, so with confirmation off anyone could sign up as someone else's invited address and claim their seat. - The first person signs in with a confirmed email and calls
start_as_admin('Their Name'). It joins the one workspace the seed created, refuses if a real (non sample) admin already exists or the caller is already a member, and becomes that workspace's admin. - To add a teammate later, an admin inserts a
workspace_membersrow withworkspace_id,name,emailandrole(user_idstays null, that is the invite). That person signs in with the same, confirmed email address and callsclaim_invite(), which links their account to the row an admin already created. Calling it again, or from an already linked account, is refused rather than silently repeated.
Both functions live in schema.sql (public.start_as_admin, public.claim_invite, and the public.nova_confirmed_email() helper they share) and are granted to authenticated only, never anon. Neither is called anywhere in script.js yet, because there is no Supabase mode to call them from; a build that wires this template to a live backend calls claim_invite() after sign in and, when that finds no row and no admin exists yet, offers "Start as admin" instead.
Honesty
Ridgeline is an original design; "Kōura" is a fictional product invented for this sample data. Billing and the ingest key are both labelled "Demo" and explain where the real connection goes. No review, certification or customer claim appears anywhere in the template.