Conversation
Collapse the frontend/backend split into one React Router app and replace lerna with a pnpm workspace. Structure: - apps/web React Router 8 framework mode (dashboard + /collect + tracker) - apps/docs Nextra 1 -> 4 (Next 12 -> 16, app router + content dir) - packages/tracker tracker script, esbuild -> apps/web/public/tracker.js Web app: - CRA/react-scripts -> Vite; Chakra v1 -> Tailwind v4 + shadcn (Base UI) - React 17 -> 19, Prisma 3 -> 7 (driver adapter + prisma.config.ts) - SWR/axios hooks -> loaders; the 8 dashboard requests are now one loader, with range/tz in the URL so the view is shareable - Vercel serverless handlers -> loaders/actions. Only /collect and /collect/:id stay HTTP (tracker calls them cross-origin); CORS is scoped to those two routes instead of every response - JWT in localStorage -> signed httpOnly cookie sessions; the client-side route guard becomes a layout loader redirect - Jest/node-mocks-http -> Vitest (38 tests); CI Node 12/14/16 -> 22 Fixes found while porting: - timeseries built SQL via $queryRawUnsafe with wid/tz/dates interpolated; now parameter-bound, with unit/tz allow-listed (bad tz -> 400) - POST /setup never checked whether a user already existed, so anyone could create an account on a live instance; both loader and action check now - DELETE /websites/:id never verified ownership - /collect/:id updated an event by id alone; now scoped to its website Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- remove apps/docs and unwire it from the workspace, root scripts, CI and the Dockerfile - remove the root README and apps/web/README.md (the latter was untouched React Router template boilerplate) - prettier -> oxfmt, config carried over via `oxfmt --migrate=prettier` so the existing style (80 cols, es5 commas, prose wrap) is preserved - add oxlint with typescript/react/import/jsx-a11y/unicorn/vitest plugins; CI gains a lint job running `lint` and `format:check` Fixes surfaced by the new lint pass: - tracker swallowed a caught error into an unused binding - bcryptjs imported via default member access instead of named exports - WebsiteForm used an object literal as a default prop value - timezone validation was duplicated across analytics.server and queries.server; consolidated into app/lib/timezone.ts - analytics test asserted inside a catch block Also add a postinstall running `prisma generate`, so typecheck and tests work on a fresh clone instead of failing on the missing generated client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app/lib/validation.ts held eight schemas that each had exactly one caller. Each now lives next to the code that uses it: - signInSchema -> routes/signin.tsx - setupSchema -> routes/setup.tsx - accountSchema -> routes/account.tsx - collectSchema -> routes/collect.ts - durationSchema -> routes/collect.$id.ts - websiteSchema -> components/website-form.tsx (the create and edit routes both submit that form, so it stays with the form rather than duplicated) Deleted as dead code: metricsFiltersSchema and timeseriesFiltersSchema (both superseded by resolveFilters in analytics.server.ts) and the four unused z.infer type aliases — none had a single reference. validation.test.ts is split into routes/auth.test.ts and routes/collect.test.ts, which stub the db/session modules the route files import at module scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops Prisma 7 for drizzle-orm 0.45 + drizzle-kit 0.31. Since this is a major release with no data to preserve, the old migration history is replaced by a single generated migration that has the fixes baked in rather than patched on. Schema fixes (from the audit of the 2022 Prisma scaffold): - events had no index beyond its primary key, while every dashboard panel filters website_id + a created_at range — ~17 statements per load, each scanning the whole table. Added (website_id, created_at). - website_id and user_id were bare TEXT with no foreign keys, so "Delete Website" removed one row and orphaned the entire event history forever — a retention failure in a product sold on privacy. Both are now FKs with ON DELETE CASCADE, plus an index on websites.user_id for the cascade. - metadata had no unique key, so the ingest path did a serial find-then-create per dimension: a sequential scan up to 6x per pageview, and a race that permanently duplicated rows. Now UNIQUE(type, value, version) with version NOT NULL DEFAULT '' (NULLs would not dedupe), and a single ON CONFLICT upsert. - events.type defaulted to 'pageview' but the only query filtering on type looks for 'pageView', so any row created via the default was counted in the totals and invisible in the Pages breakdown. Default corrected and the payload tightened to a literal. - duration was NOT NULL DEFAULT 0, making "never measured" indistinguishable from "lasted 0ms" and dragging the average down. Now nullable with a CHECK range, and bounded in the payload — /collect is unauthenticated and hands back the event id, so it was forgeable. - timestamps were split between timestamptz(6) and naive timestamp(3). All timestamptz now: the timeseries does created_at AT TIME ZONE $tz, which is only correct over an instant. - the implicit _EventToMetadata join table is now an explicit event_metadata table with a composite primary key. Query layer: the breakdowns and statistics aggregate in Postgres instead of hydrating every matching event row into Node to count it there; the five statistics aggregates collapse into one pass with FILTER. Ingest runs in a transaction. Drizzle has no @default(cuid()) or @updatedat, so ids come from cuid2 and updated_at uses $onUpdate. Deferred (product decisions, reported separately): denormalising dimensions onto events, persisting the tracker's uid as a visitor id, normalising referrer to its host, and the "Country" panel that actually shows language. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of the port surfaced two genuine regressions, both
reproduced against a live Postgres:
- Timeseries buckets were decoded in the server's local timezone. Drizzle's
node-postgres session returns TIMESTAMP columns as raw strings, and a plain
sql`` template uses a no-op decoder, so `date_trunc(...) AT TIME ZONE tz`
arrived as "2026-08-03 14:00:00" and `new Date()` parsed it as local time.
The Prisma adapter pinned it to UTC. On any host not running UTC the chart
matched nothing and flatlined at zero while the stat tiles showed real
totals. Now decoded explicitly as UTC.
- The pg Pool had no 'error' listener. Node throws on an 'error' event with
no handler, so a database restart, failover or idle-connection drop would
take the whole server process down. The Prisma adapter installed one.
Also fixed while in there — pre-existing, not caused by the port: the padded
bucket series was built from whole hours on the server clock plus a zone
offset, which only lines up when the offset is a whole number of hours. For
India, Nepal, Iran, Newfoundland and central Australia every bucket landed on
:30 or :45, matched nothing, and the chart silently read zero. Buckets are now
generated with the same truncation the SQL uses. Verified for Asia/Kolkata,
Asia/Kathmandu, Asia/Tehran and Australia/Adelaide, and across four different
server timezones.
Smaller review findings: metadata.value is now part of a btree unique key and
btree caps an index tuple at ~2704 bytes, while Zod's .max() counts UTF-16
units — a multibyte referrer could pass validation and then abort the ingest
transaction, so free-text fields are bounded by bytes. avg() over double
precision returns a number rather than a string, so the annotation and the
truthiness guard were both wrong. updateUser/updateWebsite kept a Prisma-era
`= {}` default that drizzle rejects with "No values to set".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
App runs locally; only backing service is Postgres. Host port 5434 to avoid clashing with other local Postgres containers. Env example bumped to match, and its stale Prisma comment corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every dimension is a column now; metadata and event_metadata are gone, with them the per-ingest upsert and the two joins every panel paid to group by browser. Adds visitor/session ids, channel + utm, screen class, country, locale, props and revenue, plus check constraints for the four closed sets and a partial unique index on view_token so the duration beacon can only ever rewrite one row. The migration backfills in a single pass and sets fillfactor 80: every pageview is UPDATEd by the duration beacon, and no index touches duration, is_a_bounce or updated_at, so those updates stay HOT. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
60 days of deterministic traffic — sessions, devices, places, sources, custom events with revenue — so the dashboard has something with shape to it and a metric regression is visible rather than plausible noise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Salted HMAC over UTC date + site + IP + user agent, so an id is a daily pseudonym rather than a device id — no cookie, no consent, and it is what "unique visitor" means on the dashboard. Production refuses to boot without its own AURORA_SALT; dev and test keep working unconfigured. The client address comes from the header the deployment names, not from whichever forwarding header the caller felt like setting, and the session window is 30 minutes of inactivity rather than the old client-side 15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three headers a specific edge owns and strips on the way in, plus an opt-in AURORA_COUNTRY_HEADER. No GeoIP database, and no generic names like x-country-code that pass through from the client verbatim. A deployment with no geo-aware proxy reports null, which is supported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Host is reduced to a bare hostname, self-referrals dropped, and the visit resolved once into direct/search/social/referral/campaign so no breakdown query has to carry the host lists. Search hosts match whole — mail., docs. and news.google.com are portals, and reading them as organic search inflates the one number SEO work gets sized off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Token bucket per client, 120/min with 240 burst so queued beacons flushing after a bfcache restore are not punished. LRU-capped at 50k keys: the idle sweep alone cannot bound the map, since nothing admitted in the current window is eligible for eviction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client hints are read pair by pair with the GREASE brand dropped, and the tracker's high-entropy answer wins over both — the ask can never land on a third-party subresource origin. Browser and OS versions are kept to the major and stored per column, so UA reduction hiding a version no longer throws the name away with it. Bots are detected and dropped, and screen class is bucketed from the reported width, which is a different question from the form factor the UA claims. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Origin is checked against the website's own domain, echoed back rather than wildcarded, and `null` is suppressed on both sides so no opaque origin matches. Vary: Origin goes out unconditionally, or a shared cache serves one site's allowance to another. The response also carries the Accept-CH ask, since the beacon is often the only request this origin ever sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split into payload/transport/index with its own vitest suite. No localStorage, sessionStorage, cookie or IndexedDB write anywhere — ePrivacy 5(3) covers a storage key as much as a cookie, and identity and sessionization are the server's job now. SPA navigations are tracked through history patching with a settle window so a mount redirect reads as a correction rather than a second view, duration ships as a beacon keyed by an ephemeral per-view token, and the body stays text/plain so no preflight rides along with an unload flush. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/collect resolves identity, session, acquisition, client and place server side, writes one wide row, and answers 204 with no body. Paths are normalised — hash routes kept, anchors and OAuth fragments collapsed — props and revenue are bounded to scalars, and every client string is repaired of the NUL and lone surrogates Postgres refuses and bounded in bytes rather than UTF-16 units. collect/:id is replaced by a fixed collect/duration keyed on the tracker's per-view token, so no event id ever reaches a third-party origin, and a replayed token is a 204 rather than a 500. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intl still lists 18 pre-2018 tzdata names and canonicalises none of them, so picking Asia/Calcutta from the dashboard 500'd out of the query layer. Each is mapped to its current name — checked to agree to the minute at every month — instead of being filtered out, which would leave India, Ukraine and Argentina with no zone to pick. Adds the zoned day helpers the loader and the chart both need. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One statement per panel over plain columns instead of two joins through event_metadata. Uniques are count(DISTINCT visitor_id) rather than a per-row flag that grew with the window; acquisition panels are scoped to is_new_session, since referrer and channel are properties of an arrival and counting them per pageview understated every referrer in proportion to pages-per-visit — so a panel now carries its unit with its rows. Range predicates are half-open, so the boundary instant stops belonging to both the current and the comparison window, and 0 is a real epoch millisecond again rather than a dropped filter that returned lifetime totals to an anonymous reader. The timeseries arrives padded from the same statement that counts it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
subDays is calendar arithmetic in the host's zone, so "Last 24 hours" measured 23 or 25 hours around a transition and the length of every window was a property of the machine while every bucket was a property of ?tz. The presets are durations ending now, and they are the length their label says: 7 and 30, not the 6 and 29 that were picked to make the chart draw a round number of bars. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Numbers, durations, percentages, buckets, trends, referrers, channels and countries in one place, every one pinned to an explicit locale and zone so the server's render survives hydration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also pulls in Geist Mono for the numerics and links the tracker into web as a dev dependency so the e2e ingest test drives the real script. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three surfaces in a fixed elevation ladder, chart ramp taken from the aurora emission spectrum so chart-1 is the brand hue and a single-series chart is on-brand for free, and a text-eyebrow utility with its own tailwind-merge class group — unknown text-* classes read as colours and one of the pair would have been dropped. Adds the shadcn components the new screens need: sidebar, sheet, popover, calendar, tabs, chart, combobox, empty, item and the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The website list loads once in the app layout, so the switcher and the breadcrumb have it on every screen instead of each route refetching it. Navbar and footer are gone, the collapsed state persists, and the mark is redrawn as a stroked triangle on currentColor so it tracks the theme rather than sitting at a light-mode hex on a dark surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An area chart over the padded series the query returns, with the bucket unit following the window, plus a sparkline for the website list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Grouped panels with per-row bars, an expandable row cap and icons per dimension. Each header reads its unit off the rows, so a panel counting sessions can no longer be labelled views, and the metric hints say what a daily visitor and a bar's basis actually are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Presets plus a custom calendar window, both kept in the URL — a preset stays a name so a shared "last 7 days" link means the last seven days to whoever opens it. Stat tiles carry their trend against the previous window of equal length, and the payload is typed as the shared Breakdowns rather than a local literal that silently dropped six panels and the whole goals list. The public dashboard serialises the site's name and URL only, not the row behind them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list carries 24h visitors, a sparkline and a receiving/idle badge from one overview query instead of being a list of names. Adding a site happens in a sheet from anywhere the sidebar reaches, and the form grew the snippet with a copy button and the share link, which appears the moment the switch is flipped rather than after a save round-trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A brand rail scoped dark in both themes — an aurora needs a night sky — beside the form. Account, signin and setup move onto the field primitives and report success through a toast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vite's scan walks route modules before server-only exports are stripped, so discovery re-optimized mid-navigation and the reload landed while the page was hydrating — rendered, never interactive. Base UI is listed subpath by subpath for the same reason: two module graphs at once means a component reads a React dispatcher that isn't its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Node compiles its zone database in, Postgres reads its image's OS packages, and the dashboard reads both — JS resolves a window's boundaries, SQL groups the buckets inside it. postgres:16 was on 2026b against Node's 2026a while this was written, which puts America/Vancouver an hour out from November onward with nothing on screen to say so. Both images are pinned to the patch and each checks its own copy at build/health time, so drift fails instead of quietly disagreeing. CI moves to the same Postgres pin and gains a Docker build job: the tzdata assertion, the scoped installs and the runner stage's hand-assembled node_modules are invisible to pnpm build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app/components and app/lib were two flat piles; a screen's components, queries and domain logic now sit together under app/modules/<feature>, and the pieces with no owner sit under app/shared. Route files stay put — they are React Router's config surface and their generated ./+types imports are per path — and app/shell holds the chrome that spans features. queries.server.ts is split along the seams it already had: users to auth, websites and the overview to websites, breakdowns/statistics/ timeseries plus the window predicates to analytics. Its websites tests move with it, which is why the pg-pool harness now exists twice. Imports are relative inside a module and aliased across one, so a module folder can be moved without touching its contents. components.json moves with the tree or the next `shadcn add` writes to the old paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shared/ may not import a feature module. Left to convention that holds until the first hurried import, and then "shared" is a junk drawer again. Tests are exempt: asserting a shared formatter's zone behaviour means borrowing the module that defines zones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
collect and collect/duration are the only routes that render nothing: unauthenticated, CORS-answering, and posted to from third-party origins. Grouping them says which surface is public API without touching a URL — routes.ts names every path explicitly, so the file layout is free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its own module, because importing db.server constructs the connection pool: a suite that stubs the pool would have to stub this too, which means mocking away the logic it came to test. /signup needs the same predicate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pair reads as signin/signout now, in the route, the session helper and the URL the sidebar posts to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app-shell.tsx imports AppSidebar, so exporting both shells from it put the authenticated chrome in the public dashboard's module graph — and rollup emitted one 21KB chunk holding both, which the anonymous route pulled behind its own 767 bytes. Verified in the build output: "Add website" and "Account settings" were in the chunk a signed-out reader downloaded. The public route now carries a 1.9KB chunk and a badge, and the sidebar lives only where a session does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The goals panel was importing seven symbols out of breakdown-panel.tsx — the row cap, the expander wording, the truncation note, the column header, the bar explanation — which made one sibling component the other's library and put a 587-line module in the graph between the goals list and two constants. They encode a rule that both lists have to make the identical claim, which is easier to hold in one small file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
collect.duration.ts imported `bounded` and `readPayload` from collect.ts, the only route-to-route edge in the graph. Both schemas move with them, which turned out to be the same problem: a route's non-route exports survive into the client build — React Router only strips loader, action, middleware and headers — so a schema exported from a route is a schema in the browser bundle, and the build refuses a `.server` import until the routes export nothing else. They now export loader and action only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Commit messages become the input to versioning, so they are checked by a commit-msg hook and again in CI — the hook is one `--no-verify` from being skipped, and a squash merge takes the pull request title rather than any commit the hook ever saw, so both are linted. Subjects are all lowercase. release-please reads those commits and maintains a release pull request; merging it bumps the version, writes the changelog, tags, and publishes the GitHub release. `include-component-in-tag` is off so tags stay `vX.Y.Z` rather than becoming `aurora-vX.Y.Z`, and refactor is shown rather than hidden because nearly everything here is one. Publishing keys off that job's output instead of living in a workflow triggered `on: release`. A release created with GITHUB_TOKEN does not fire release, create or tag push events — GitHub suppresses them so workflows cannot trigger themselves — so the obvious wiring would never have run. The Docker gate is now pull requests only, since publish builds the same image on main. `prepare` is `husky || true` because the production install in the Dockerfile has no devDependencies and would otherwise fail on it, and CHANGELOG.md is exempt from oxfmt, which formats markdown and would fight release-please over every release. Versions start at 4.0.0: the 0.x-2.x tags belong to the Next.js project this replaced, and 3.x is skipped so the rewrite gets a clean major.
Both were dropped along with the docs app, leaving the repository with no entry point and an MIT claim in package.json that nothing backed. The readme now carries what the Nextra site used to: configuration, the tracker snippet, and how to run it. AURORA_IP_HEADER gets a paragraph rather than a table row. Unset in production it silently makes visitor, session and bounce figures forgeable, which is the one setting a self-hoster can get wrong without noticing. The license is the same MIT body, word for word, with the title line it was missing and a copyright line naming a holder — the old one read `Copyright (c) 2012-2021` with nothing after it. Extensionless so oxfmt, which formats markdown at 80 columns, cannot reflow the text.
oxlint has flagged this as an error for as long as the rule has been on, so `pnpm lint` exited non-zero on every run — which the new release workflow turns from an annoyance into a block, since release and publish both sit behind the lint job. Removed rather than suppressed. The one other autoFocus in the app, on the range picker's calendar, carries a disable comment because it fires when a popover opens on a click; this one fires on page load, which is the case the rule is actually about.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the 2021 Next.js application entirely. The stack, the database schema,
the tracker and the dashboard are all new, and there is no migration path from a
1.x or 2.x installation.
What changed
shadcn/ui, Recharts, Vitest, oxlint + oxfmt in place of prettier.
surfaced fixed. Events are denormalised into one wide table.
nothing to cookies,
localStorage,sessionStorageor IndexedDB. Visitor andsession ids come from a rotating HMAC instead of a stored identifier.
classified into acquisition channels at write time, country from edge headers
only, client parsed from UA hints before the UA string, rate limiting on the
unauthenticated collect endpoints, and CORS that echoes the caller's origin
rather than allowing
*.timeseries, tabbed breakdown panels and a sidebar shell.
enforced by oxlint rather than by convention.
grouping happens in SQL, so Node's zone database and Postgres's have to agree
or the two halves of a chart disagree by a zone's offset, silently. Each side
now asserts its own copy at build and at startup.
Release pipeline
The last three commits add what was missing entirely: conventional commits
enforced by a hook and by CI, release-please deriving versions and the changelog
from them, and a multi-arch image published to
ghcr.io/askides/auroraon everypush to main and on every release. A README and the MIT LICENSE are restored.
Versions start at 4.0.0 — the 0.x–2.x tags belong to the project this
replaces, and 3.x is skipped so the rewrite gets a clean major.
Expect one red check
The
Commitlintjob lints every commit in the range, and the 39 rewrite commitsbelow predate the rule, so the range check fails here and only here. The PR
title check passes. Every PR opened after this one starts clean.
Merging
Prefer a merge commit over a squash.
release-please-config.jsonpinsbootstrap-shato508d100, which stays reachable through a merge but notthrough a squash. If you do squash, repoint
bootstrap-shaat the resultingcommit before the first release runs.
After merging: tag
v4.0.0, enable Allow GitHub Actions to create and approvepull requests, and make the GHCR package public once it first publishes.