diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 00000000..7e678f95 --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "subject-case": [2, "always", "lower-case"] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..dd7bd43d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +**/node_modules +**/build +**/.next +**/.react-router +**/app/generated +.git +.github +*.md +**/.env diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml deleted file mode 100644 index 8edd8c86..00000000 --- a/.github/workflows/backend.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Backend CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: "./packages/backend" - - strategy: - matrix: - node-version: [12.x, 14.x, 16.x] - - services: - # Label used to access the service container - postgres: - # Docker Hub image - image: postgres - # Provide the password for postgres - env: - POSTGRES_DB: aurora - POSTGRES_USER: root - POSTGRES_PASSWORD: password - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready --health-interval 10s --health-timeout 5s - --health-retries 5 - ports: - # Maps tcp port 5432 on service container to the host - - 5432:5432 - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - cache-dependency-path: "./packages/backend/package-lock.json" - - run: npm ci - - run: npm run build --if-present - - run: npm run db:migrate - env: - DATABASE_URL: postgres://root:password@localhost:5432/aurora - - run: npm test - env: - JWT_SECRET: impossibletoguess - DATABASE_URL: postgres://root:password@localhost:5432/aurora diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5d56da8f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,235 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Narrow by default; the two jobs that need more declare it themselves. +permissions: + contents: read + +# There is no commit to hook here, and the commitlint job below is the real +# enforcement point anyway. +env: + HUSKY: 0 + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm format:check + + # The commit-msg hook is one `--no-verify` away from being bypassed, and it + # never sees the message that actually lands: squash merges are enabled with + # `squash_merge_commit_title: COMMIT_OR_PR_TITLE`, so a multi-commit squash + # takes the *pull request title* as the subject on main. Both are checked. + commitlint: + name: Commitlint + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + # commitlint walks the base..head range, which shallow clones lack. + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Commits + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: pnpm exec commitlint --verbose --from "$BASE_SHA" --to "$HEAD_SHA" + + # Through the environment, never interpolated into the script: a pull + # request title is attacker-controlled text on a public repository. + - name: Pull request title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: echo "$PR_TITLE" | pnpm exec commitlint --verbose + + web: + name: Web + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + + env: + DATABASE_URL: postgres://root:password@localhost:5432/aurora + SESSION_SECRET: impossibletoguess + + services: + postgres: + # Pinned to the patch for the reason docker-compose.yml gives at length: + # Postgres reads zones from its image's own OS tzdata package, so a + # floating `postgres:16` picks up whichever release the latest rebuild + # carried. It resolves to 16.14 / tzdata 2026b today, one release ahead + # of the 2026a the Dockerfile asserts against `process.versions.tz` and + # docker-compose's healthcheck refuses to start without. The + # timezone-sensitive suites are the ones that run here, so this was the + # one place the two zone databases were never checked against each + # other. Move this with the pin in docker-compose.yml. + image: postgres:16.13 + env: + POSTGRES_DB: aurora + POSTGRES_USER: root + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + working-directory: . + + - run: pnpm db:migrate + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build + + # The deployment path, which nothing else exercises. `pnpm build` covers the + # app's own compilation, but the Dockerfile carries logic that only ever runs + # here: the base stage's tzdata assertion, the `--filter web... --filter + # tracker...` scoped installs, the `--prod` install, and the runner stage's + # hand-assembled node_modules copy. A dependency that only resolved because a + # dev dependency hoisted it, or a file the runner stage forgets to copy, is + # invisible to every step above and surfaces at deploy time. + # + # Pull requests only: on main the publish job below builds the same image for + # real, so running this too would build it twice. + docker: + name: Docker + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + # Build only — the image is never pushed from a pull request. This is a + # gate, not a release step. Its own cache scope, because it builds one + # architecture where publish builds two. + - uses: docker/build-push-action@v6 + with: + context: . + push: false + cache-from: type=gha,scope=gate + cache-to: type=gha,mode=max,scope=gate + + # Opens (and maintains) a release pull request from the conventional commits + # on main; merging that pull request is what bumps the version, writes + # CHANGELOG.md, tags, and publishes the GitHub release. Gated on lint and web + # so a red main cannot produce a release. + release: + name: Release + if: github.event_name == 'push' + needs: [lint, web] + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + issues: write + + outputs: + release_created: ${{ steps.release-please.outputs.release_created }} + tag_name: ${{ steps.release-please.outputs.tag_name }} + + steps: + # No `release-type` input: omitting it is what makes the action read + # release-please-config.json and .release-please-manifest.json. + - uses: googleapis/release-please-action@v4 + id: release-please + with: + token: ${{ secrets.GITHUB_TOKEN }} + + # Publishing lives here, keyed off the job above's output, rather than 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 simply + # never run. Reading `release_created` in the same workflow avoids needing a + # personal access token or a GitHub App just to break that loop. + publish: + name: Publish + if: github.event_name == 'push' + needs: [release] + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + # linux/arm64 is emulated, so its install and build stages are slow on a + # cold cache. If that becomes the bottleneck, the repository is public and + # therefore has free ubuntu-24.04-arm runners: split into a per-platform + # matrix that builds by digest and merge with `buildx imagetools create`. + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # `edge` and `sha-` land on every push to main; the version tags only on + # the push that merged a release pull request. Every versioned entry is + # gated on `release_created` rather than on tag_name being empty, so no + # entry is ever handed a value to parse that isn't a tag: on an ordinary + # push a `type=semver` with no value falls back to github.ref, which is + # `refs/heads/main`. + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=edge + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}},value=${{ needs.release.outputs.tag_name }},enable=${{ needs.release.outputs.release_created == 'true' }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.tag_name }},enable=${{ needs.release.outputs.release_created == 'true' }} + type=semver,pattern={{major}},value=${{ needs.release.outputs.tag_name }},enable=${{ needs.release.outputs.release_created == 'true' }} + type=raw,value=latest,enable=${{ needs.release.outputs.release_created == 'true' }} + + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + # Carries org.opencontainers.image.source, which is what links the + # package to this repository and inherits its visibility. + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=publish + cache-to: type=gha,mode=max,scope=publish diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml deleted file mode 100644 index abb6f229..00000000 --- a/.github/workflows/frontend.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Frontend CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: "./packages/frontend" - - strategy: - matrix: - node-version: [12.x, 14.x, 16.x] - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - cache-dependency-path: "./packages/frontend/package-lock.json" - - run: npm ci - - run: npm run build --if-present - - run: npm run test:ci diff --git a/.gitignore b/.gitignore index 8a392a11..f0340150 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,18 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules -/.pnp +node_modules/ +.pnp .pnp.js # testing -/coverage +coverage/ -# next.js -/.next/ -/out/ - -# production -/build +# builds +build/ +dist/ +out/ +.react-router/ # misc .DS_Store @@ -23,8 +22,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* # local env files +.env .env.local .env.development.local .env.test.local @@ -32,4 +33,3 @@ yarn-error.log* # vercel .vercel -.env diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..2e6b87e2 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm exec commitlint --edit "$1" diff --git a/.prettierrc b/.oxfmtrc.json similarity index 69% rename from .prettierrc rename to .oxfmtrc.json index 931bc689..80040030 100644 --- a/.prettierrc +++ b/.oxfmtrc.json @@ -1,19 +1,20 @@ { + "$schema": "./node_modules/oxfmt/configuration_schema.json", "arrowParens": "always", "bracketSameLine": false, "bracketSpacing": true, "embeddedLanguageFormatting": "auto", "htmlWhitespaceSensitivity": "css", - "insertPragma": false, "jsxSingleQuote": false, "printWidth": 80, "proseWrap": "always", "quoteProps": "as-needed", - "requirePragma": false, "semi": true, "singleQuote": false, "tabWidth": 2, "trailingComma": "es5", "useTabs": false, - "vueIndentScriptAndStyle": false + "vueIndentScriptAndStyle": false, + "sortPackageJson": false, + "ignorePatterns": ["CHANGELOG.md"] } diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..73faee06 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,85 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "typescript", + "unicorn", + "oxc", + "react", + "import", + "jsx-a11y", + "vitest" + ], + "categories": { + "correctness": "error", + "suspicious": "warn", + "perf": "warn" + }, + "env": { + "builtin": true, + "browser": true, + "es2024": true + }, + "ignorePatterns": [ + "**/build/**", + "**/.react-router/**", + "**/public/tracker.js" + ], + "rules": { + // The automatic JSX runtime is used everywhere; React needn't be in scope. + "react/react-in-jsx-scope": "off", + // CSS and font side-effect imports are intentional. + "import/no-unassigned-import": "off", + // role="img" on inline is the correct accessible pattern here. + "jsx-a11y/prefer-tag-over-role": "off", + // Prisma's aggregate API is _count/_avg/_all; renaming isn't an option. + "no-underscore-dangle": "off", + // Server code legitimately awaits sequentially (e.g. metadata upserts that + // must not race each other within one request). + "no-await-in-loop": "off", + // Building a new object per item is the clearer form for these mappings. + "oxc/no-map-spread": "off" + }, + "overrides": [ + { + // The direction the layout depends on: features may reach into shared, + // shared may never reach back. Enforced rather than documented, because a + // single import the other way is all it takes for "shared" to go back to + // meaning "whatever nobody moved". + "files": ["apps/web/app/shared/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["~/modules/*", "~/modules/**", "~/shell/*"], + "message": "shared/ must not depend on a feature module. Move the shared part down, or the importing file up into the module." + } + ] + } + ] + } + }, + { + // Vendored shadcn/ui components — regenerated by the CLI, not hand-edited. + "files": ["apps/web/app/shared/ui/**"], + "rules": { + "react/no-array-index-key": "off", + // Generic primitive: htmlFor is supplied by each call site. + "jsx-a11y/label-has-associated-control": "off" + } + }, + { + "files": ["**/*.test.ts", "**/*.test.tsx"], + "rules": { + // A suite may reach across the boundary its subject may not: asserting + // a shared formatter's zone behaviour means borrowing the module that + // defines zones. + "no-restricted-imports": "off", + "typescript/no-explicit-any": "off", + // vi.mock factories return plain stubs; type params add noise, not safety. + "vitest/require-mock-type-parameters": "off" + } + } + ] +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..e6f87756 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "4.0.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c5fb6e63 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +## [4.0.0](https://github.com/askides/aurora/compare/v2.0.1...v4.0.0) (2026-08-05) + +A ground-up rewrite. Nothing is carried over from the 2.x Next.js application — +the stack, the database schema, the tracker, and the dashboard are all new, and +there is no migration path from a 2.x installation. + +There is no 3.x: the number was skipped so the rewrite starts on a clean major. + +### ⚠ BREAKING CHANGES + +- The event schema is denormalised into a single wide table. A 2.x database + cannot be upgraded in place. +- Prisma is replaced by Drizzle; `prisma migrate` is replaced by + `pnpm db:migrate` (drizzle-kit). +- The tracker's wire format changed. Re-copy the snippet from the dashboard — + a 2.x `tracker.js` will not report to a 3.x collector. + +### Features + +- Rewritten storage-free tracker in TypeScript: no cookies, no `localStorage`. +- Visitor and session ids derived from a rotating HMAC rather than stored + identifiers. +- Ingest rebuilt around sessionization and a duration token, with referrers + classified into acquisition channels at write time. +- Country resolved from edge headers only; client parsed from UA hints first, + falling back to the UA string. +- Rate limiting on the unauthenticated collect endpoints, and CORS that echoes + the caller's origin instead of allowing `*`. +- Dashboard rebuilt around range and timezone pickers, a Recharts timeseries, + tabbed breakdown panels, and a sidebar shell on an aurora palette. +- Website list with per-site numbers and a sheet for adding sites. + +### Refactoring + +- Migrated to React Router v8 on a pnpm workspace with Tailwind and shadcn. +- Reorganised the app into feature modules over a shared layer, with the + shared-to-modules direction enforced by oxlint. +- Replaced Prisma with Drizzle and fixed the schema defects that surfaced. +- Query layer rebuilt on the wide events table; preset windows measured in + milliseconds rather than calendar days. +- Replaced prettier with oxfmt + oxlint. + +### Bug Fixes + +- Pinned both tzdata copies — the Node base image and the Postgres image — and + assert them at build time, so the JS and SQL halves of a chart cannot disagree + about a zone's offset. +- Substituted the legacy timezone names Postgres rejects. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..768505c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Build context is the repository root: the web app is a pnpm workspace member +# and needs the root lockfile plus packages/tracker to build. +# +# Pinned to the patch, not `node:22`, because the tzdata release is a property +# of the Node version: it is compiled in, so this tag fixes `process.versions.tz` +# exactly, where the database's copy comes from its image's OS packages and can +# move under a tag that never changed. See docker-compose.yml. +FROM node:22.23.2-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable +WORKDIR /app + +# The zone database this image is built against, asserted rather than assumed. +# +# The dashboard resolves a window's boundaries in JS and groups the buckets +# inside it in SQL (app/lib/timezone.ts, getWebsiteViewsTimeSeries), so the two +# tzdata releases have to agree about a zone or the two halves of one chart +# disagree by that zone's offset — with nothing on screen to say so, months +# after whichever image moved. Held to the value docker-compose.yml pins the +# Postgres image to; bumping the base image above without bumping both fails +# here, at build, instead of at some transition next November. +ARG AURORA_TZDATA=2026a +RUN node -e 'const want = process.env.AURORA_TZDATA, got = process.versions.tz; if (want !== got) { console.error(`tzdata mismatch: node ${process.version} ships ${got}, this image is pinned to ${want}. Match AURORA_TZDATA here and in docker-compose.yml to a release both images carry.`); process.exit(1); }' + +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/ +COPY packages/tracker/package.json packages/tracker/ +RUN pnpm install --frozen-lockfile --filter web... --filter tracker... + +FROM deps AS build +COPY . . +RUN pnpm --filter web build + +FROM base AS prod-deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/ +COPY packages/tracker/package.json packages/tracker/ +RUN pnpm install --frozen-lockfile --prod --filter web... + +FROM base AS runner +ENV NODE_ENV=production +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=prod-deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=build /app/apps/web/build ./apps/web/build +COPY --from=build /app/apps/web/package.json ./apps/web/package.json +COPY package.json pnpm-workspace.yaml ./ + +WORKDIR /app/apps/web +EXPOSE 3000 +CMD ["node", "./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..103bffd8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Renato Pozzi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e075a280..75c81861 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,172 @@ -Aurora Logo + -
+# Aurora -![Stars](https://img.shields.io/github/stars/itsrennyman/aurora?style=for-the-badge) -![Latest Release](https://img.shields.io/github/v/release/itsrennyman/aurora?style=for-the-badge) +Open, cookie-free website analytics you host yourself. -# About Aurora 🌈 +Aurora measures your traffic without storing anything on your visitors' devices +— no cookies, no `localStorage`, no fingerprinting — and keeps every event in a +Postgres database you control. The tracking script is 2.4 KB over the wire. -Hate Cookies? Introducing Aurora, 100% Cookie-Free Open Website Analytics. -Collect Anonymous Data. Make your Audience Happy Now! +[![Release](https://img.shields.io/github/v/release/askides/aurora?style=flat-square)](https://github.com/askides/aurora/releases) +[![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](#license) -## Can I see a demo? 👀 +## Why cookie-free matters -You can see a **running demo** -[here](https://aurora-app-frontend.vercel.app/websites/cl2re5iw6000809l843fb61jr/s/analytics)! +ePrivacy Art. 5(3) governs "storage of information in terminal equipment" — a +`localStorage` key counts just as much as a cookie does. Aurora writes to +neither, so there is no consent banner to show for it. -### Getting Started 🤩 +Visitors are identified by a rotating HMAC derived from the IP address, user +agent and a server-side salt, never by a stored identifier. The hash input +rotates daily, so yesterday's visitor id cannot be linked to today's, and +nothing that leaves the browser can be traced back to a person. -You can go up and running with Aurora by following the -[Official Docs](https://aurora-docs.vercel.app). +## Quick start with Docker -> Please note that docs are currently under construction. +```bash +docker run -d --name aurora -p 3000:3000 \ + -e DATABASE_URL=postgres://user:password@host:5432/aurora \ + -e SESSION_SECRET="$(openssl rand -base64 32)" \ + -e AURORA_SALT="$(openssl rand -base64 32)" \ + ghcr.io/askides/aurora:latest +``` -### Built With 🏗️ +Images are published for `linux/amd64` and `linux/arm64`. Use `:latest` or pin a +version (`:4`, `:4.0`, `:4.0.0`); `:edge` tracks `main` and is not stable. -- [React.js](https://reactjs.org/) -- [Chakra UI](https://chakra-ui.com/) -- [Vercel](https://vercel.com/) +Then open `http://localhost:3000/signup`, create an account, and add a site. -### Versioning 🚦 +## Configuration -We use [SemVer](http://semver.org/) for versioning. For the versions available, -see the [tags on this repository](https://github.com/itsrennyman/aurora/tags). +| Variable | Required | Description | +| ----------------------- | -------------------- | --------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | yes | Postgres connection string, used at runtime and for migrations. | +| `SESSION_SECRET` | yes | Signs the session cookie. `openssl rand -base64 32`. | +| `AURORA_SALT` | yes in production | HMAC salt for visitor ids. Without it they would be derivable by anyone, so boot fails. | +| `AURORA_IP_HEADER` | strongly recommended | The forwarding header your proxy overwrites, e.g. `cf-connecting-ip`. | +| `AURORA_COUNTRY_HEADER` | no | The header your edge writes the country code into, when it isn't one of the three known ones. | -### Authors 🙋 +`AURORA_IP_HEADER` deserves a word. Every forwarding header is client-supplied +until some hop overwrites it, and which hop that is depends on your topology +rather than on anything the request can prove. Left unset in production, Aurora +warns at boot and falls back to guessing among `cf-connecting-ip`, `x-real-ip` +and `x-forwarded-for` — all of which a client can set, which makes visitor, +session and bounce figures forgeable. Name your trusted hop and nothing else is +consulted. -- [Renato Pozzi](https://github.com/itsrennyman) +## Adding the tracker to a site -### Stargazers 🌟 +Paste the snippet the dashboard gives you into your page's ``: -[![Stargazers repo roster for @itsrennyman/aurora](https://reporoster.com/stars/itsrennyman/aurora)](https://github.com/itsrennyman/aurora/stargazers) +``` + +``` -See also the list of -[contributors](https://github.com/itsrennyman/aurora/contributors) who -participated in this project. +Pageviews, including client-side route changes, are tracked automatically. -### License +For custom events, call the global — it queues calls made before the script has +loaded, so it is safe to use immediately: -This project is licensed under the MIT License - see the -[LICENSE.md](LICENSE.md) file for details +```js +aurora("signup"); +aurora("purchase", { + props: { plan: "pro" }, + revenue: { amount: 49.0, currency: "EUR" }, +}); +``` + +Props take scalars only, up to 24 keys. Revenue currencies are ISO-4217. + +## What the dashboard shows + +Visits, unique visitors, sessions, bounce rate and average session duration over +a range you pick, in a timezone you pick, on a Recharts timeseries. Breakdowns +cover pages, referrers, acquisition channels, countries, browsers, operating +systems, devices, languages and the full set of UTM parameters, plus custom +events with their props and revenue. + +Any site can be flagged public, which exposes its dashboard read-only at +`/websites/:id/s/analytics` without a login. + +## Development + +Requires Node 20+ and pnpm 10. + +```bash +git clone https://github.com/askides/aurora +cd aurora +pnpm install + +docker compose up -d --wait postgres # dev Postgres on host port 5434 +cp apps/web/.env.example apps/web/.env +pnpm db:migrate +pnpm dev +``` + +The compose Postgres is pinned to a patch release on purpose. Aurora resolves a +window's boundaries in JS and groups the buckets inside it in SQL, so Node's +tzdata and Postgres's have to agree about a zone or the two halves of a chart +disagree by that zone's offset, silently. Both sides assert their own copy +against `AURORA_TZDATA`; move them together or neither. + +### Scripts + +| Command | Description | +| ----------------- | --------------------------------- | +| `pnpm dev` | Run the dashboard in development | +| `pnpm build` | Build every workspace package | +| `pnpm test` | Run the test suites | +| `pnpm typecheck` | Typecheck every workspace package | +| `pnpm lint` | oxlint | +| `pnpm format` | oxfmt | +| `pnpm db:migrate` | Apply migrations | +| `pnpm db:seed` | Seed sample data | +| `pnpm db:studio` | Open Drizzle Studio | + +### Layout + +pnpm workspace, one deployable app plus the script it serves: + +``` +apps/ + web React Router app — dashboard, /collect endpoints, tracker host +packages/ + tracker Browser tracking script, bundled to apps/web/public/tracker.js +``` + +`apps/web/app` is organised as feature modules (`analytics`, `auth`, `ingest`, +`websites`) over a `shared` layer. The dependency direction is one-way and +enforced by oxlint: `shared` may not import from `modules` or `shell`. + +### Built with + +[React Router](https://reactrouter.com/) in framework mode, +[Drizzle](https://orm.drizzle.team/) on PostgreSQL, +[Tailwind CSS](https://tailwindcss.com/) with +[shadcn/ui](https://ui.shadcn.com/), [Recharts](https://recharts.org/), +[Vitest](https://vitest.dev/), and [oxlint/oxfmt](https://oxc.rs/). + +## Contributing + +Commits follow [Conventional Commits](https://www.conventionalcommits.org/) with +all-lowercase subjects, enforced by a commit-msg hook and again in CI: + +``` +feat(analytics): add the campaign breakdown panel +fix: reject null props on the collect route +``` + +Releases are automated. +[release-please](https://github.com/googleapis/release-please) reads those +commits, opens a release pull request, and merging it bumps the version, writes +`CHANGELOG.md`, tags, publishes a GitHub release and pushes the image to GHCR. +Don't edit versions or the changelog by hand. + +Versioning follows [SemVer](https://semver.org/). Aurora 4 is a ground-up +rewrite with no migration path from 1.x or 2.x; see +[CHANGELOG.md](CHANGELOG.md). + +## License + +[MIT](LICENSE). diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 62e5b2d8..00000000 --- a/TODO.md +++ /dev/null @@ -1,11 +0,0 @@ -# ToDo List - -- Add "View All Time" Stats to Analytics -- Adding Multi Language Support - -- Adding Multiple Users (Without roles) -- Implement a Better Authentication Flow - -## Docs ToDo - -- Write about the setup of the first user. diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..11636fa8 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,5 @@ +# Postgres connection string used by Drizzle (runtime + migrations). +DATABASE_URL=postgres://root:password@localhost:5434/aurora + +# Secret used to sign the session cookie. Generate with: openssl rand -base64 32 +SESSION_SECRET=change-me diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 00000000..fde7141b --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.env +node_modules/ + +# React Router +/.react-router/ +/build/ + +# Built by packages/tracker +/public/tracker.js diff --git a/apps/web/app/app.css b/apps/web/app/app.css new file mode 100644 index 00000000..cbb8bd21 --- /dev/null +++ b/apps/web/app/app.css @@ -0,0 +1,341 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "@fontsource-variable/geist"; +@import "@fontsource-variable/geist-mono"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif; + --font-mono: "Geist Mono Variable", ui-monospace, SFMono-Regular, monospace; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +/** + * The two bands of the aurora on the auth pages. Slow, out of phase, and + * offset from each other so the rail never reads as a still image — the + * reduced-motion rule at the bottom of this file collapses both. + * + * Transform only, deliberately. Opacity is what separates the bands from each + * other — violet over cerulean over green, the order they stack in the sky — + * and an animated `opacity` would override the class that sets it and drift + * every band through the same range, flattening the ramp into one wash. + */ +@theme { + --animate-aurora: aurora 22s ease-in-out infinite alternate; + --animate-aurora-slow: aurora-slow 31s ease-in-out infinite alternate; + + @keyframes aurora { + from { + transform: translate3d(-4%, 2%, 0) scale(1); + } + to { + transform: translate3d(6%, -4%, 0) scale(1.18); + } + } + + @keyframes aurora-slow { + from { + transform: translate3d(4%, -3%, 0) scale(1.14); + } + to { + transform: translate3d(-5%, 3%, 0) scale(1); + } + } +} + +/** + * Aurora + * + * Three surfaces, in order of elevation: --sidebar is the recessed frame the + * app sits in, --background is the content surface, --card is what floats on + * it. The ladder holds in both themes, so panels read as panels without + * needing shadows to say so. + * + * The chart ramp is the aurora emission spectrum — indigo and violet from + * nitrogen, cerulean through green from oxygen — which is also where the + * brand indigo (#555de4) comes from. chart-1 is the brand hue, so a + * single-series chart is automatically on-brand. + */ +:root { + --background: oklch(0.988 0.002 268); + --foreground: oklch(0.185 0.015 268); + --card: oklch(1 0 0); + --card-foreground: oklch(0.185 0.015 268); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.185 0.015 268); + --primary: oklch(0.545 0.204 271.5); + --primary-foreground: oklch(0.99 0.005 271.5); + --secondary: oklch(0.962 0.004 268); + --secondary-foreground: oklch(0.245 0.015 268); + --muted: oklch(0.962 0.004 268); + --muted-foreground: oklch(0.508 0.016 268); + --accent: oklch(0.955 0.012 271.5); + --accent-foreground: oklch(0.34 0.1 271.5); + /** + * The third semantic token, held to the same rule as the two below and for + * the same reason: it is worn as text everywhere it appears — Button and + * Badge `variant="destructive"` are `text-destructive` on a 10% tint of + * themselves, Alert is `text-destructive` on a 5% one with its description at + * 90% opacity over that, and the stat cards' down-trend arrow is bare + * `text-destructive`. At oklch(0.585 0.216 25.5) it measured 4.02:1 on the + * button's tint over --card, 3.89:1 over --background, 3.43:1 on the button's + * hover tint, and 3.80:1 for the alert description — a fail in every tinted + * role, on the one token of the three that was not re-lit. + * + * Same method as --success and --warning: measured on every surface the + * components can sit on (--card, --background, --popover — never --sidebar, + * which carries no alerts or destructive buttons) and on the darkest backdrop + * each role lays down, which for the button is the 20% hover tint rather than + * the 10% resting one. Worst case is now 4.63:1, on the hover tint over + * --background; the resting states are 5.6:1 and up. + * + * Chroma is capped just under what sRGB holds at this lightness (0.201 at + * L 0.49, hue 25.5) for the reason spelled out below: asking for more only + * makes the browser gamut-map it, to a colour whose contrast is then whatever + * the mapping decided rather than what was measured here. + */ + --destructive: oklch(0.49 0.195 25.5); + /** + * Both of these are worn as text — `text-success` under every stat card's + * trend arrow, and both as the `text-*` half of the tinted badges — so they + * are held to the 4.5:1 AA threshold for body text, not the 3:1 one for + * graphics. They used to be lit for a chart legend: 3.93:1 and 2.99:1 on + * --card, which is a fail and a bad fail. + * + * Measured on every surface they can sit on (--card, --background, --popover, + * --sidebar) and on the 10% tint of themselves the badges lay down over the + * first two, which is the darkest backdrop either of them gets. Read back off + * a canvas in the browser rather than modelled, so the figures are the pixels + * that get painted: worst case is now 4.66:1 for --success and 4.76:1 for + * --warning, and on --card, where the stat cards wear them, 5.60:1 and + * 5.69:1. The dark theme's pair already cleared the threshold with room to + * spare (7.33:1 and 8.63:1 at worst) and is unchanged. + * + * The chroma comes down with the lightness because these hues run out of sRGB + * before that: at L 0.5 the greenest green sRGB holds is C 0.132, and asking + * for more only makes the browser gamut-map it back — to a colour whose + * contrast is then whatever the mapping decided rather than what was measured + * here. The old --warning was over that line already. + */ + --success: oklch(0.5 0.132 152); + --success-foreground: oklch(0.985 0.01 152); + --warning: oklch(0.52 0.117 65); + --warning-foreground: oklch(0.985 0.01 65); + --border: oklch(0.912 0.006 268); + /** + * No longer the same value as --border, which is what it was. + * + * The two are worn for different jobs and held to different rules. --border + * draws the ring around a Card, which is decoration: WCAG 1.4.11 exempts it, + * and 1.25:1 is fine for something whose absence costs nothing. --input is + * the *only* thing that delimits an Input, Textarea, SelectTrigger or + * InputGroup from the page — all four are `bg-transparent border + * border-input` — so it is the visual boundary that identifies a user + * interface component, which 1.4.11 requires to reach 3:1. It measured + * 1.25:1 against --background and 1.30:1 against --card. + * + * L 0.655 is the lightest value that clears 3:1 against the surfaces a form + * control renders on: 3.07:1 on --background, 3.17:1 on --card and --popover. + * It does not reach 3:1 on --sidebar (2.78:1), which no form control is drawn + * on — SidebarInput is unused, and would be `bg-background` if it were. + */ + --input: oklch(0.655 0.006 268); + --ring: oklch(0.545 0.204 271.5); + + --chart-1: oklch(0.545 0.204 271.5); + --chart-2: oklch(0.585 0.185 300); + --chart-3: oklch(0.635 0.135 225); + --chart-4: oklch(0.7 0.13 175); + --chart-5: oklch(0.775 0.155 140); + + --radius: 0.5rem; + + --sidebar: oklch(0.955 0.005 268); + --sidebar-foreground: oklch(0.185 0.015 268); + --sidebar-primary: oklch(0.545 0.204 271.5); + --sidebar-primary-foreground: oklch(0.99 0.005 271.5); + --sidebar-accent: oklch(0.925 0.008 268); + --sidebar-accent-foreground: oklch(0.185 0.015 268); + --sidebar-border: oklch(0.9 0.007 268); + --sidebar-ring: oklch(0.545 0.204 271.5); +} + +.dark { + --background: oklch(0.185 0.011 268); + --foreground: oklch(0.965 0.003 268); + --card: oklch(0.215 0.012 268); + --card-foreground: oklch(0.965 0.003 268); + --popover: oklch(0.225 0.013 268); + --popover-foreground: oklch(0.965 0.003 268); + --primary: oklch(0.655 0.19 272); + --primary-foreground: oklch(0.16 0.04 272); + --secondary: oklch(0.258 0.013 268); + --secondary-foreground: oklch(0.965 0.003 268); + --muted: oklch(0.258 0.013 268); + --muted-foreground: oklch(0.69 0.014 268); + --accent: oklch(0.288 0.028 272); + --accent-foreground: oklch(0.9 0.05 272); + /** + * Lighter and less chromatic than the oklch(0.695 0.19 24) it replaces, by + * the same measurement as the light theme's: 4.45:1 on the button's 20% tint + * over --card and 3.62:1 on its 30% hover tint, against a 4.5:1 threshold. + * + * The chroma is not a taste decision. Red runs out of sRGB quickly on the + * light side — the most saturated red the display holds at L 0.755 is + * C 0.147 — so lifting the lightness far enough to clear the tints spends + * chroma whether this file admits it or not. Worst case is now 4.61:1, on the + * hover tint over --popover. + */ + --destructive: oklch(0.755 0.144 24); + --success: oklch(0.72 0.15 152); + --success-foreground: oklch(0.16 0.04 152); + --warning: oklch(0.79 0.15 72); + --warning-foreground: oklch(0.16 0.04 72); + --border: oklch(1 0 0 / 9%); + /** Split from --border for the reason given in the light theme: 1.39:1. */ + --input: oklch(1 0 0 / 35%); + --ring: oklch(0.655 0.19 272); + + --chart-1: oklch(0.655 0.19 272); + --chart-2: oklch(0.685 0.165 300); + --chart-3: oklch(0.72 0.125 225); + --chart-4: oklch(0.78 0.125 175); + --chart-5: oklch(0.855 0.155 140); + + --sidebar: oklch(0.145 0.012 268); + --sidebar-foreground: oklch(0.965 0.003 268); + --sidebar-primary: oklch(0.655 0.19 272); + --sidebar-primary-foreground: oklch(0.16 0.04 272); + --sidebar-accent: oklch(0.245 0.014 268); + --sidebar-accent-foreground: oklch(0.965 0.003 268); + --sidebar-border: oklch(1 0 0 / 8%); + --sidebar-ring: oklch(0.655 0.19 272); +} + +/** Section and metric labels. Small, quiet, and always the same size. */ +@utility text-eyebrow { + font-size: 0.6875rem; + line-height: 1rem; + font-weight: 500; + letter-spacing: 0.055em; + text-transform: uppercase; + font-variant-numeric: tabular-nums; +} + +/** Any figure a reader might scan down a column. */ +@utility num { + font-variant-numeric: tabular-nums; + font-feature-settings: "ss01"; + letter-spacing: -0.01em; +} + +/** Headline figures: tight, optically aligned, never reflowing on tick. */ +@utility num-display { + font-variant-numeric: tabular-nums; + font-feature-settings: "ss01"; + letter-spacing: -0.03em; + line-height: 1; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html { + @apply font-sans; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + @apply bg-sidebar text-foreground; + } + + ::selection { + background-color: color-mix(in oklch, var(--primary) 22%, transparent); + } + + /* Thin, self-effacing scrollbars — dense tables scroll a lot here. */ + * { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--foreground) 20%, transparent) + transparent; + } + + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 9999px; + background-color: color-mix(in oklch, var(--foreground) 18%, transparent); + } + + *::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in oklch, var(--foreground) 30%, transparent); + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } +} diff --git a/apps/web/app/db/id.ts b/apps/web/app/db/id.ts new file mode 100644 index 00000000..434453fd --- /dev/null +++ b/apps/web/app/db/id.ts @@ -0,0 +1,9 @@ +import { init } from "@paralleldrive/cuid2"; + +/** + * Prisma generated ids with `@default(cuid())`; Drizzle has no equivalent, so + * ids are minted in the application. Length 25 keeps the same shape the old + * ids had, which matters because ids appear in dashboard URLs and in the + * `aurora-id` attribute of every installed tracker snippet. + */ +export const createId = init({ length: 25 }); diff --git a/apps/web/app/db/migrations/0000_sloppy_vivisector.sql b/apps/web/app/db/migrations/0000_sloppy_vivisector.sql new file mode 100644 index 00000000..cca40cd1 --- /dev/null +++ b/apps/web/app/db/migrations/0000_sloppy_vivisector.sql @@ -0,0 +1,58 @@ +CREATE TABLE "event_metadata" ( + "event_id" text NOT NULL, + "metadata_id" text NOT NULL, + CONSTRAINT "event_metadata_event_id_metadata_id_pk" PRIMARY KEY("event_id","metadata_id") +); +--> statement-breakpoint +CREATE TABLE "events" ( + "id" text PRIMARY KEY NOT NULL, + "type" text DEFAULT 'pageView' NOT NULL, + "element" text NOT NULL, + "duration" double precision, + "is_new_visitor" boolean DEFAULT false NOT NULL, + "is_new_session" boolean DEFAULT false NOT NULL, + "is_a_bounce" boolean DEFAULT false NOT NULL, + "website_id" text NOT NULL, + "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "events_duration_range" CHECK ("events"."duration" IS NULL OR ("events"."duration" >= 0 AND "events"."duration" <= 86400000)) +); +--> statement-breakpoint +CREATE TABLE "metadata" ( + "id" text PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "value" text NOT NULL, + "version" text DEFAULT '' NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "metadata_type_value_version_key" UNIQUE("type","value","version") +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "firstname" text NOT NULL, + "lastname" text NOT NULL, + "email" text NOT NULL, + "password" text NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "websites" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "url" text NOT NULL, + "is_public" boolean DEFAULT false NOT NULL, + "user_id" text NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "event_metadata" ADD CONSTRAINT "event_metadata_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "event_metadata" ADD CONSTRAINT "event_metadata_metadata_id_metadata_id_fk" FOREIGN KEY ("metadata_id") REFERENCES "public"."metadata"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_website_id_websites_id_fk" FOREIGN KEY ("website_id") REFERENCES "public"."websites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "websites" ADD CONSTRAINT "websites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "event_metadata_metadata_id_idx" ON "event_metadata" USING btree ("metadata_id");--> statement-breakpoint +CREATE INDEX "events_website_id_created_at_idx" ON "events" USING btree ("website_id","created_at");--> statement-breakpoint +CREATE INDEX "websites_user_id_idx" ON "websites" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/web/app/db/migrations/0001_nervous_alex_wilder.sql b/apps/web/app/db/migrations/0001_nervous_alex_wilder.sql new file mode 100644 index 00000000..e8a3cf13 --- /dev/null +++ b/apps/web/app/db/migrations/0001_nervous_alex_wilder.sql @@ -0,0 +1,188 @@ +-- drizzle runs this whole file inside one transaction, and the first statement +-- takes ACCESS EXCLUSIVE on "events" and holds it until COMMIT: reads and +-- ingest are blocked for the duration. Nothing here can avoid that lock — every +-- ADD COLUMN takes it too, and CREATE INDEX CONCURRENTLY is not allowed in a +-- transaction — so the only lever is how much work happens under it. Hence the +-- single backfill pass further down: the obvious shape, one UPDATE per +-- dimension, rewrites every row six or seven times, which on a 50M-row install +-- is tens of GB of transient heap that cannot be reclaimed until commit and an +-- outage measured in the tens of minutes. + +-- drizzle-kit cannot see a rename, so it emitted DROP "element" + ADD "path" +-- text NOT NULL: that discards every path ever recorded and then aborts on the +-- first surviving row. The rename keeps the data and the NOT NULL that +-- "element" already carried. +ALTER TABLE "events" RENAME COLUMN "element" TO "path";--> statement-breakpoint +-- The check constraint at the end of this file admits 'pageview' and 'event' +-- only, so every other value has to be folded onto one of them first or the +-- validation scan aborts the entire migration and leaves the operator with no +-- indication of which rows were at fault. +-- +-- More than one literal is in play. The Prisma-era column default was +-- 'pageview' and the Drizzle 0000 baseline default was 'pageView', so an +-- install spanning both eras holds a mix. Worse, the pre-Drizzle ingest +-- endpoint validated this field as `z.string().optional()` on an +-- unauthenticated CORS-open route, so the column can hold literally any string +-- a client ever posted. Anything that is not recognisably a pageview lands on +-- 'event', where it stays out of every pageview metric — the safe direction, +-- since the alternative is inflating the headline numbers with garbage. +UPDATE "events" SET + "type" = CASE WHEN lower("type") = 'pageview' THEN 'pageview' ELSE 'event' END +WHERE "type" NOT IN ('pageview', 'event');--> statement-breakpoint +ALTER TABLE "events" ALTER COLUMN "type" SET DEFAULT 'pageview';--> statement-breakpoint +-- Dropped before the backfill rather than after: it is replaced by +-- "events_website_id_type_created_at_idx" below, and every index still standing +-- during the rewrite costs one more index tuple per row. +DROP INDEX "events_website_id_created_at_idx";--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "name" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "view_token" text;--> statement-breakpoint +-- "visitor_id", "session_id" and "channel" are NOT NULL in the schema, and +-- drizzle-kit emitted them as ADD COLUMN ... NOT NULL with no default, which +-- Postgres refuses on a table that already has rows. They arrive nullable, get +-- backfilled below, and take the constraint afterwards. +ALTER TABLE "events" ADD COLUMN "visitor_id" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "session_id" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "referrer_host" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "channel" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "utm_source" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "utm_medium" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "utm_campaign" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "utm_term" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "utm_content" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "browser" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "browser_version" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "os" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "os_version" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "device" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "screen_class" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "country" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "locale" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "props" jsonb;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "revenue" numeric(14, 2);--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "currency" text;--> statement-breakpoint +-- Storage parameters, which drizzle has no API for and drizzle-kit does not +-- read back when it introspects — so this line lives only here and will not +-- show up as drift on the next `db:push`. +-- +-- fillfactor: every pageview is UPDATEd at least once after insert (the +-- duration beacon, and the bounce clear on a second view). No index touches +-- "duration", "is_a_bounce" or "updated_at", so those updates are HOT-eligible +-- and cost zero index writes — but HOT also needs free space on the same heap +-- page, which the default fillfactor of 100 never leaves. Set before the +-- backfill so the rewritten heap already has the free space. +-- +-- 80 is measured, not guessed. Simulating this table's write pattern (batched +-- inserts, 7% custom events, a duration beacon for 88% of pageviews one batch +-- later, bounce clears on a third of them) over 10,000 rows: +-- +-- fillfactor 100 90 80 70 50 +-- HOT updates 3.3% 19.6% 37.5% 50.4% 87.1% +-- heap+index 5552 5544 5568 5640 5576 kB +-- +-- Total size barely moves across the range — the index tuples a HOT update +-- does not write pay for the heap the reserve costs — so the only real trade +-- is heap density, and 80 buys an order of magnitude more HOT updates for 2.7% +-- more heap. Lower values keep winning on this workload but only while nearly +-- every row really is updated once; if beacons stop arriving (ad blockers eat +-- the unload beacon) the reserve is simply wasted, and 80 stays reasonable in +-- that case where 50 does not. +-- +-- autovacuum_vacuum_scale_factor: the default 0.2 means a large table waits for +-- 20% churn before a vacuum, so the visibility map stays stale exactly over the +-- recent rows every dashboard window reads. +ALTER TABLE "events" SET (fillfactor = 80, autovacuum_vacuum_scale_factor = 0.02);--> statement-breakpoint +-- One pass, deliberately. Every dimension recorded so far lives in the two +-- tables dropped further down; the ones that still have a home on "events" are +-- moved across, and the identity columns are filled, in a single UPDATE so each +-- row is rewritten exactly once instead of once per dimension. "engine" has no +-- target column and is dropped with the tables. +-- +-- Referrers were stored as the full URL, which is a path on somebody else's +-- site and must not survive into the new column: only the host crosses over, +-- lowercased (hostnames are case-insensitive, and `Example.com` and +-- `example.com` would otherwise be two rows in the breakdown forever), any +-- userinfo dropped, `www.` stripped, and self-referrals discarded. +-- +-- The two hosts are extracted with deliberately different expressions. On the +-- referrer side the scheme is required: `document.referrer` is always absolute, +-- and the legacy ingest also wrote the literal sentinel 'Direct' into this +-- column, which a scheme-optional pattern would happily parse into a referrer +-- host named "direct". On the site side it is optional, because +-- "websites"."url" is not a validated URL — the form accepts `example.com` and +-- `https://WWW.Example.org` alike, and requiring a scheme there yields NULL, +-- which `IS DISTINCT FROM` then treats as "not a self-referral" and lets the +-- site's own domain through as its top referrer. +-- +-- Channel cannot be reconstructed beyond "something linked here" without the +-- search/social host lists, so rows with a surviving referrer land on +-- 'referral' and the rest on 'direct'. +-- +-- No visitor or session identity can be recovered: both were computed on the +-- client and only ever reached the server as booleans. Giving each archived row +-- its own pair keeps history countable — one past pageview reads as one visitor +-- with one single-page session, hence the bounce — rather than collapsing every +-- past visit onto a shared sentinel that would report one visitor for all time. +-- The prefix cannot collide with a real visitor_id, a 22-char base64url HMAC, +-- so sessionization can never attach a live visit to an archived one. All three +-- flags are written alongside the ids: leaving is_new_visitor and is_new_session +-- at their old per-row values would leave the same table holding two +-- contradictory answers to "how many sessions is this", and any window covering +-- pre-migration data would report more bounces than sessions. +UPDATE "events" SET + "browser" = "d"."browser", + "browser_version" = NULLIF(split_part("d"."browser_version", '.', 1), ''), + "os" = "d"."os", + "os_version" = NULLIF(split_part("d"."os_version", '.', 1), ''), + "device" = "d"."device", + "locale" = "d"."locale", + "referrer_host" = "d"."referrer_host", + "channel" = CASE WHEN "d"."referrer_host" IS NULL THEN 'direct' ELSE 'referral' END, + "visitor_id" = 'legacy_' || "events"."id", + "session_id" = 'legacy_' || "events"."id", + "is_new_visitor" = true, + "is_new_session" = true, + "is_a_bounce" = true +FROM ( + SELECT + "e"."id" AS "id", + -- value and version are read out of the same metadata row, not maxed + -- independently, so a browser can never end up wearing another + -- browser's version number. + (max(ARRAY["m"."value", "m"."version"]) FILTER (WHERE "m"."type" = 'browser'))[1] AS "browser", + (max(ARRAY["m"."value", "m"."version"]) FILTER (WHERE "m"."type" = 'browser'))[2] AS "browser_version", + (max(ARRAY["m"."value", "m"."version"]) FILTER (WHERE "m"."type" = 'os'))[1] AS "os", + (max(ARRAY["m"."value", "m"."version"]) FILTER (WHERE "m"."type" = 'os'))[2] AS "os_version", + -- The old column stored ucFirst(ua-parser type), which ranges wider + -- than the three values the column is documented to hold; anything else + -- becomes NULL rather than a fourth bucket nothing downstream expects + -- and the check constraint at the end of this file would reject. + lower(max("m"."value") FILTER ( + WHERE "m"."type" = 'device' AND lower("m"."value") IN ('desktop', 'mobile', 'tablet') + )) AS "device", + max("m"."value") FILTER (WHERE "m"."type" = 'locale') AS "locale", + NULLIF( + substring(lower(max("m"."value") FILTER (WHERE "m"."type" = 'referrer')) FROM '^[a-z][a-z0-9+.-]*://(?:[^/?#@]*@)?(?:www\.)?([^/?#:@]+)'), + substring(lower("w"."url") FROM '^(?:[a-z][a-z0-9+.-]*://)?(?:[^/?#@]*@)?(?:www\.)?([^/?#:@]+)') + ) AS "referrer_host" + FROM "events" AS "e" + JOIN "websites" AS "w" ON "w"."id" = "e"."website_id" + LEFT JOIN "event_metadata" AS "em" ON "em"."event_id" = "e"."id" + LEFT JOIN "metadata" AS "m" ON "m"."id" = "em"."metadata_id" + GROUP BY "e"."id", "w"."url" +) AS "d" +WHERE "d"."id" = "events"."id";--> statement-breakpoint +ALTER TABLE "events" ALTER COLUMN "visitor_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "events" ALTER COLUMN "session_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "events" ALTER COLUMN "channel" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "event_metadata" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "metadata" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "event_metadata" CASCADE;--> statement-breakpoint +DROP TABLE "metadata" CASCADE;--> statement-breakpoint +CREATE INDEX "events_website_id_type_created_at_idx" ON "events" USING btree ("website_id","type","created_at");--> statement-breakpoint +CREATE INDEX "events_website_id_visitor_id_created_at_idx" ON "events" USING btree ("website_id","visitor_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE UNIQUE INDEX "events_website_id_view_token_idx" ON "events" USING btree ("website_id","view_token") WHERE "events"."view_token" IS NOT NULL AND "events"."type" = 'pageview';--> statement-breakpoint +CREATE INDEX "events_website_id_session_id_idx" ON "events" USING btree ("website_id","session_id");--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_type_valid" CHECK ("events"."type" IN ('pageview', 'event'));--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_channel_valid" CHECK ("events"."channel" IN ('direct', 'search', 'social', 'referral', 'campaign'));--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_device_valid" CHECK ("events"."device" IS NULL OR "events"."device" IN ('desktop', 'mobile', 'tablet'));--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_screen_class_valid" CHECK ("events"."screen_class" IS NULL OR "events"."screen_class" IN ('mobile', 'tablet', 'laptop', 'desktop')); diff --git a/apps/web/app/db/migrations/meta/0000_snapshot.json b/apps/web/app/db/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..cab638fb --- /dev/null +++ b/apps/web/app/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,405 @@ +{ + "id": "e16c1859-0b8f-4d1d-9a05-5ea41673f8fe", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.event_metadata": { + "name": "event_metadata", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata_id": { + "name": "metadata_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "event_metadata_metadata_id_idx": { + "name": "event_metadata_metadata_id_idx", + "columns": [ + { + "expression": "metadata_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_metadata_event_id_events_id_fk": { + "name": "event_metadata_event_id_events_id_fk", + "tableFrom": "event_metadata", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_metadata_metadata_id_metadata_id_fk": { + "name": "event_metadata_metadata_id_metadata_id_fk", + "tableFrom": "event_metadata", + "tableTo": "metadata", + "columnsFrom": ["metadata_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_metadata_event_id_metadata_id_pk": { + "name": "event_metadata_event_id_metadata_id_pk", + "columns": ["event_id", "metadata_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pageView'" + }, + "element": { + "name": "element", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "is_new_visitor": { + "name": "is_new_visitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_new_session": { + "name": "is_new_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_a_bounce": { + "name": "is_a_bounce", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "website_id": { + "name": "website_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_website_id_created_at_idx": { + "name": "events_website_id_created_at_idx", + "columns": [ + { + "expression": "website_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_website_id_websites_id_fk": { + "name": "events_website_id_websites_id_fk", + "tableFrom": "events", + "tableTo": "websites", + "columnsFrom": ["website_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "events_duration_range": { + "name": "events_duration_range", + "value": "\"events\".\"duration\" IS NULL OR (\"events\".\"duration\" >= 0 AND \"events\".\"duration\" <= 86400000)" + } + }, + "isRLSEnabled": false + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "metadata_type_value_version_key": { + "name": "metadata_type_value_version_key", + "nullsNotDistinct": false, + "columns": ["type", "value", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "firstname": { + "name": "firstname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastname": { + "name": "lastname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.websites": { + "name": "websites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "websites_user_id_idx": { + "name": "websites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "websites_user_id_users_id_fk": { + "name": "websites_user_id_users_id_fk", + "tableFrom": "websites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/app/db/migrations/meta/0001_snapshot.json b/apps/web/app/db/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..df1ddd19 --- /dev/null +++ b/apps/web/app/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,506 @@ +{ + "id": "576ebef3-7e09-4d13-9cf1-18f196e507d4", + "prevId": "e16c1859-0b8f-4d1d-9a05-5ea41673f8fe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "website_id": { + "name": "website_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pageview'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "view_token": { + "name": "view_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_new_visitor": { + "name": "is_new_visitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_new_session": { + "name": "is_new_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_a_bounce": { + "name": "is_a_bounce", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "duration": { + "name": "duration", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "referrer_host": { + "name": "referrer_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "browser_version": { + "name": "browser_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os_version": { + "name": "os_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screen_class": { + "name": "screen_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "props": { + "name": "props", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "revenue": { + "name": "revenue", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_website_id_type_created_at_idx": { + "name": "events_website_id_type_created_at_idx", + "columns": [ + { + "expression": "website_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_website_id_visitor_id_created_at_idx": { + "name": "events_website_id_visitor_id_created_at_idx", + "columns": [ + { + "expression": "website_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_website_id_view_token_idx": { + "name": "events_website_id_view_token_idx", + "columns": [ + { + "expression": "website_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "view_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"events\".\"view_token\" IS NOT NULL AND \"events\".\"type\" = 'pageview'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_website_id_session_id_idx": { + "name": "events_website_id_session_id_idx", + "columns": [ + { + "expression": "website_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_website_id_websites_id_fk": { + "name": "events_website_id_websites_id_fk", + "tableFrom": "events", + "tableTo": "websites", + "columnsFrom": ["website_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "events_duration_range": { + "name": "events_duration_range", + "value": "\"events\".\"duration\" IS NULL OR (\"events\".\"duration\" >= 0 AND \"events\".\"duration\" <= 86400000)" + }, + "events_type_valid": { + "name": "events_type_valid", + "value": "\"events\".\"type\" IN ('pageview', 'event')" + }, + "events_channel_valid": { + "name": "events_channel_valid", + "value": "\"events\".\"channel\" IN ('direct', 'search', 'social', 'referral', 'campaign')" + }, + "events_device_valid": { + "name": "events_device_valid", + "value": "\"events\".\"device\" IS NULL OR \"events\".\"device\" IN ('desktop', 'mobile', 'tablet')" + }, + "events_screen_class_valid": { + "name": "events_screen_class_valid", + "value": "\"events\".\"screen_class\" IS NULL OR \"events\".\"screen_class\" IN ('mobile', 'tablet', 'laptop', 'desktop')" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "firstname": { + "name": "firstname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastname": { + "name": "lastname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.websites": { + "name": "websites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "websites_user_id_idx": { + "name": "websites_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "websites_user_id_users_id_fk": { + "name": "websites_user_id_users_id_fk", + "tableFrom": "websites", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/app/db/migrations/meta/_journal.json b/apps/web/app/db/migrations/meta/_journal.json new file mode 100644 index 00000000..0ebd0561 --- /dev/null +++ b/apps/web/app/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785795637857, + "tag": "0000_sloppy_vivisector", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785836687841, + "tag": "0001_nervous_alex_wilder", + "breakpoints": true + } + ] +} diff --git a/apps/web/app/db/schema.ts b/apps/web/app/db/schema.ts new file mode 100644 index 00000000..de1cc547 --- /dev/null +++ b/apps/web/app/db/schema.ts @@ -0,0 +1,312 @@ +import { createId } from "~/db/id"; +import { relations, sql } from "drizzle-orm"; +import { + boolean, + check, + doublePrecision, + index, + jsonb, + numeric, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +/** + * Drizzle has no `@default(cuid())` or `@updatedAt`, so both are explicit here. + * Every timestamp is `timestamptz` — the timeseries query does + * `created_at AT TIME ZONE $tz`, which only yields correct buckets when the + * column stores an instant rather than a naive local time. + */ +const id = () => + text("id") + .primaryKey() + .$defaultFn(() => createId()); + +const createdAt = (precision: 3 | 6) => + timestamp("created_at", { withTimezone: true, precision }) + .notNull() + .defaultNow(); + +const updatedAt = (precision: 3 | 6) => + timestamp("updated_at", { withTimezone: true, precision }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()); + +export const users = pgTable("users", { + id: id(), + firstname: text("firstname").notNull(), + lastname: text("lastname").notNull(), + email: text("email").notNull().unique(), + password: text("password").notNull(), + created_at: createdAt(3), + updated_at: updatedAt(3), +}); + +export const websites = pgTable( + "websites", + { + id: id(), + name: text("name").notNull(), + url: text("url").notNull(), + is_public: boolean("is_public").notNull().default(false), + user_id: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + created_at: createdAt(3), + updated_at: updatedAt(3), + }, + // Postgres does not index foreign key columns automatically, and deleting a + // user has to scan this table to cascade. + (t) => [index("websites_user_id_idx").on(t.user_id)] +); + +/** A pageview or a named custom event; the check constraint below pins both. */ +export type EventType = "pageview" | "event"; + +/** + * How the visit was acquired. Resolved once at ingest from the referrer host + * and the utm params, because deriving it at read time would mean carrying the + * search/social host lists into every breakdown query. + */ +export type ChannelType = + | "direct" + | "search" + | "social" + | "referral" + | "campaign"; + +/** + * Form factor as the user agent reports it. A guess about the kind of device, + * which is a different question from how much room the page actually got. + */ +export type DeviceType = "desktop" | "mobile" | "tablet"; + +/** + * Bucketed at ingest from the reported screen width in CSS px: + * `< 640` mobile, `< 1024` tablet, `< 1536` laptop, else desktop. + */ +export type ScreenClass = "mobile" | "tablet" | "laptop" | "desktop"; + +/** Bounded at ingest to scalars so one event cannot carry a whole document. */ +export type EventProps = Record; + +/** + * Every dimension is a column on the event. + * + * The previous shape normalised them into `metadata` + `event_metadata`, which + * cost a multi-row upsert on every ingest and two joins on every panel to + * answer "group by browser". Normalisation pays for values that are large and + * shared; these are short, low-cardinality strings, so it only bought write + * amplification and a query layer that could not express `count(DISTINCT + * visitor_id)` without a subselect. + */ +export const events = pgTable( + "events", + { + id: id(), + website_id: text("website_id") + .notNull() + .references(() => websites.id, { onDelete: "cascade" }), + type: text("type").$type().notNull().default("pageview"), + // Only custom events are named; a pageview is identified by its path. + name: text("name"), + path: text("path").notNull(), + /** + * The tracker's per-pageview token, the key the duration beacon matches on. + * It exists so the event id never has to be handed to a third-party origin, + * and it is null on rows no duration beacon can ever refer to. + * + * Ingest contract, enforced by the partial unique index below: mint a fresh + * token per pageview, leave it null on custom events, and translate a 23505 + * from the insert into a 204 rather than a 500 — the token arrives from an + * unauthenticated client, so a replayed one is a request to ignore, not an + * error. Uniqueness is what bounds the duration UPDATE to a single row; + * without it one replayed beacon rewrites every row sharing the token. + */ + view_token: text("view_token"), + /** + * HMAC over the UTC date, site, IP and user agent. It rotates at midnight + * UTC, so it is a daily pseudonym rather than a device id — which is both + * the reason no consent is needed and the definition of "unique visitor" + * the dashboard reports. + */ + visitor_id: text("visitor_id").notNull(), + session_id: text("session_id").notNull(), + is_new_visitor: boolean("is_new_visitor").notNull().default(false), + is_new_session: boolean("is_new_session").notNull().default(false), + is_a_bounce: boolean("is_a_bounce").notNull().default(false), + // Nullable: "never measured" must stay distinguishable from "lasted 0ms", + // otherwise unreported pageviews drag the average visit time down. + duration: doublePrecision("duration"), + // Hostname only, `www.` stripped, self-referrals dropped. The full URL is a + // path on somebody else's site: PII we would never display and must not + // keep just because the browser offered it. + referrer_host: text("referrer_host"), + channel: text("channel").$type().notNull(), + utm_source: text("utm_source"), + utm_medium: text("utm_medium"), + utm_campaign: text("utm_campaign"), + utm_term: text("utm_term"), + utm_content: text("utm_content"), + browser: text("browser"), + /** + * Major only ("139"). A full version string buckets one row per Chrome + * patch release and turns the panel into a histogram of noise. Null when + * UA reduction hides it — the name still counts, which is the bug the old + * all-or-nothing dimension had. + */ + browser_version: text("browser_version"), + os: text("os"), + os_version: text("os_version"), + device: text("device").$type(), + /** + * Bucketed from the reported screen width, deliberately kept alongside + * `device`: the user agent only ever claims a form factor, and it cannot + * tell a 13" laptop from a 32" monitor — both are `device = 'desktop'`. + * The screen class is the one that answers "which breakpoint do these + * readers actually land on", which is the question a layout change needs. + */ + screen_class: text("screen_class").$type(), + // ISO-3166-1 alpha-2, from edge headers only. Null wherever the deployment + // has no geo-aware proxy in front, which is a supported setup and must not + // read as an error anywhere downstream. + country: text("country"), + // BCP-47, kept apart from `country`: the browser language says who the + // reader is, the edge header says where they are, and they disagree often. + locale: text("locale"), + props: jsonb("props").$type(), + /** + * Split from `currency` so revenue stays summable in SQL; one text column + * holding "49 EUR" would push parsing into every aggregate. + * + * `numeric` rather than `double precision`, which is the one type where + * that sum is wrong: float8 addition is inexact and non-associative, so a + * hundred rows totalling 26.99 come back as 26.990000000000027 and the + * figure changes with the scan order. `sum(numeric)` arrives from pg as a + * string, so an aggregate annotated `sql` needs `::float8` on the + * way out — a rounding at the display edge instead of in the ledger. + */ + revenue: numeric("revenue", { precision: 14, scale: 2, mode: "number" }), + currency: text("currency"), + created_at: createdAt(6), + updated_at: updatedAt(6), + }, + // The migration also sets `fillfactor = 80` and a tighter autovacuum + // threshold on this table. Neither is expressible here — drizzle has no + // table-level storage-parameter API — and drizzle-kit does not read + // `reloptions` when it introspects, so the hand-written line is invisible to + // `db:push` rather than a source of churn. It matters because every pageview + // is UPDATEd at least once by the duration beacon: none of the indexes below + // touch `duration`, `is_a_bounce` or `updated_at`, so those updates are + // HOT-eligible and cost no index writes at all, but HOT also needs free space + // on the same heap page, which a fillfactor of 100 never leaves. Measured on + // this write pattern it is the difference between 3% and 38% HOT updates at + // the same total size; the numbers are in the migration. + // + // Keep it in mind before adding an index here: putting any of those three + // columns into a key or a predicate gives that back up. + (t) => [ + // Every dashboard panel filters website_id then ranges over created_at, + // and every pageview metric also filters type so custom events cannot + // inflate it. type sits in the middle so that qual is an access-path + // boundary rather than a heap-side filter — Postgres 16 has no skip scan, + // so a trailing type column would mean reading and discarding every custom + // event in the window, nine times per dashboard render. + index("events_website_id_type_created_at_idx").on( + t.website_id, + t.type, + t.created_at + ), + // Sessionization reads the visitor's latest event on every single ingest, + // so this index sits on the write path and cannot be allowed to seq-scan. + // Descending makes that lookup a one-row backwards walk with no sort. + // + // `t.created_at.desc()` and not `desc(t.created_at)`: the latter is the + // ORDER BY helper and lands in the index as an opaque SQL expression that + // drizzle-kit cannot match against pg_index, so every `db:push` would drop + // and rebuild the largest index on the hottest table. + // + // `.nullsFirst()` is not redundant. Drizzle defaults a descending index + // column to NULLS LAST, Postgres defaults `ORDER BY x DESC` to NULLS FIRST, + // and the planner compares those two literally rather than noticing that + // the column is NOT NULL — so the default spelling puts a Sort back on top + // of the scan and the lookup this index exists for stops being sortless. + index("events_website_id_visitor_id_created_at_idx").on( + t.website_id, + t.visitor_id, + t.created_at.desc().nullsFirst() + ), + // The duration beacon finds its pageview by token, and unique is what keeps + // that UPDATE to one row: the token is client-supplied over an + // unauthenticated endpoint, so without it a client can mint N pageviews + // sharing a token and rewrite all N with one beacon. The predicate names + // `type` as well as `view_token IS NOT NULL` so the implication "has a + // token" -> "is a pageview" is held by the database rather than by the + // ingest route remembering to null the column on custom events; it also + // keeps the index a fraction of the table, and stops a beacon attaching a + // duration to a custom event that per-session visit duration would sum. + // Neither predicate column is ever updated, so this does not cost HOT. + uniqueIndex("events_website_id_view_token_idx") + .on(t.website_id, t.view_token) + .where(sql`${t.view_token} IS NOT NULL AND ${t.type} = 'pageview'`), + // A second view retroactively clears is_a_bounce on every earlier row of + // the session, once per ingest, and the bounce rate groups by the same key. + // + // That UPDATE must carry `AND is_a_bounce AND type = 'pageview'`: only the + // session's first pageview can hold the flag, and without the predicate the + // statement rewrites every prior row of the session on every view — 1225 + // row versions for a 50-view session. Do not express it by making this + // index partial instead: Postgres counts index predicate columns as + // modified-attribute blockers, so putting is_a_bounce in the predicate is + // exactly what would turn the bounce clear into a non-HOT update. + index("events_website_id_session_id_idx").on(t.website_id, t.session_id), + // /collect is unauthenticated, so a client could otherwise post a negative + // or absurd duration and skew a site's average permanently. The upper bound + // also rejects NaN, which Postgres sorts above every finite value. + check( + "events_duration_range", + sql`${t.duration} IS NULL OR (${t.duration} >= 0 AND ${t.duration} <= 86400000)` + ), + // The four closed sets below are unions in TypeScript, which is a claim + // about the code that writes the column and says nothing about the column. + // Every one of them is resolved once at ingest and grouped on at read time, + // so an unexpected value would not fail anything: it would quietly stop + // being counted anywhere, which is the failure that takes longest to see. + check("events_type_valid", sql`${t.type} IN ('pageview', 'event')`), + check( + "events_channel_valid", + sql`${t.channel} IN ('direct', 'search', 'social', 'referral', 'campaign')` + ), + check( + "events_device_valid", + sql`${t.device} IS NULL OR ${t.device} IN ('desktop', 'mobile', 'tablet')` + ), + check( + "events_screen_class_valid", + sql`${t.screen_class} IS NULL OR ${t.screen_class} IN ('mobile', 'tablet', 'laptop', 'desktop')` + ), + ] +); + +export const usersRelations = relations(users, ({ many }) => ({ + websites: many(websites), +})); + +export const websitesRelations = relations(websites, ({ one, many }) => ({ + user: one(users, { fields: [websites.user_id], references: [users.id] }), + events: many(events), +})); + +export const eventsRelations = relations(events, ({ one }) => ({ + website: one(websites, { + fields: [events.website_id], + references: [websites.id], + }), +})); + +export type User = typeof users.$inferSelect; +export type Website = typeof websites.$inferSelect; +export type Event = typeof events.$inferSelect; diff --git a/apps/web/app/db/seed.ts b/apps/web/app/db/seed.ts new file mode 100644 index 00000000..409a960a --- /dev/null +++ b/apps/web/app/db/seed.ts @@ -0,0 +1,522 @@ +import { events, users, websites } from "./schema"; +import { faker } from "@faker-js/faker"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { createHash } from "node:crypto"; +import { Pool } from "pg"; +import { createId } from "./id"; + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error("DATABASE_URL is not set"); +} + +const pool = new Pool({ connectionString }); +const db = drizzle(pool, { casing: "snake_case" }); + +// Fixed, so a screenshot taken against one seeded database still matches the +// next one and a regression in a metric is visible rather than plausible noise. +faker.seed(20260804); + +const MINUTE_MS = 60_000; +const HOUR_MS = 3_600_000; +const DAY_MS = 86_400_000; + +/** + * Long enough that the 7 and 30 day presets both have history behind them and + * the previous-window comparison has something to compare against. + */ +const DAYS = 60; + +type EventRow = typeof events.$inferInsert; + +type Device = Pick< + EventRow, + | "browser" + | "browser_version" + | "os" + | "os_version" + | "device" + | "screen_class" +>; + +type Place = Pick; + +type Source = Pick< + EventRow, + | "referrer_host" + | "channel" + | "utm_source" + | "utm_medium" + | "utm_campaign" + | "utm_term" + | "utm_content" +>; + +/** + * Whole devices rather than a cross product of the columns: the breakdowns only + * read as real traffic if Safari appears on macOS and iOS and never on Windows. + * Two rows carry no version — UA reduction hides it in the field, and the new + * schema is meant to keep the name in that case instead of dropping the row. + */ +const DEVICES: Device[] = [ + { + browser: "Chrome", + browser_version: "139", + os: "Windows", + os_version: "10", + device: "desktop", + screen_class: "desktop", + }, + { + browser: "Chrome", + browser_version: "138", + os: "Windows", + os_version: "11", + device: "desktop", + screen_class: "laptop", + }, + { + browser: "Safari", + browser_version: "18", + os: "macOS", + os_version: "15", + device: "desktop", + screen_class: "laptop", + }, + { + browser: "Firefox", + browser_version: "131", + os: "Linux", + os_version: null, + device: "desktop", + screen_class: "desktop", + }, + { + browser: "Edge", + browser_version: "139", + os: "Windows", + os_version: "11", + device: "desktop", + screen_class: "laptop", + }, + { + browser: "Mobile Safari", + browser_version: "18", + os: "iOS", + os_version: "18", + device: "mobile", + screen_class: "mobile", + }, + { + browser: "Chrome", + browser_version: "139", + os: "Android", + os_version: "15", + device: "mobile", + screen_class: "mobile", + }, + { + browser: "Samsung Internet", + browser_version: null, + os: "Android", + os_version: "14", + device: "mobile", + screen_class: "mobile", + }, + { + browser: "Mobile Safari", + browser_version: "18", + os: "iPadOS", + os_version: "18", + device: "tablet", + screen_class: "tablet", + }, +]; + +/** The last row has no country: a self-hoster behind a plain reverse proxy gets + * no geo header, and every panel has to survive that. */ +const PLACES: Place[] = [ + { country: "US", locale: "en-US" }, + { country: "GB", locale: "en-GB" }, + { country: "IT", locale: "it-IT" }, + { country: "DE", locale: "de-DE" }, + { country: "FR", locale: "fr-FR" }, + { country: "ES", locale: "es-ES" }, + { country: "BR", locale: "pt-BR" }, + { country: "JP", locale: "ja-JP" }, + { country: "IN", locale: "en-IN" }, + { country: null, locale: "nl-NL" }, +]; + +const NO_UTM = { + utm_source: null, + utm_medium: null, + utm_campaign: null, + utm_term: null, + utm_content: null, +}; + +/** + * Acquisition is a property of the whole session, not of each hit: + * `document.referrer` survives a pushState, so every event of a visit carries + * the same host and channel. Weighted to the usual shape — search and direct + * dominate, paid is a sliver — so the channel panel is not a flat bar chart. + */ +const SOURCES: { weight: number; value: Source }[] = [ + { weight: 26, value: { referrer_host: null, channel: "direct", ...NO_UTM } }, + { + weight: 20, + value: { referrer_host: "google.com", channel: "search", ...NO_UTM }, + }, + { + weight: 6, + value: { referrer_host: "duckduckgo.com", channel: "search", ...NO_UTM }, + }, + { + weight: 4, + value: { referrer_host: "bing.com", channel: "search", ...NO_UTM }, + }, + { + weight: 8, + value: { + referrer_host: "news.ycombinator.com", + channel: "social", + ...NO_UTM, + }, + }, + { + weight: 7, + value: { referrer_host: "x.com", channel: "social", ...NO_UTM }, + }, + { + weight: 5, + value: { referrer_host: "reddit.com", channel: "social", ...NO_UTM }, + }, + { + weight: 4, + value: { referrer_host: "linkedin.com", channel: "social", ...NO_UTM }, + }, + { + weight: 5, + value: { referrer_host: "github.com", channel: "referral", ...NO_UTM }, + }, + { + weight: 3, + value: { referrer_host: "producthunt.com", channel: "referral", ...NO_UTM }, + }, + { + weight: 6, + value: { + referrer_host: "t.co", + channel: "campaign", + utm_source: "twitter", + utm_medium: "social", + utm_campaign: "launch-week", + utm_term: null, + utm_content: "hero-card", + }, + }, + { + weight: 4, + value: { + referrer_host: null, + channel: "campaign", + utm_source: "newsletter", + utm_medium: "email", + utm_campaign: "monthly-digest", + utm_term: null, + utm_content: "issue-14", + }, + }, + { + weight: 2, + value: { + referrer_host: "google.com", + channel: "campaign", + utm_source: "google", + utm_medium: "cpc", + utm_campaign: "brand", + utm_term: "web analytics", + utm_content: "ad-b", + }, + }, +]; + +/** Most visits land on one page and leave; without that skew the bounce rate + * comes out at a number no real site has ever reported. */ +const VIEWS_PER_SESSION = [ + { weight: 45, value: 1 }, + { weight: 25, value: 2 }, + { weight: 15, value: 3 }, + { weight: 9, value: 4 }, + { weight: 6, value: 5 }, +]; + +/** Office hours in UTC, so the hourly buckets have a shape to draw. */ +const START_HOURS = [ + { weight: 1, value: [0, 6] }, + { weight: 6, value: [7, 12] }, + { weight: 8, value: [13, 18] }, + { weight: 4, value: [19, 23] }, +]; + +const SITES = [ + { + name: "Aurora", + url: "https://aurora.dev", + is_public: true, + paths: [ + { weight: 30, value: "/" }, + { weight: 14, value: "/pricing" }, + { weight: 12, value: "/docs/install" }, + { weight: 9, value: "/docs/tracker" }, + { weight: 8, value: "/blog/cookie-free-analytics" }, + { weight: 6, value: "/changelog" }, + { weight: 5, value: "/docs/self-hosting" }, + { weight: 4, value: "/signin" }, + ], + }, + { + name: "Field Notes", + url: "https://notes.example.com", + is_public: false, + paths: [ + { weight: 26, value: "/" }, + { weight: 18, value: "/posts/postgres-index-only-scans" }, + { weight: 13, value: "/posts/what-a-cookie-actually-is" }, + { weight: 10, value: "/posts/reading-explain-analyze" }, + { weight: 7, value: "/archive" }, + { weight: 5, value: "/about" }, + ], + }, +]; + +const CUSTOM_EVENTS = [ + { weight: 6, value: "signup_started" }, + { weight: 3, value: "signup_completed" }, + { weight: 5, value: "docs_search" }, + { weight: 4, value: "newsletter_subscribe" }, + { weight: 2, value: "checkout_completed" }, +]; + +function propsFor(name: string): EventRow["props"] { + switch (name) { + case "docs_search": + return { + query: faker.hacker.noun(), + results: faker.number.int({ min: 0, max: 20 }), + }; + case "newsletter_subscribe": + return { placement: faker.helpers.arrayElement(["footer", "post-end"]) }; + default: + return { + plan: faker.helpers.arrayElement(["free", "pro", "team"]), + seats: faker.number.int({ min: 1, max: 25 }), + }; + } +} + +/** + * Shaped like the real derivation (an HMAC over the UTC date, site, ip and user + * agent): the id has to rotate at midnight or the seeded "unique visitors" + * would not mean what the dashboard says it means. + */ +function visitorId(utcDate: string, wid: string, person: number) { + return createHash("sha256") + .update(`${utcDate}:${wid}:${person}`) + .digest("base64url") + .slice(0, 22); +} + +function generate( + website: { id: string }, + paths: { weight: number; value: string }[] +) { + const rows: EventRow[] = []; + const now = Date.now(); + const startOfToday = Math.floor(now / DAY_MS) * DAY_MS; + + for (let daysAgo = DAYS - 1; daysAgo >= 0; daysAgo--) { + const dayStart = startOfToday - daysAgo * DAY_MS; + const day = new Date(dayStart); + const utcDate = day.toISOString().slice(0, 10); + const weekend = day.getUTCDay() === 0 || day.getUTCDay() === 6; + + // A slow ramp with a weekend dip, so the window-over-window comparison on + // the dashboard has a trend to report instead of flat noise. + const growth = 0.55 + (0.45 * (DAYS - daysAgo)) / DAYS; + const sessions = Math.round( + faker.number.int({ min: 14, max: 26 }) * growth * (weekend ? 0.6 : 1) + ); + + // A person visiting twice in a day is one unique visitor, and the ingest + // path learns that from the visitor's own earlier row rather than a flag. + const seen = new Set(); + + for (let i = 0; i < sessions; i++) { + const person = faker.number.int({ min: 1, max: 220 }); + const visitor_id = visitorId(utcDate, website.id, person); + + // Indexed off the person, not drawn fresh: a reader keeps their phone and + // their country between visits, and a browser panel where they do not is + // the kind of data nobody would ship a screenshot of. + const device = DEVICES[person % DEVICES.length]; + const place = PLACES[(person * 7) % PLACES.length]; + const source = faker.helpers.weightedArrayElement(SOURCES); + + const [fromHour, toHour] = + faker.helpers.weightedArrayElement(START_HOURS); + let at = + dayStart + + faker.number.int({ min: fromHour, max: toHour }) * HOUR_MS + + faker.number.int({ min: 0, max: HOUR_MS - 1 }); + + if (at >= now) { + continue; + } + + const views = faker.helpers.weightedArrayElement(VIEWS_PER_SESSION); + const session_id = createId(); + const is_new_visitor = !seen.has(visitor_id); + + seen.add(visitor_id); + + for (let view = 0; view < views && at < now; view++) { + // The dwell time is the gap to the next view, so summing durations + // across a session lands on the same figure the timeline shows. + const gap = faker.number.int({ min: 20_000, max: 5 * MINUTE_MS }); + const path = faker.helpers.weightedArrayElement(paths); + + rows.push({ + website_id: website.id, + type: "pageview", + name: null, + path, + view_token: faker.string.uuid(), + visitor_id, + session_id, + is_new_visitor: is_new_visitor && view === 0, + is_new_session: view === 0, + // Cleared on every earlier row the moment a second view arrives, so + // a bounce is only ever a session that stopped at one page. + is_a_bounce: views === 1, + // A slice of pageviews never report: the beacon is lost, or the tab + // is killed. Those have to stay null rather than count as zero. + duration: faker.datatype.boolean({ probability: 0.12 }) ? null : gap, + ...source, + ...device, + ...place, + props: null, + revenue: null, + currency: null, + created_at: new Date(at), + }); + + if (faker.datatype.boolean({ probability: 0.07 })) { + const name = faker.helpers.weightedArrayElement(CUSTOM_EVENTS); + const paid = name === "checkout_completed"; + + rows.push({ + website_id: website.id, + type: "event", + name, + path, + // Custom events join the session but never open one, carry no + // duration beacon, and must stay out of the pageview counters. + view_token: null, + visitor_id, + session_id, + is_new_visitor: false, + is_new_session: false, + is_a_bounce: false, + duration: null, + ...source, + ...device, + ...place, + props: propsFor(name), + revenue: paid + ? faker.number.float({ min: 9, max: 499, fractionDigits: 2 }) + : null, + currency: paid + ? faker.helpers.arrayElement(["EUR", "USD", "GBP"]) + : null, + created_at: new Date( + at + faker.number.int({ min: 1_000, max: 30_000 }) + ), + }); + } + + at += gap; + } + } + } + + return rows; +} + +/** Postgres caps a statement at 65535 bound parameters and every event binds + * one per column, so the batch has to stay well under two thousand rows. */ +function batches(rows: T[], size: number) { + const out: T[][] = []; + + for (let i = 0; i < rows.length; i += size) { + out.push(rows.slice(i, i + size)); + } + + return out; +} + +async function main() { + const [user] = await db + .insert(users) + .values({ + firstname: "John", + lastname: "Doe", + email: "john.doe@example.com", + // bcrypt hash of "password" + password: "$2a$10$6m.u36XdklkkMYZ01tSPXexVLXMmS.BM1AVcYtOg3fCtsu9EmyqOy", + }) + .onConflictDoNothing({ target: users.email }) + .returning(); + + // Gated on the user being new: a second run would otherwise stack another two + // months of traffic on top of the first and every figure would double. + if (!user) { + console.log("user already present, nothing to seed"); + return; + } + + const created = await db + .insert(websites) + .values( + SITES.map((site) => ({ + name: site.name, + url: site.url, + is_public: site.is_public, + user_id: user.id, + })) + ) + .returning(); + + const rows = created.flatMap((website, index) => + generate(website, SITES[index].paths) + ); + + for (const batch of batches(rows, 500)) { + await db.insert(events).values(batch); + } + + console.log( + `${user.email}: ${created.length} websites, ${rows.length} events over ${DAYS} days` + ); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(async () => { + await pool.end(); + }); diff --git a/apps/web/app/layouts/app.tsx b/apps/web/app/layouts/app.tsx new file mode 100644 index 00000000..8efb090e --- /dev/null +++ b/apps/web/app/layouts/app.tsx @@ -0,0 +1,30 @@ +import { Outlet } from "react-router"; +import { AppShell } from "~/shell/app-shell"; +import { getUserWebsites } from "~/modules/websites/queries.server"; +import { requireUser } from "~/modules/auth/session.server"; +import type { Route } from "./+types/app"; + +/** + * Auth gate for the whole authenticated area. Replaces the client-side + * wrapper — unauthenticated requests never reach the + * child loaders, and no user data is sent to the browser before the check. + * + * The website list is loaded here because the shell's switcher and breadcrumb + * need it on every screen; child routes would each have to refetch it. + */ +export async function loader({ request }: Route.LoaderArgs) { + const user = await requireUser(request); + const websites = await getUserWebsites(user.id); + + return { user, websites }; +} + +export default function AppLayout({ loaderData }: Route.ComponentProps) { + const { user, websites } = loaderData; + + return ( + + + + ); +} diff --git a/apps/web/app/modules/analytics/__tests__/breakdown-panel.test.tsx b/apps/web/app/modules/analytics/__tests__/breakdown-panel.test.tsx new file mode 100644 index 00000000..116af9b1 --- /dev/null +++ b/apps/web/app/modules/analytics/__tests__/breakdown-panel.test.tsx @@ -0,0 +1,286 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BreakdownPanel } from "../components/breakdown-panel"; +import { ROW_CAP } from "../components/panel"; + +// jest-dom's matchers are not registered by test/setup.ts, so everything here +// asserts on plain DOM. +afterEach(cleanup); + +const row = (element: string, count: number, unique = count) => ({ + element, + count, + unique, +}); + +const tab = (rows: ReturnType[]) => ({ + value: "locales", + label: "Language", + kind: "locale" as const, + unit: "views" as const, + rows, +}); + +const text = (node: Element | null) => node?.textContent ?? ""; + +describe("BreakdownPanel", () => { + it("adds up rows that arrive under the same label", () => { + // `toLocaleName` in metrics.server.ts rewrites the stored BCP-47 tag to a + // display name, and several tags share one: zh and zh-Hans are both + // "Chinese (Simplified)", as are nb/no, sr/sr-Latn, uz/uz-Latn, az/az-Latn. + // Keyed by the rewritten value, React warned about duplicate keys and the + // panel drew the language twice with its counts split — 10 and 3 rather + // than one row of 13. + const warn = vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + + ); + + const rows = screen.getAllByRole("row").slice(1); + const cells = within(rows[0]).getAllByRole("cell"); + + expect(rows).toHaveLength(2); + expect(text(within(rows[0]).getByRole("rowheader"))).toBe( + "Chinese (Simplified)" + ); + expect(text(cells[0])).toBe("13"); + expect(text(cells[1])).toBe("7"); + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it("associates every figure with the column it belongs to", () => { + // Seven three-column grids on the dashboard and not one or ` is undefined in the table spec but implemented by every engine in + // use. The cost is that the bar, being positioned, paints over the + // *content* of the unpositioned cells beside it rather than under it — so + // the two numeric cells are marked `relative` to put them back on top. + // + // The dark tint is 18% rather than the 22% it was. The bar composites under + // the row's hover colour as well as over the card, and the Daily visitors + // cell is `text-muted-foreground`: at 22% that cell measured 4.59:1 resting + // and 4.38:1 hovered, so hover was what pushed it under 4.5:1, across the + // leftmost fifth of every row in all seven panels. At 18% it is 4.90:1 and + // 4.68:1. The light tint is 12% and was never close to the line. + + + + + + ); +} + +function BreakdownList({ tab, title }: { tab: BreakdownTab; title: string }) { + const [expanded, setExpanded] = useState(false); + const sorted = useMemo(() => mergeRows(tab.rows), [tab.rows]); + const unit = UNIT_LABEL[tab.unit]; + // The card's title plus the tab's, except where a single-dimension panel + // would otherwise say the same word twice ("Pages: Pages"). + const name = title === tab.label ? title : `${title}: ${tab.label}`; + + if (sorted.length === 0) { + const Icon = KIND_ICON[tab.kind]; + + return ( + // `h-full` so the dashed frame fills the card. Panels sit in a grid row + // whose height is set by the tallest sibling, and an empty one that only + // claimed its min-height left a band of blank card under it. + + + + + + No data in this range + {EMPTY_HINT} + + + ); + } + + const maxCount = sorted[0].count; + const visible = expanded ? sorted : sorted.slice(0, COLLAPSED_ROWS); + // Measured before the merge: merging can pull the length under the limit + // while the list is still the top of a longer one. + const capped = tab.rows.length >= ROW_CAP; + + return ( +
+ {/* The height lives on the wrapper, not the table: a folded list, a full + one and the empty state all have to leave the card the same size, and + a table-row-group does not take a min-height. */} +
+
+ // between them: a screen reader announced "/pricing 500 300" with no way + // to tell which number was Views and which was Daily visitors. + render( + + ); + + const columns = screen.getAllByRole("columnheader").map(text); + + expect(screen.getAllByRole("table")).toHaveLength(1); + expect(columns.slice(0, 2)).toEqual(["Language", "Views"]); + expect(columns[2]).toContain("Daily visitors"); + }); + + it("does not leave an orphan tabpanel when there is one dimension", () => { + // Base UI's TabsPanel emits role="tabpanel" with tabIndex={0} whether or + // not a Tab was registered, so hiding the list left an extra keyboard stop + // owning no tab and carrying no accessible name. + render(); + + expect(screen.queryAllByRole("tabpanel")).toHaveLength(0); + }); + + it("keeps the tabs when there is more than one dimension", () => { + render( + + ); + + expect(screen.getAllByRole("tab")).toHaveLength(2); + }); + + it("does not claim a capped list is the whole list", () => { + // Every breakdown is cut to BREAKDOWN_LIMIT after ordering by count, so + // "Show all (100)" asserted completeness about the top hundredth of a + // routine Pages list, with nothing else in the panel saying it was cut. + const rows = Array.from({ length: ROW_CAP }, (_, index) => + row(`/page-${index}`, ROW_CAP - index) + ); + + render(); + + expect( + screen.getByRole("button", { name: `Show top ${ROW_CAP}` }) + ).toBeTruthy(); + }); + + it("says the list is all of it when the query did not cut it", () => { + const rows = Array.from({ length: 12 }, (_, index) => + row(`/page-${index}`, 12 - index) + ); + + render(); + + expect(screen.getByRole("button", { name: "Show all (12)" })).toBeTruthy(); + }); + + it("heads the count column with the unit the panel was scoped in", () => { + // The acquisition dimensions are grouped over `is_new_session` — one row + // per arrival — so a "Views" header over them would be the same class of + // defect as the scope it replaced, restated in a word rather than a number. + // The unit rides along with the rows for exactly this reason: nothing + // between the query and this header gets to decide it. + render( + + ); + + const columns = screen.getAllByRole("columnheader").map(text); + + expect(columns.slice(0, 2)).toEqual(["Channel", "Sessions"]); + }); + + it("names a channel rather than echoing the stored value", () => { + // The column is lowercase and CHECK-constrained to five values, which is + // what the panel would have printed: "search" under a capitalised header. + render( + + ); + + const rows = screen.getAllByRole("row").slice(1); + + expect( + rows.map((node) => text(within(node).getByRole("rowheader"))) + ).toEqual(["Search", "Campaign"]); + }); + + it("does not call the empty referrer bucket Direct", () => { + // Direct means something narrower one tab over: `channel` calls a visit + // `campaign` whenever the link carried utm parameters, referrer or not, so + // a newsletter click sits in this bucket and under Campaign in the other. + render( + + ); + + expect(text(screen.getAllByRole("rowheader")[0])).toBe("No referrer"); + }); + + it("names each metric hint after the metric it explains", () => { + // Seventeen buttons all answering to "How this is measured" gave a screen + // reader's rotor seventeen identical entries. + render( + + ); + + expect( + screen.getByRole("button", { name: "Sources: how this is measured" }) + ).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "Language daily visitors: how this is measured", + }) + ).toBeTruthy(); + }); + + it("gives the Daily visitors hint a different name in every panel", () => { + // The column header is drawn once per table and there are fourteen tables + // on a populated dashboard, so a fixed `about="Daily visitors"` put + // fourteen identically-named buttons in the rotor — the defect MetricHint's + // `about` exists to remove, reintroduced by the one hint drawn in a loop. + render( + + ); + + // Every table, not only the visible one: the panels are kept mounted so + // each list holds its own expanded state, so the buttons in the hidden + // tabpanels are in the document too and land in the same rotor the moment + // their tab is opened. + const names = [...document.querySelectorAll("[aria-label]")].map((node) => + node.getAttribute("aria-label") + ); + + expect(names).toEqual([ + "Source daily visitors: how this is measured", + "Medium daily visitors: how this is measured", + "Campaign daily visitors: how this is measured", + ]); + expect(new Set(names).size).toBe(names.length); + }); + + it("gives every table an accessible name of its own", () => { + // 0 of 13 cards carried aria-label or aria-labelledby, 0 tables did, and + // there was not one
on the dashboard: a screen reader's table + // list showed seven unnamed tables in reading order. The CardTitle is a + // plain div with no programmatic association to the table under it. + render( + + ); + + expect([...document.querySelectorAll("table > caption")].map(text)).toEqual( + ["Campaigns: Source", "Campaigns: Medium"] + ); + }); + + it("does not say a single-dimension panel's name twice", () => { + render( + + ); + + expect([...document.querySelectorAll("table > caption")].map(text)).toEqual( + ["Pages"] + ); + }); +}); diff --git a/apps/web/app/modules/analytics/__tests__/loader.test.ts b/apps/web/app/modules/analytics/__tests__/loader.test.ts new file mode 100644 index 00000000..e72d1911 --- /dev/null +++ b/apps/web/app/modules/analytics/__tests__/loader.test.ts @@ -0,0 +1,583 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadDashboard, resolveFilters } from "../loader.server"; +import { isZoneName } from "../queries.server"; +import { RANGES } from "../range"; +import type { Breakdowns, Statistics } from "../types"; + +// What the panels contain is covered in metrics.test.ts. What is under test +// here is the window arithmetic, the shape the dashboard destructures, and that +// the loader asks for all of it at once. +vi.mock("../metrics.server", () => ({ + statistics: vi.fn(), + timeseries: vi.fn(), + breakdowns: vi.fn(), + customEvents: vi.fn(), +})); + +const metrics = await import("../metrics.server"); + +const url = (query: string) => new URL(`http://localhost/analytics${query}`); + +/** resolveFilters throws a Response; unwrap it so assertions stay unconditional. */ +const rejectionOf = (query: string) => { + try { + resolveFilters(url(query)); + } catch (error) { + return error; + } + + return undefined; +}; + +describe("resolveFilters", () => { + it("defaults to the last 24 hours in UTC", () => { + const filters = resolveFilters(url("")); + + expect(filters.range).toBe("LAST_24_HOURS"); + expect(filters.unit).toBe("hour"); + expect(filters.tz).toBe("UTC"); + }); + + it("falls back to the default range for an unknown range key", () => { + expect(resolveFilters(url("?range=LAST_CENTURY")).range).toBe( + "LAST_24_HOURS" + ); + }); + + it("uses a day bucket for the multi-day ranges", () => { + expect(resolveFilters(url("?range=LAST_7_DAYS")).unit).toBe("day"); + expect(resolveFilters(url("?range=LAST_30_DAYS")).unit).toBe("day"); + }); + + it("keeps a valid IANA timezone", () => { + expect(resolveFilters(url("?tz=Europe/Rome")).tz).toBe("Europe/Rome"); + }); + + it("rejects a timezone that isn't a real zone with a 400", () => { + // Guards the query that previously interpolated tz straight into SQL. + const rejection = rejectionOf( + "?tz=UTC%27%3B%20DROP%20TABLE%20events%3B%20--" + ); + + expect(rejection).toBeInstanceOf(Response); + expect((rejection as Response).status).toBe(400); + }); + + it("produces a start before the end for every range", () => { + for (const range of Object.keys(RANGES)) { + const { from, to } = resolveFilters(url(`?range=${range}`)); + + expect(from).toBeLessThan(to); + } + }); +}); + +describe("resolveFilters with an explicit window", () => { + const day = 86_400_000; + const from = Date.UTC(2026, 0, 1); + const to = Date.UTC(2026, 0, 8); + + it("takes from/to as epoch milliseconds", () => { + const filters = resolveFilters(url(`?from=${from}&to=${to}`)); + + expect(filters.range).toBe("CUSTOM"); + expect(filters.from).toBe(from); + expect(filters.to).toBe(to); + }); + + it("puts the comparison window immediately before it", () => { + const { previous } = resolveFilters(url(`?from=${from}&to=${to}`)); + + expect(previous.end).toBe(from); + expect(previous.start).toBe(from - (to - from)); + }); + + it("buckets by hour up to two days and by day beyond that", () => { + expect(resolveFilters(url(`?from=${from}&to=${from + day}`)).unit).toBe( + "hour" + ); + expect(resolveFilters(url(`?from=${from}&to=${from + 3 * day}`)).unit).toBe( + "day" + ); + }); + + it("clips an end in the future back to now", () => { + const ahead = Date.now() + 2 * day; + const clipped = resolveFilters( + url(`?from=${Date.now() - day}&to=${ahead}`) + ); + + expect(clipped.to).toBeLessThan(ahead); + }); + + it("overrides a range that is also in the URL", () => { + const filters = resolveFilters( + url(`?range=LAST_30_DAYS&from=${from}&to=${to}`) + ); + + expect(filters.range).toBe("CUSTOM"); + expect(filters.from).toBe(from); + }); + + it.each([ + ["a half written pair", `?from=${from}`], + ["a non-numeric bound", `?from=nope&to=${to}`], + ["a fractional bound", `?from=${from}.5&to=${to}`], + ["an inverted window", `?from=${to}&to=${from}`], + ["a window longer than a year", `?from=0&to=${to}`], + [ + "a window entirely in the future", + `?from=${Date.now() + day}&to=${Date.now() + 2 * day}`, + ], + ])("rejects %s with a 400", (_, query) => { + const rejection = rejectionOf(query); + + expect(rejection).toBeInstanceOf(Response); + expect((rejection as Response).status).toBe(400); + }); +}); + +/** + * The comparison window is the previous *calendar* span, not the previous + * `to - from` milliseconds, and the two differ on exactly the days a zone + * changes offset. Every window below is one the range picker can produce: + * `startOfZonedDay` to `endOfZonedDayExclusive` of a real transition day, in a + * zone whose offset moves by a whole hour off a whole hour, off a half hour and + * off a three-quarter hour. Every boundary here was checked against + * `timezone(zone, ts)` in Postgres, which is what the buckets are grouped by. + */ +describe("resolveFilters comparison window across a DST transition", () => { + const HOUR = 3_600_000; + + const DAYS = [ + { + what: "the 23-hour day New York springs forward", + tz: "America/New_York", + from: Date.UTC(2026, 2, 8, 5), // 2026-03-08 00:00 EST + to: Date.UTC(2026, 2, 9, 4), // 2026-03-09 00:00 EDT + hours: 23, + previousStart: Date.UTC(2026, 2, 7, 5), // 2026-03-07 00:00 EST + }, + { + what: "the 25-hour day New York falls back", + tz: "America/New_York", + from: Date.UTC(2026, 10, 1, 4), // 2026-11-01 00:00 EDT + to: Date.UTC(2026, 10, 2, 5), // 2026-11-02 00:00 EST + hours: 25, + previousStart: Date.UTC(2026, 9, 31, 4), // 2026-10-31 00:00 EDT + }, + { + what: "the 23-hour day Adelaide springs forward, half an hour off the hour", + tz: "Australia/Adelaide", + from: Date.UTC(2026, 9, 3, 14, 30), // 2026-10-04 00:00 ACST (+09:30) + to: Date.UTC(2026, 9, 4, 13, 30), // 2026-10-05 00:00 ACDT (+10:30) + hours: 23, + previousStart: Date.UTC(2026, 9, 2, 14, 30), + }, + { + what: "the 25-hour day Adelaide falls back, half an hour off the hour", + tz: "Australia/Adelaide", + from: Date.UTC(2026, 3, 4, 13, 30), // 2026-04-05 00:00 ACDT (+10:30) + to: Date.UTC(2026, 3, 5, 14, 30), // 2026-04-06 00:00 ACST (+09:30) + hours: 25, + previousStart: Date.UTC(2026, 3, 3, 13, 30), + }, + { + what: "the 23-hour day Chatham springs forward, 45 minutes off the hour", + tz: "Pacific/Chatham", + from: Date.UTC(2026, 8, 26, 11, 15), // 2026-09-27 00:00 +12:45 + to: Date.UTC(2026, 8, 27, 10, 15), // 2026-09-28 00:00 +13:45 + hours: 23, + previousStart: Date.UTC(2026, 8, 25, 11, 15), + }, + { + what: "the 25-hour day Chatham falls back, 45 minutes off the hour", + tz: "Pacific/Chatham", + from: Date.UTC(2026, 3, 4, 10, 15), // 2026-04-05 00:00 +13:45 + to: Date.UTC(2026, 3, 5, 11, 15), // 2026-04-06 00:00 +12:45 + hours: 25, + previousStart: Date.UTC(2026, 3, 3, 10, 15), + }, + ]; + + beforeEach(() => { + // Every transition above is in 2026 and the loader clips a window ending in + // the future back to now, so the clock is parked past all of them. + vi.useFakeTimers(); + vi.setSystemTime(Date.UTC(2027, 0, 1)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const windowFor = (tz: string, from: number, to: number) => + resolveFilters(url(`?tz=${encodeURIComponent(tz)}&from=${from}&to=${to}`)); + + it.each(DAYS)( + "compares $what against the whole calendar day before it", + ({ tz, from, to, hours, previousStart }) => { + const { previous } = windowFor(tz, from, to); + + // The window really is the short or long day, not a plain 24 hours. + expect(to - from).toBe(hours * HOUR); + + expect(previous.start).toBe(previousStart); + expect(from - previous.start).toBe(24 * HOUR); + } + ); + + it.each(DAYS)( + "no longer counts back $what in milliseconds", + ({ tz, from, to, hours }) => { + const { previous } = windowFor(tz, from, to); + + // What the old arithmetic answered: an hour short of the previous day and + // an hour adrift of its midnight, which is the ~4% phantom trend. + expect(previous.start).not.toBe(from - (to - from)); + expect(Math.abs(previous.start - (from - (to - from)))).toBe(HOUR); + expect(hours).not.toBe(24); + } + ); + + it.each(DAYS)( + "leaves no gap or overlap at $what's start", + ({ tz, from, to }) => { + const filters = windowFor(tz, from, to); + + // Half-open on both sides, so the shared instant belongs to the current + // window alone and no event is counted twice or dropped. + expect(filters.previous.end).toBe(filters.from); + expect(filters.previous.end).toBe(from); + expect(filters.to).toBe(to); + } + ); + + it("shifts a multi-day window by whole days, not by its own length", () => { + // 2026-03-02 through 2026-03-08 in New York: seven calendar days, 167 hours + // because the last of them is the short one. The seven before it are a full + // 168, and that is the comparison. + const from = Date.UTC(2026, 2, 2, 5); + const to = Date.UTC(2026, 2, 9, 4); + const { previous } = windowFor("America/New_York", from, to); + + expect(to - from).toBe(167 * HOUR); + expect(previous.start).toBe(Date.UTC(2026, 1, 23, 5)); + expect(from - previous.start).toBe(168 * HOUR); + }); + + it.each([ + ["Asia/Kolkata", Date.UTC(2026, 2, 7, 18, 30)], + ["Asia/Kathmandu", Date.UTC(2026, 2, 7, 18, 15)], + ])("still steps a plain day back in %s", (tz, from) => { + const to = from + 24 * HOUR; + const { previous } = windowFor(tz, from, to); + + // +05:30 and +05:45 never change offset, so every day is 24 hours and the + // calendar answer and the millisecond answer are the same one. + expect(previous.start).toBe(from - 24 * HOUR); + expect(previous.end).toBe(from); + }); + + it("compares a window shorter than a day against the hours before it", () => { + // No calendar day is spanned, so there is nothing to read off the calendar + // and the millisecond arithmetic is the right answer. + const from = Date.UTC(2026, 2, 8, 14); + const to = from + 3 * HOUR; + const { previous } = windowFor("America/New_York", from, to); + + expect(previous.start).toBe(from - 3 * HOUR); + expect(previous.end).toBe(from); + }); +}); + +/** + * The other half of the same question, and the half the block above cannot see. + * + * Every window up there runs midnight to midnight, which is the one shape the + * calendar shift is right about — and the shape the range picker produces. A + * preset is not that shape: "Last 24 hours" ends at whatever o'clock now is, so + * its two ends sit at different times of day, and shifting *those* by calendar + * days charges the comparison the length of a day the window may not even + * contain. A window is either drawn on calendar squares or measured in + * milliseconds, and the two are compared differently. + */ +describe("resolveFilters comparison window on a window that is not day-aligned", () => { + const HOUR = 3_600_000; + + const windowFor = (tz: string, from: number, to: number) => + resolveFilters(url(`?tz=${encodeURIComponent(tz)}&from=${from}&to=${to}`)); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(Date.UTC(2027, 0, 1)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("compares a rolling day against the day before it, not against 25 hours", () => { + // 2026-03-08 08:00 EDT -> 2026-03-09 08:00 EDT, the day after New York + // springs forward: 24 real hours, both ends at 08:00 local. Shifting each + // end back a calendar square and keeping the remainder as *elapsed* time + // since local midnight answered 25 hours here, because 08:00 on the 8th is + // seven hours into a day that lost one. A 24-hour window compared against + // 25 is a −4% trend on Pageviews, Daily visitors and Sessions that nothing + // in the data did. + const from = Date.UTC(2026, 2, 8, 12); + const to = Date.UTC(2026, 2, 9, 12); + const { previous } = windowFor("America/New_York", from, to); + + expect(to - from).toBe(24 * HOUR); + expect(previous.end).toBe(from); + expect(from - previous.start).toBe(24 * HOUR); + }); + + it("never hands back a comparison window that starts after it ends", () => { + // The degenerate case of the same arithmetic: the last hour of a 25-hour + // local day. `from` is 24 hours into that day and the day before it is only + // 24 long, so the shifted start overshot `from` and the window came back + // inverted — which `withinRange` matches with zero rows and no error, so + // every count tile rendered the label "New" and the rate tiles were + // compared against nothing at all. + const from = Date.UTC(2026, 10, 2, 4); // 2026-11-01 23:00 EST + const to = Date.UTC(2026, 10, 2, 5); // 2026-11-02 00:00 EST + const { previous } = windowFor("America/New_York", from, to); + + expect(previous.start).toBeLessThan(previous.end); + expect(previous.end - previous.start).toBe(to - from); + }); + + it.each([ + // A tail of a 25-hour day, ending at the local midnight that closes it. + ["America/New_York", Date.UTC(2026, 10, 2, 5)], + ["Europe/Berlin", Date.UTC(2026, 9, 25, 23)], + // A 23-hour day, where the shift errs the other way. + ["Australia/Adelaide", Date.UTC(2026, 9, 4, 13, 30)], + // An ordinary 24-hour day whose *predecessor* was 25 hours long: the window + // holds no transition and was still compared against two hours of traffic. + ["America/New_York", Date.UTC(2026, 10, 3, 5)], + ])( + "compares a sub-day window ending at midnight in %s against its own length", + (tz, midnight) => { + for (const minutes of [15, 30, 60, 120, 180]) { + const from = midnight - minutes * 60_000; + const { previous } = windowFor(tz, from, midnight); + + expect(previous.end).toBe(from); + expect(previous.end - previous.start).toBe(midnight - from); + } + } + ); +}); + +/** + * A preset is a duration ending now, and `RANGES[range].days` is documented as + * the length of the window rather than a count of calendar squares to step + * over. It was applied with date-fns `subDays`, which steps calendar squares in + * the *host's* zone: the label read "Last 24 hours" over 23 or 25 of them, and + * which one depended on the server rather than on `?tz`. + */ +describe("resolveFilters preset windows", () => { + const HOUR = 3_600_000; + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["a spring-forward day", Date.UTC(2026, 2, 8, 12)], + ["a fall-back day", Date.UTC(2026, 10, 1, 12)], + ])("measures exactly what its label says on %s", (_, now) => { + // The suite is pinned to TZ=UTC, under which a host-zone leak is invisible + // by construction — and the leak is the defect. On a server running + // America/New_York these two instants are the ones `subDays` got wrong: + // stepping a calendar square back off 07:00 lands on a wall clock at the + // other side of the transition, so "Last 24 hours" spanned 25 hours here + // and 23 on the March instant. The 24 consecutive hourly values of `to` + // around each transition are all affected, twice a year, in every zone a + // self-hoster might set. + const host = process.env.TZ; + + process.env.TZ = "America/New_York"; + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + for (const [key, range] of Object.entries(RANGES)) { + const { from, to } = resolveFilters(url(`?range=${key}`)); + + expect(to - from).toBe(range.days * 24 * HOUR); + } + } finally { + process.env.TZ = host; + } + }); + + it("stays a duration on the one instant a day it looks like a calendar day", () => { + // Once every 24 hours `now` lands on a local midnight and a preset resolves + // to a window shaped exactly like a picked calendar day. Reading the shape + // rather than being told which kind it is gave "Last 24 hours" a 23-hour + // comparison here — found against Postgres on uniform hourly traffic, at + // this instant and at no other in the fortnight around the transition. + vi.useFakeTimers(); + vi.setSystemTime(Date.UTC(2026, 2, 10, 4)); // 2026-03-10 00:00 EDT + + const { from, to, previous } = resolveFilters( + url("?range=LAST_24_HOURS&tz=America/New_York") + ); + + expect(to - from).toBe(24 * HOUR); + expect(previous.end - previous.start).toBe(24 * HOUR); + }); + + it("compares a preset against a window of the same length", () => { + // Both ends of a rolling window sit at the same time of day and neither is + // a midnight, so there is no calendar span to read: the comparison is the + // same duration laid end to end, and it tiles. + vi.useFakeTimers(); + vi.setSystemTime(Date.UTC(2026, 10, 2, 12)); + + for (const key of Object.keys(RANGES)) { + const { from, to, previous } = resolveFilters( + url(`?range=${key}&tz=America/New_York`) + ); + + expect(previous.end).toBe(from); + expect(previous.end - previous.start).toBe(to - from); + } + }); +}); + +describe("loadDashboard", () => { + const stats: Statistics = { + visits: 3, + uniqueVisits: 2, + sessions: 2, + bounces: 1, + avgDuration: 1000, + }; + + const panels = {} as Breakdowns; + + beforeEach(() => { + vi.mocked(metrics.statistics).mockResolvedValue(stats); + vi.mocked(metrics.timeseries).mockResolvedValue([]); + vi.mocked(metrics.breakdowns).mockResolvedValue(panels); + vi.mocked(metrics.customEvents).mockResolvedValue([]); + }); + + it("serves the dashboard every field it destructures", async () => { + const data = await loadDashboard("wid", url("?range=LAST_7_DAYS&tz=UTC")); + + expect(new Set(Object.keys(data))).toEqual( + new Set([ + "range", + "from", + "to", + "unit", + "tz", + "stats", + "previousStats", + "series", + "breakdowns", + "events", + ]) + ); + expect(data.breakdowns).toBe(panels); + expect(data.unit).toBe("day"); + }); + + it("asks for the previous window as well, so every figure can be a change", async () => { + const { from, to } = await loadDashboard("wid", url("?range=LAST_7_DAYS")); + + expect(vi.mocked(metrics.statistics).mock.calls).toEqual( + expect.arrayContaining([ + ["wid", { start: from, end: to }], + ["wid", { start: from - (to - from), end: from }], + ]) + ); + }); + + it("asks the panels and the goals for the window the filters resolved to", async () => { + const { from, to } = await loadDashboard("wid", url("?range=LAST_30_DAYS")); + + expect(vi.mocked(metrics.breakdowns)).toHaveBeenCalledWith("wid", { + start: from, + end: to, + }); + expect(vi.mocked(metrics.customEvents)).toHaveBeenCalledWith("wid", { + start: from, + end: to, + }); + }); + + it("hands the series the bucket and the zone, which only the loader knows", async () => { + const { from, to } = await loadDashboard( + "wid", + url("?range=LAST_7_DAYS&tz=Asia/Kolkata") + ); + + // The zone reaches Postgres as the operand of `AT TIME ZONE` and the unit + // as date_trunc's, so dropping either here silently rebuckets the chart. + expect(vi.mocked(metrics.timeseries)).toHaveBeenCalledWith("wid", { + start: from, + end: to, + unit: "day", + tz: "Asia/Kolkata", + }); + }); + + it("fans every panel out at once rather than one query after another", async () => { + let inFlight = 0; + let peak = 0; + + const tracked = + (value: T) => + async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + + await Promise.resolve(); + + inFlight -= 1; + + return value; + }; + + vi.mocked(metrics.statistics).mockImplementation(tracked(stats)); + vi.mocked(metrics.timeseries).mockImplementation(tracked([])); + vi.mocked(metrics.breakdowns).mockImplementation(tracked(panels)); + vi.mocked(metrics.customEvents).mockImplementation(tracked([])); + + await loadDashboard("wid", url("?range=LAST_7_DAYS")); + + // Both windows of the statistics, the series, the panels and the goals. + // Awaiting them in sequence would never put more than one in flight, and + // would cost the first paint five round trips instead of the longest one. + expect(peak).toBe(5); + }); +}); + +describe("isZoneName", () => { + it.each(["UTC", "Europe/Rome", "America/New_York", "Etc/GMT+5"])( + "accepts %s", + (tz) => { + expect(isZoneName(tz)).toBe(true); + } + ); + + it.each([ + "Not/AZone", + "'; DROP TABLE events; --", + "", + // Intl reads these as UTC+05:30 and UTC-08:00; Postgres reads the same two + // strings POSIX-style, which is the other sign. The chart would be + // relabelled by twice the offset with nothing to show for it. + "+05:30", + "-08:00", + ])("rejects %s", (tz) => { + expect(isZoneName(tz)).toBe(false); + }); +}); diff --git a/apps/web/app/modules/analytics/__tests__/metrics.test.ts b/apps/web/app/modules/analytics/__tests__/metrics.test.ts new file mode 100644 index 00000000..44f9dee5 --- /dev/null +++ b/apps/web/app/modules/analytics/__tests__/metrics.test.ts @@ -0,0 +1,665 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as metrics from "../metrics.server"; +import { BREAKDOWN_DIMENSIONS } from "../queries.server"; + +const { answers, statements } = vi.hoisted(() => ({ + /** SQL fragment identifying a query ⇒ the rows Postgres would answer with. */ + answers: new Map(), + statements: [] as { text: string; params: unknown[] }[], +})); + +/** + * The connection pool is the seam, not the query layer. + * + * Every metric is now a definition written in SQL — distinct visitors rather + * than a per-row flag, sessions rather than events — so stubbing + * `queries.server` would mock away the only place those definitions exist. + * Replacing `pg` instead leaves drizzle, the schema and the query layer + * entirely real: `statements` below is what the server would send to Postgres, + * and `answers` is what Postgres would send back. + * + * `types` has to stay the real export — drizzle's node-postgres driver reads + * `pg.types.builtins` while building the type parsers for every query. + */ +vi.mock("pg", async (importOriginal) => { + const actual = await importOriginal(); + + class RecordingPool { + /** db.server installs an idle-client error handler on the pool. */ + on() {} + + query(config: { text: string }, params: unknown[] = []) { + statements.push({ text: config.text, params }); + + for (const [fragment, rows] of answers) { + if (config.text.includes(fragment)) { + return Promise.resolve({ rows }); + } + } + + return Promise.resolve({ rows: [] }); + } + } + + const Pool = RecordingPool as unknown as typeof actual.Pool; + + return { ...actual, default: { ...actual, Pool }, Pool }; +}); + +beforeEach(() => { + statements.length = 0; + answers.clear(); +}); + +/** + * A fixture row is shaped like the statement that asks for it. Drizzle's query + * builder asks for `rowMode: "array"` and maps positionally, so those take the + * select list in order; the two statements written as `db.execute` (the + * timeseries and the goals) come back as objects keyed by column name. + */ +const answer = (fragment: string, rows: unknown[]) => + answers.set(fragment, rows); + +/** The single statement one metric call issues. */ +async function statementFor(run: () => Promise) { + await run(); + + expect(statements).toHaveLength(1); + + return statements[0]; +} + +/** + * Every `date_trunc($n, … AT TIME ZONE $m)` in a statement, with its parameters + * substituted. The bug class this whole module has been chasing is two + * truncations that were supposed to be the same one and weren't. + */ +function truncations({ text, params }: { text: string; params: unknown[] }) { + return [ + ...text.matchAll(/date_trunc\(\$(\d+), [^)]*AT TIME ZONE \$(\d+)\)/g), + ].map(([, unit, tz]) => [params[Number(unit) - 1], params[Number(tz) - 1]]); +} + +const window = { start: Date.UTC(2026, 0, 1), end: Date.UTC(2026, 0, 31) }; + +describe("timeseries", () => { + it("labels a naive bucket as UTC whatever the server's zone is", async () => { + // A bucket is `timestamp without time zone` — the wall clock in the zone + // the caller asked for, which the driver hands over as a naive string with + // no offset on it. `new Date("2026-01-02 00:00:00")` would read that in the + // *host's* zone and shift every label; the query layer pins it to UTC. + answer("generate_series(", [ + { ts: "2026-01-01 00:00:00", count: 0 }, + { ts: "2026-01-02 00:00:00", count: 7 }, + { ts: "2026-01-03 00:00:00", count: 0 }, + ]); + + const tokyo = process.env.TZ; + + // The suite is pinned to TZ=UTC, under which a host-zone leak is invisible + // by construction. Servers are not all UTC and the design's whole claim is + // that it does not matter, so this case states it where it can fail. + process.env.TZ = "Asia/Tokyo"; + + try { + const points = await metrics.timeseries("wid", { + start: Date.UTC(2026, 0, 1), + end: Date.UTC(2026, 0, 3), + unit: "day", + tz: "UTC", + }); + + expect(points.map((point) => point.timeseries)).toEqual([ + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + "2026-01-03T00:00:00.000Z", + ]); + expect(points.map((point) => point.count)).toEqual([0, 7, 0]); + } finally { + process.env.TZ = tokyo; + } + }); + + it("un-shifts a bucket a driver change would parse in the host's zone", async () => { + // drizzle's node-postgres session overrides the type parser for OID 1114 + // and hands back the raw string, which is why the branch above is the one + // that runs. Raw pg parses it in the host's zone instead, so a driver swap + // or a custom `types` option would deliver a Date that is silently an hour + // (or nine) off. Reading its local fields back out is what undoes that. + const tokyo = process.env.TZ; + + process.env.TZ = "Asia/Tokyo"; + + try { + answer("generate_series(", [ + { ts: new Date("2026-01-02T00:00:00"), count: 5 }, + ]); + + const [point] = await metrics.timeseries("wid", { + start: Date.UTC(2026, 0, 1), + end: Date.UTC(2026, 0, 3), + unit: "day", + tz: "UTC", + }); + + expect(point.timeseries).toBe("2026-01-02T00:00:00.000Z"); + } finally { + process.env.TZ = tokyo; + } + }); + + it("generates the empty buckets from the expression that counts them", async () => { + // The padding used to be stepped in JS from a zone offset this process + // derived itself, which is a second implementation of Postgres' calendar: + // it returned NaN for 37 zones (blank chart), and it sampled one offset for + // the whole window, so a window containing a DST change generated a series + // that stopped one bucket short and the newest bucket's views were dropped. + // Both sides now truncate through the same expression on the same + // parameters, which is the property that makes a disagreement impossible. + const statement = await statementFor(() => + metrics.timeseries("wid", { ...window, unit: "day", tz: "Europe/Rome" }) + ); + + expect(statement.text).toContain("generate_series("); + expect(truncations(statement)).toEqual([ + ["day", "Europe/Rome"], + ["day", "Europe/Rome"], + ["day", "Europe/Rome"], + ]); + // And the step is the same unit again, so a bucket is a local day rather + // than a fixed 86_400_000ms that a transition knocks off the hour. + expect(statement.text).toContain(`('1 ' || $`); + }); + + it("asks for a half-open window, the same one the statistics use", async () => { + const { text } = await statementFor(() => + metrics.timeseries("wid", { + start: 0, + end: 1_000, + unit: "hour", + tz: "UTC", + }) + ); + + expect(text).toContain(`"events"."created_at" >= $`); + expect(text).toContain(`"events"."created_at" < $`); + expect(text).not.toContain(`"events"."created_at" <= $`); + }); + + it("rejects a unit it cannot bucket", async () => { + await expect( + metrics.timeseries("wid", { + start: Date.UTC(2026, 0, 1), + end: Date.UTC(2026, 0, 2), + unit: "fortnight", + tz: "UTC", + }) + ).rejects.toThrow(/Invalid unit/); + }); + + it("rejects a numeric zone, which the two sides sign oppositely", async () => { + // Intl reads `+05:30` as UTC+05:30 and Postgres reads it as UTC-05:30, + // because a bare numeric zone is POSIX. Nothing downstream can reconcile + // the two, and no picker and no browser produces one, so it is not a zone. + await expect( + metrics.timeseries("wid", { ...window, unit: "day", tz: "+05:30" }) + ).rejects.toThrow(/Invalid time zone/); + + await expect( + metrics.timeseries("wid", { ...window, unit: "day", tz: "Asia/Kolkata" }) + ).resolves.toEqual([]); + }); +}); + +describe("breakdowns", () => { + it("fills a panel for every breakdown the dashboard declares", async () => { + const panels = await metrics.breakdowns("wid", window); + + expect(Object.keys(panels)).toEqual([...BREAKDOWN_DIMENSIONS]); + expect(statements).toHaveLength(BREAKDOWN_DIMENSIONS.length); + }); + + it("groups the country panel on geography and the locale panel on language", async () => { + // The bug this schema change exists to fix: `countries` was fed the locale + // breakdown, so the panel was a list of browser languages wearing a flag. + await metrics.breakdowns("wid", window); + + const columns = statements.map( + (statement) => /coalesce\("(\w+)"/.exec(statement.text)?.[1] + ); + + expect(columns).toContain("country"); + expect(columns).toContain("locale"); + }); + + it("resolves locale tags to a language and its region", async () => { + answer(`coalesce("locale"`, [["en-US", 5, 3]]); + + const { locales } = await metrics.breakdowns("wid", window); + + expect(locales.rows).toEqual([ + { element: "English (United States)", count: 5, unique: 3 }, + ]); + }); + + it("leaves a language tag with no region unqualified", async () => { + answer(`coalesce("locale"`, [["en", 1, 1]]); + + const { locales } = await metrics.breakdowns("wid", window); + + expect(locales.rows[0].element).toBe("English"); + }); + + it("falls back to the raw tag for unknown locales", async () => { + answer(`coalesce("locale"`, [ + ["zz-ZZ", 1, 1], + ["", 1, 1], + ]); + + const { locales } = await metrics.breakdowns("wid", window); + + // The empty bucket is the one the panels label "Unknown"; it has to survive + // the mapping rather than become the string "undefined". + expect(locales.rows.map((row) => row.element)).toEqual(["zz-ZZ", ""]); + }); + + it("passes every other dimension through untouched", async () => { + answer(`coalesce("browser"`, [["Chrome", 4, 2]]); + // Raw ISO-3166; names and flags are the dashboard's to render, and the + // panel must not be handed a locale-codes location by mistake again. + answer(`coalesce("country"`, [["US", 4, 2]]); + + const panels = await metrics.breakdowns("wid", window); + + expect(panels.browsers.rows).toEqual([ + { element: "Chrome", count: 4, unique: 2 }, + ]); + expect(panels.countries.rows).toEqual([ + { element: "US", count: 4, unique: 2 }, + ]); + }); + + it("groups the channel panel that nothing used to ask for", async () => { + // Resolved at ingest since the schema change and rendered nowhere: direct / + // search / social / referral / campaign is the one acquisition view that is + // complete, because every arrival lands in exactly one of the five. + answer(`coalesce("channel"`, [["search", 9, 8]]); + + const { channels } = await metrics.breakdowns("wid", window); + + expect(channels).toEqual({ + unit: "sessions", + rows: [{ element: "search", count: 9, unique: 8 }], + }); + }); +}); + +/** + * Acquisition is a property of an arrival. + * + * `referrer_host` is only ever set on the pageview that opened a visit — the + * tracker reads `document.referrer` once per document, and ingest nulls + * self-referrals — and `channel` is derived per event from that same referrer, + * so pageviews 2..N are classified `direct` for the same reason. Grouped over + * every pageview in the window these reported close to the opposite of the + * truth: measured on a 494k-pageview / 240k-session fixture at the 30 day + * preset, the empty referrer bucket held 66.3% of the panel against a real 30.0% + * and google.com's share was 10.7% against a real 22.2%, with `channel` calling + * 65% of traffic direct where 25.5% of visits were. + */ +describe("acquisition scope", () => { + /** The dimensions whose value only exists on the session's first pageview. */ + const ACQUISITION = [ + "referrers", + "channels", + "utmSources", + "utmMediums", + "utmCampaigns", + "utmTerms", + "utmContents", + ] as const; + + /** Facts about the view itself, recorded on every one of them. */ + const TECHNOLOGY = [ + "pages", + "browsers", + "os", + "devices", + "countries", + "locales", + ] as const; + + it("covers every dimension the dashboard declares", () => { + // A dimension added to `Breakdowns` and left out of both lists would + // otherwise be a panel nothing in this file has an opinion about. + expect(new Set([...ACQUISITION, ...TECHNOLOGY])).toEqual( + new Set(BREAKDOWN_DIMENSIONS) + ); + }); + + it("counts arrivals for the acquisition dimensions and views for the rest", async () => { + const panels = await metrics.breakdowns("wid", window); + + for (const dimension of ACQUISITION) { + expect(panels[dimension].unit).toBe("sessions"); + } + + for (const dimension of TECHNOLOGY) { + expect(panels[dimension].unit).toBe("views"); + } + }); + + it("scopes exactly those dimensions to the session's first pageview", async () => { + await metrics.breakdowns("wid", window); + + // is_new_session is set by ingest on a session's opening pageview and + // nowhere else — custom events never carry it — so it *is* the arrival. + const scoped = statements + .filter((statement) => statement.text.includes(`"is_new_session" = $`)) + .map((statement) => /coalesce\("(\w+)"/.exec(statement.text)?.[1]); + + expect(new Set(scoped)).toEqual( + new Set([ + "channel", + "referrer_host", + "utm_campaign", + "utm_content", + "utm_medium", + "utm_source", + "utm_term", + ]) + ); + expect(scoped).toHaveLength(ACQUISITION.length); + expect(statements).toHaveLength(BREAKDOWN_DIMENSIONS.length); + }); + + it("leaves the technology dimensions counting pageviews", async () => { + await metrics.breakdowns("wid", window); + + const unscoped = statements + .filter((statement) => !statement.text.includes("is_new_session")) + .map((statement) => /coalesce\("(\w+)"/.exec(statement.text)?.[1]); + + // Which pages were read, on which browser, from where. Those are facts + // about the view, they are on every row, and narrowing them to arrivals + // would throw away the answer rather than correct it. + expect(new Set(unscoped)).toEqual( + new Set(["browser", "country", "device", "locale", "os", "path"]) + ); + expect(unscoped).toHaveLength(TECHNOLOGY.length); + }); + + it("keeps the type qual on the acquisition panels too", async () => { + // is_new_session is in no index and must stay out of one (ADDENDUM v2 §D: + // an index over it would cost this table its HOT updates). It is a heap + // filter on rows the (website_id, type, created_at) range scan has already + // fetched, which only holds while `type` is still named — measured warm on + // 494k pageviews: 7d 25.3ms -> 19.2ms, identical bitmap index scan and + // identical 2341 buffers, with the filter removing 27159 of 52358 rows. + await metrics.breakdowns("wid", window); + + for (const { text, params } of statements) { + expect(text).toContain(`"events"."type" = $`); + expect(params).toContain("pageview"); + } + }); +}); + +/** + * Null is an answer for most dimensions and the absence of one for the five utm + * columns, and the difference is what the Campaigns card is a list of. + * + * Grouped with the rest, every visit that arrived without campaign parameters — + * on a normal site, nearly all of them — collapsed into one bucket the panel + * labelled "Unknown". It sorted first, so it was also the bar every real + * campaign's share was drawn against; its Daily visitors column was the site's + * whole audience under a card headed Campaigns; and the word invited the reader + * to take "arrived without a campaign" for "a campaign we could not attribute". + */ +describe("the empty bucket", () => { + const OMITTED = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + ]; + + const COUNTED = [ + "path", + "referrer_host", + "channel", + "browser", + "os", + "device", + "country", + "locale", + ]; + + /** The column each statement groups on, in the order the panels are asked. */ + const columnsOf = () => + statements.map( + (statement) => /coalesce\("(\w+)"/.exec(statement.text)?.[1] ?? "" + ); + + it("drops it from the campaign dimensions and keeps it everywhere else", async () => { + await metrics.breakdowns("wid", window); + + const filtered = statements + .map( + (statement) => + /coalesce\("events"\."(\w+)", ''\) <> ''/.exec(statement.text)?.[1] + ) + .filter((column): column is string => column !== undefined); + + expect(new Set(filtered)).toEqual(new Set(OMITTED)); + expect(new Set(columnsOf())).toEqual(new Set([...OMITTED, ...COUNTED])); + }); + + it("filters on the same expression the bucket is grouped by", async () => { + await metrics.breakdowns("wid", window); + + // `is not null` would be a second spelling of the bucket, and the two + // disagree about a legacy row holding '' — which the panel would then draw + // as the empty bucket the filter exists to remove. + for (const { text } of statements) { + expect(text).not.toContain("is not null"); + } + + // And it is the bucket's own expression, so the two cannot drift: the + // column the filter names is the column the panel groups on, every time. + const pairs = statements + .map(({ text }) => ({ + bucket: /coalesce\("(\w+)", ''\) as "element"/.exec(text)?.[1], + filtered: /coalesce\("events"\."(\w+)", ''\) <> ''/.exec(text)?.[1], + })) + .filter((pair) => pair.filtered !== undefined); + + expect(pairs).toHaveLength(OMITTED.length); + expect(pairs.map((pair) => pair.filtered)).toEqual( + pairs.map((pair) => pair.bucket) + ); + }); + + it("leaves the utm panels counting sessions", async () => { + // The scope is unchanged: still one row per arrival, now only the arrivals + // that carried the parameter the panel lists. + answer(`coalesce("utm_source"`, [["newsletter", 9, 8]]); + + const { utmSources } = await metrics.breakdowns("wid", window); + + expect(utmSources).toEqual({ + unit: "sessions", + rows: [{ element: "newsletter", count: 9, unique: 8 }], + }); + }); +}); + +/** + * What the numbers mean. Postgres computes them, so the statement it is handed + * is where the definition lives and the only thing there is to assert. + * + * The figures quoted below were measured against Postgres 16 on a fixture of + * nine pageviews and two custom events: one visitor returning on three separate + * days, one three-page session, one session whose bounce flag was never + * cleared, and a custom event carrying its own visitor, session and duration. + */ +describe("metric semantics", () => { + it("counts unique visitors as distinct ids, not rows carrying a flag", async () => { + const { text } = await statementFor(() => + metrics.statistics("wid", window) + ); + + expect(text).toContain(`count(DISTINCT "visitor_id")::int`); + // is_new_visitor means "first hit of this visitor's UTC day". Counting the + // rows that carry it made the returning reader three visitors, and grew the + // headline number with the length of the window instead of the audience: + // 4 against the fixture's 2. + expect(text).not.toContain("is_new_visitor"); + }); + + it("counts bounces in sessions, so the rate divides by a like quantity", async () => { + const { text } = await statementFor(() => + metrics.statistics("wid", window) + ); + + expect(text).toContain(`count(DISTINCT "session_id")::int`); + expect(text).toContain( + `count(DISTINCT "session_id") FILTER (WHERE "is_a_bounce")::int` + ); + // Numerator and denominator both count sessions, so the ratio cannot exceed + // one. Counting flagged rows against sessions could and did: a single + // session that kept a stale flag on three pageviews reported 6 bounces over + // 5 sessions on the fixture — a bounce rate of 120%. + expect(text).not.toContain(`count(*) FILTER (WHERE "is_a_bounce")`); + expect(text).not.toContain("is_new_session"); + }); + + it("averages visit duration over sessions rather than over pageviews", async () => { + const { text } = await statementFor(() => + metrics.statistics("wid", window) + ); + + expect(text).toContain( + `sum("duration") / nullif(count(DISTINCT "session_id") FILTER (WHERE "duration" IS NOT NULL), 0)` + ); + // A five-page visit is one visit. avg() over rows weighted every session by + // how many pages it had and reported 1800ms where the fixture's sessions + // averaged 3000ms. The FILTER is what keeps a session whose beacon never + // arrived out of the denominator instead of scoring it zero. + expect(text).not.toContain(`avg("duration")`); + }); + + it("asks Postgres for pageviews only, everywhere but the goals panel", async () => { + await metrics.statistics("wid", window); + await metrics.timeseries("wid", { ...window, unit: "day", tz: "UTC" }); + await metrics.breakdowns("wid", window); + + expect(statements).toHaveLength(2 + BREAKDOWN_DIMENSIONS.length); + + for (const { text, params } of statements) { + // Naming `type` is not only what stops a site's own aurora() calls from + // inflating its pageview count. The dashboard index is + // (website_id, type, created_at) and Postgres 16 has no skip scan, so + // leaving the qual out demotes it to a heap filter over the whole window. + expect(text).toContain(`"events"."type" = $`); + expect(params).toContain("pageview"); + } + }); + + it("counts distinct visitors by grouping on them, not by ordering them", async () => { + await metrics.breakdowns("wid", window); + + const { text } = statements[0]; + + // A DISTINCT aggregate is an *ordered* aggregate, and one of those turns + // off hash aggregation and parallelism for the whole node: every panel + // became a GroupAggregate over a full sort of the window, spilling to disk, + // twelve times per render. Grouping by (element, visitor_id) and counting + // the groups is the same question asked in a shape Postgres can hash — + // measured at 125ms to 25ms per panel on 588k events at the 7 day preset. + expect(text).toContain(`group by 1, 2`); + expect(text).toContain(`count(*)::int`); + expect(text).not.toContain(`count(DISTINCT`); + }); + + it("keeps a revenue total per currency instead of adding them together", async () => { + answer("jsonb_agg(", [ + { + name: "checkout", + count: 3, + unique: 2, + revenue: [ + { currency: "EUR", total: 49 }, + { currency: "USD", total: 10 }, + ], + }, + ]); + + const [goal] = await metrics.customEvents("wid", window); + + // 49.00 EUR + 10.00 USD was reported as one figure of 59, which is a + // quantity in no unit at all: ingest stores `currency` next to every + // amount, and the row it fed carried no way to tell that it had been lost. + expect(goal.revenue).toEqual([ + { currency: "EUR", total: 49 }, + { currency: "USD", total: 10 }, + ]); + }); + + it("counts custom events on their own, which is why nothing else counts them", async () => { + const { text, params } = await statementFor(() => + metrics.customEvents("wid", window) + ); + + expect(params).toContain("event"); + expect(params).not.toContain("pageview"); + // revenue is numeric(14,2) and sum(numeric) arrives from pg as a string, so + // the annotation is a claim and the cast is the conversion. Without it the + // dashboard adds "49.00" to a total by concatenating it. + expect(text).toContain(`sum("events"."revenue")::float8`); + // Grouped by the currency as well as the goal, which is what makes the + // per-currency totals above possible rather than a JS-side guess. + expect(text).toContain(`"events"."currency" as currency`); + }); + + it("bounds every panel so an unbounded tail cannot be serialised into the page", async () => { + const { text, params } = await statementFor(() => + metrics.customEvents("wid", window) + ); + + expect(text).toContain("limit $"); + // Ordering by count alone leaves the rows at the cut in an order Postgres + // is free to change, and the panel reshuffles between two renders of the + // same window. + expect(text).toContain("order by count(*) desc, 1 asc"); + expect(params).toContain(100); + }); + + it("keeps a zero bound instead of dropping the predicate that carries it", async () => { + const { text, params } = await statementFor(() => + metrics.statistics("wid", { start: -1_000, end: 0 }) + ); + + // `0` is a real epoch millisecond and a falsy JS number. Testing the raw + // value for truthiness dropped the bound, so `?from=-1000&to=0` asked for + // one second of 1970 and was answered with the site's lifetime totals — + // anonymously, on the public dashboard, past the loader's span cap. + expect(text).toContain(`"events"."created_at" < $`); + expect(params).toContain(new Date(0).toISOString()); + expect(params).toContain(new Date(-1_000).toISOString()); + }); + + it("bounds every window half-open, so two adjacent windows tile it", async () => { + const { text } = await statementFor(() => + metrics.statistics("wid", window) + ); + + // The comparison window ends exactly where this one starts. While both + // ends were inclusive, an event landing on that instant was counted in + // both, and every trend arrow was a comparison of overlapping sets. + expect(text).toContain(`"events"."created_at" >= $`); + expect(text).toContain(`"events"."created_at" < $`); + expect(text).not.toContain(`"events"."created_at" <= $`); + }); +}); diff --git a/apps/web/app/modules/analytics/__tests__/timezone.test.ts b/apps/web/app/modules/analytics/__tests__/timezone.test.ts new file mode 100644 index 00000000..84aae497 --- /dev/null +++ b/apps/web/app/modules/analytics/__tests__/timezone.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it } from "vitest"; +import { + bucketsWithin, + canonicalTimeZone, + endOfZonedDayExclusive, + isValidTimeZone, + listTimeZones, + startOfZonedDay, + zonedCalendarDay, + zonedWallClock, +} from "../timezone"; + +/** The calendar hands back local-field Dates; the suite runs with TZ=UTC. */ +const picked = (year: number, month: number, day: number) => + new Date(year, month - 1, day); + +describe("startOfZonedDay", () => { + it("snaps to midnight in the charted zone, not the browser's", () => { + // Aug 1 in Rome is CEST, so its midnight is 22:00 the previous day in UTC. + // date-fns startOfDay would answer Aug 1 00:00 UTC — two hours of Aug 1 in + // the zone the chart is bucketed by, missing from the window. + expect(startOfZonedDay(picked(2026, 8, 1), "Europe/Rome")).toBe( + Date.UTC(2026, 6, 31, 22) + ); + expect(startOfZonedDay(picked(2026, 8, 1), "America/New_York")).toBe( + Date.UTC(2026, 7, 1, 4) + ); + expect(startOfZonedDay(picked(2026, 8, 1), "Asia/Tokyo")).toBe( + Date.UTC(2026, 6, 31, 15) + ); + }); + + it("uses the offset in force on the day, not one sampled elsewhere", () => { + // Rome is UTC+1 in January and UTC+2 in August. A single offset for the + // whole year is wrong for half of it. + expect(startOfZonedDay(picked(2026, 1, 15), "Europe/Rome")).toBe( + Date.UTC(2026, 0, 14, 23) + ); + }); + + it("resolves the midnight that DST skips forward", () => { + // Santiago starts DST at 24:00 on the first Saturday of September, so + // 2026-09-06 has no 00:00 at all — it opens at 01:00. The first instant of + // the day is what the window has to start at, and 01:00 CLST is 04:00Z. + // + // This asserted 03:00Z, which is 23:00 on the *fifth* — the answer a + // backwards resolution gives, and an hour of the previous day inside a + // window the button labels as one. Postgres resolves the same clock + // forward, both in `timestamp AT TIME ZONE` and in the `date_trunc` the + // buckets are grouped by, so the two have to meet here or the chart is + // drawn against a day the stats were not counted over. + expect(startOfZonedDay(picked(2026, 9, 6), "America/Santiago")).toBe( + Date.UTC(2026, 8, 6, 4) + ); + }); + + it.each([ + ["America/Havana", 2026, 3, 8, Date.UTC(2026, 2, 8, 5)], + ["Atlantic/Azores", 2026, 3, 29, Date.UTC(2026, 2, 29, 1)], + ])( + "starts %s's %i-%i-%i at the first instant the day has", + (tz, year, month, day, expected) => { + // The other two zones the picker offers whose transition opens at 00:00. + // Each read as the previous day's 23:00 before the gap was resolved + // forward, which is what made a single click on the transition day label + // itself "Mar 7 - Mar 8" and, on reopening the picker, apply as 47 hours. + expect(startOfZonedDay(picked(year, month, day), tz)).toBe(expected); + } + ); + + it("keeps a gap day exactly one hour short", () => { + // The consequence the window has to show: Havana's 2026-03-08 is 23 hours, + // not the 24 a backwards midnight made it. + const start = startOfZonedDay(picked(2026, 3, 8), "America/Havana"); + const end = endOfZonedDayExclusive(picked(2026, 3, 8), "America/Havana"); + + expect(end - start).toBe(82_800_000); + }); + + it("still takes the first of two instants when midnight is ambiguous", () => { + // Havana ends DST at 01:00, so 2025-11-02 has one midnight and the day is + // 25 hours. Only the *gap* resolution changed; this one already agreed with + // Postgres and has to keep agreeing. + const start = startOfZonedDay(picked(2025, 11, 2), "America/Havana"); + const end = endOfZonedDayExclusive(picked(2025, 11, 2), "America/Havana"); + + expect(start).toBe(Date.UTC(2025, 10, 2, 4)); + expect(end - start).toBe(90_000_000); + }); +}); + +describe("endOfZonedDayExclusive", () => { + it("ends on the next day's first instant in that zone", () => { + // Not 23:59:59.999. The query layer's range predicate is half-open, so the + // boundary belongs to the *next* window and this one stops just short of + // it; the last-millisecond form left that millisecond in neither. + expect(endOfZonedDayExclusive(picked(2026, 8, 1), "Europe/Rome")).toBe( + Date.UTC(2026, 7, 1, 22) + ); + expect(endOfZonedDayExclusive(picked(2026, 8, 1), "Asia/Tokyo")).toBe( + Date.UTC(2026, 7, 1, 15) + ); + }); + + it("meets the next day's start exactly, so windows tile", () => { + // The whole point: consecutive days share one instant, which is a member of + // the later window only. A gap of a millisecond here is an event nothing + // counts; an overlap is an event counted twice. + expect(endOfZonedDayExclusive(picked(2026, 8, 1), "Europe/Rome")).toBe( + startOfZonedDay(picked(2026, 8, 2), "Europe/Rome") + ); + }); + + it("rolls over the end of a month", () => { + expect(endOfZonedDayExclusive(picked(2026, 8, 31), "Europe/Rome")).toBe( + startOfZonedDay(picked(2026, 9, 1), "Europe/Rome") + ); + }); + + it("spans exactly a day across one with no transition in it", () => { + const start = startOfZonedDay(picked(2026, 8, 1), "Europe/Rome"); + const end = endOfZonedDayExclusive(picked(2026, 8, 1), "Europe/Rome"); + + // Exactly 86_400_000, which is what makes `previous = [from - span, from)` + // the calendar day before rather than a day shifted off its own midnight. + expect(end - start).toBe(86_400_000); + }); + + it("spans an hour less across a spring-forward day", () => { + // The picker's label says "Mar 29" either way; the window behind it is a + // 23 hour day, which is the answer Postgres buckets to as well. + const start = startOfZonedDay(picked(2026, 3, 29), "Europe/Rome"); + const end = endOfZonedDayExclusive(picked(2026, 3, 29), "Europe/Rome"); + + expect(end - start).toBe(82_800_000); + }); + + it("spans an hour more across a fall-back day", () => { + const start = startOfZonedDay(picked(2026, 10, 25), "Europe/Rome"); + const end = endOfZonedDayExclusive(picked(2026, 10, 25), "Europe/Rome"); + + expect(end - start).toBe(90_000_000); + }); + + it("ends a day whose own midnight does not exist", () => { + // Beirut's 2026-03-29 opens at 01:00 and is 23 hours long; the day after it + // is ordinary. Both edges have to come from the zone, not from arithmetic. + const start = startOfZonedDay(picked(2026, 3, 29), "Asia/Beirut"); + const end = endOfZonedDayExclusive(picked(2026, 3, 29), "Asia/Beirut"); + + expect(start).toBe(Date.UTC(2026, 2, 28, 22)); + expect(end).toBe(Date.UTC(2026, 2, 29, 21)); + }); +}); + +describe("zonedCalendarDay", () => { + it("round-trips the window back to the squares that produced it", () => { + // Reopening the picker must not move the selection: the seeded day has to + // be the one Apply would send back. + const from = startOfZonedDay(picked(2026, 8, 1), "Asia/Tokyo"); + const day = zonedCalendarDay(from, "Asia/Tokyo"); + + expect([day.getFullYear(), day.getMonth() + 1, day.getDate()]).toEqual([ + 2026, 8, 1, + ]); + expect(startOfZonedDay(day, "Asia/Tokyo")).toBe(from); + }); + + it("reads the instant in the charted zone, which can be another date", () => { + // 23:30 UTC is already the next day in Tokyo, and the previous one in + // Los Angeles. The calendar square depends on the zone, not on the host. + const at = Date.UTC(2026, 7, 1, 23, 30); + + expect(zonedCalendarDay(at, "Asia/Tokyo").getDate()).toBe(2); + expect(zonedCalendarDay(at, "America/Los_Angeles").getDate()).toBe(1); + }); + + it("seeds the last square from the instant before an exclusive end", () => { + // How the picker reopens on the window it sent. The boundary itself is + // already the next day, so reading it directly would move the selection + // forward one square every time the popover was opened. + const to = endOfZonedDayExclusive(picked(2026, 8, 1), "Asia/Tokyo"); + + expect(zonedCalendarDay(to, "Asia/Tokyo").getDate()).toBe(2); + expect(zonedCalendarDay(to - 1, "Asia/Tokyo").getDate()).toBe(1); + }); +}); + +describe("zonedWallClock", () => { + it("relabels an instant as the clock it reads in the zone", () => { + // The space the chart's buckets live in: wall clock in `tz`, labelled UTC. + expect(zonedWallClock(Date.UTC(2026, 7, 1, 22), "Europe/Rome")).toBe( + Date.UTC(2026, 7, 2) + ); + expect(zonedWallClock(Date.UTC(2026, 7, 1, 22), "UTC")).toBe( + Date.UTC(2026, 7, 1, 22) + ); + }); + + it("puts a window's exclusive end on the bucket that follows the last one", () => { + // Which is what lets the dashboard drop the empty bucket generate_series + // pads onto the end of a whole-day range. + const to = endOfZonedDayExclusive(picked(2026, 8, 1), "America/New_York"); + + expect(zonedWallClock(to, "America/New_York")).toBe(Date.UTC(2026, 7, 2)); + }); +}); + +/** A bucket as the query hands it over: wall clock in `tz`, labelled UTC. */ +const bucket = (iso: string) => ({ timeseries: iso, count: 0 }); + +describe("bucketsWithin", () => { + it("drops the empty bucket the padding adds past an exclusive end", () => { + // A single day picked in New York: the series is padded to + // date_trunc('hour', to), and `to` is the next day's midnight, so Postgres + // returns a 25th bucket that the counts beside it can never fill. + const to = endOfZonedDayExclusive(new Date(2026, 7, 1), "America/New_York"); + + const series = [ + bucket("2026-08-01T23:00:00.000Z"), + bucket("2026-08-02T00:00:00.000Z"), + ]; + + expect(bucketsWithin(series, to, "America/New_York")).toEqual([series[0]]); + }); + + it("keeps the partial bucket a preset's window ends inside", () => { + // Presets end at `now`, mid-bucket. That bucket is the one with today's + // traffic in it and dropping it would blank the right-hand end of the chart. + const to = Date.parse("2026-08-04T17:42:00.000Z"); + const series = [ + bucket("2026-08-04T14:00:00.000Z"), + bucket("2026-08-04T15:00:00.000Z"), + ]; + + expect(bucketsWithin(series, to, "Europe/Rome")).toEqual(series); + }); + + it("compares in the charted zone rather than against the raw instant", () => { + // 19:00 in Rome is 17:00 UTC. Comparing the bucket label — a wall clock — + // against the instant would cut the last two hours off every European + // afternoon and be invisible to anyone testing from UTC. + const to = Date.parse("2026-08-04T17:42:00.000Z"); + const series = [ + bucket("2026-08-04T18:00:00.000Z"), + bucket("2026-08-04T19:00:00.000Z"), + bucket("2026-08-04T20:00:00.000Z"), + ]; + + expect(bucketsWithin(series, to, "Europe/Rome")).toEqual([ + series[0], + series[1], + ]); + }); + + it("leaves a window that ends on no bucket at all alone", () => { + const series = [bucket("2026-08-01T00:00:00.000Z")]; + + expect( + bucketsWithin(series, Date.parse("2026-09-01T00:00:00Z"), "UTC") + ).toEqual(series); + }); +}); + +/** + * Verified against postgres:16 (docker-compose.yml): the runtime offers these 18 + * names, Postgres refuses every one of them with `time zone "…" not recognized`, + * and every name on the right is accepted. The full 419-name list `listTimeZones` + * produces after substitution was checked the same way — all accepted, and all + * agreeing with Intl on the wall clock in January and in July. + */ +const LEGACY_ALIASES: [string, string][] = [ + ["Africa/Asmera", "Africa/Asmara"], + ["America/Buenos_Aires", "America/Argentina/Buenos_Aires"], + ["America/Catamarca", "America/Argentina/Catamarca"], + ["America/Cordoba", "America/Argentina/Cordoba"], + ["America/Godthab", "America/Nuuk"], + ["America/Indianapolis", "America/Indiana/Indianapolis"], + ["America/Jujuy", "America/Argentina/Jujuy"], + ["America/Louisville", "America/Kentucky/Louisville"], + ["America/Mendoza", "America/Argentina/Mendoza"], + ["Asia/Calcutta", "Asia/Kolkata"], + ["Asia/Katmandu", "Asia/Kathmandu"], + ["Asia/Rangoon", "Asia/Yangon"], + ["Asia/Saigon", "Asia/Ho_Chi_Minh"], + ["Atlantic/Faeroe", "Atlantic/Faroe"], + ["Europe/Kiev", "Europe/Kyiv"], + ["Pacific/Enderbury", "Pacific/Kanton"], + ["Pacific/Ponape", "Pacific/Pohnpei"], + ["Pacific/Truk", "Pacific/Chuuk"], +]; + +/** The wall clock a zone reads at an instant, to the minute. */ +const reading = (tz: string, at: number) => + new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hourCycle: "h23", + dateStyle: "short", + timeStyle: "short", + }).format(at); + +describe("isValidTimeZone", () => { + it("accepts the names the pickers offer and rejects the rest", () => { + expect(isValidTimeZone("Europe/Rome")).toBe(true); + expect(isValidTimeZone("Not/AZone")).toBe(false); + }); + + it.each(LEGACY_ALIASES)( + "rejects %s, which Postgres has no such zone for", + (alias) => { + // Intl says yes to all of these, which is exactly the defect: this + // predicate is what the query layer's isZoneName defers to before the + // name reaches `AT TIME ZONE`, so accepting one turned a bad ?tz= into a + // 500 out of Postgres instead of a 400 out of the loader. + expect(isValidTimeZone(alias)).toBe(false); + } + ); + + it.each(LEGACY_ALIASES)( + "accepts the zone %s is offered as", + (_, canonical) => { + expect(isValidTimeZone(canonical)).toBe(true); + } + ); + + it.each([ + "US/Eastern", + "Canada/Pacific", + "Brazil/East", + "Australia/NSW", + "Japan", + "Poland", + "GB-Eire", + "Asia/Ulan_Bator", + "Europe/Uzhgorod", + "Antarctica/South_Pole", + ])("rejects the tzdata backward link %s", (legacy) => { + // Node's ICU accepts all ~80 of these and the Postgres image carries none + // of them: Debian ships the `backward` links in a separate tzdata-legacy + // package. Answering true here let `?tz=US/Eastern` past the loader and + // straight into `AT TIME ZONE`, and loadDashboard runs from the anonymous + // loader in analytics.public.tsx — an unauthenticated 500 on a shared link + // where the contract promises a 400. + expect(isValidTimeZone(legacy)).toBe(false); + }); + + it("still accepts the POSIX offset names, which both sides read alike", () => { + // Deliberately allowed by the query layer: a *name*, unlike a bare `+05:30` + // offset, which Intl and Postgres read with opposite signs. + expect(isValidTimeZone("Etc/GMT+5")).toBe(true); + expect(isValidTimeZone("Etc/GMT-14")).toBe(true); + expect(isValidTimeZone("Etc/UTC")).toBe(true); + expect(isValidTimeZone("Etc/GMT+15")).toBe(false); + }); +}); + +describe("canonicalTimeZone", () => { + it.each(LEGACY_ALIASES)("rewrites %s to %s", (alias, canonical) => { + expect(canonicalTimeZone(alias)).toBe(canonical); + }); + + it("leaves anything else alone, including what it cannot vouch for", () => { + // It canonicalises; it does not validate. The caller checks. + expect(canonicalTimeZone("Europe/Rome")).toBe("Europe/Rome"); + expect(canonicalTimeZone("UTC")).toBe("UTC"); + expect(canonicalTimeZone("Not/AZone")).toBe("Not/AZone"); + }); + + it.each(LEGACY_ALIASES)( + "keeps %s and %s the same zone at every month of the year", + (alias, canonical) => { + // The substitution is only safe because the pair are one zone under two + // names. Checked against Intl rather than against the table that claims + // it, so a typo in the table fails here and not on someone's dashboard. + for (let month = 0; month < 12; month++) { + const at = Date.UTC(2026, month, 15, 12); + + expect(reading(canonical, at)).toBe(reading(alias, at)); + } + } + ); +}); + +describe("listTimeZones", () => { + const zones = listTimeZones(); + + it("offers nothing the loader would then refuse", () => { + // The invariant the picker rests on: every row it draws is a name the + // dashboard can actually be loaded with. + expect(zones.filter((zone) => !isValidTimeZone(zone))).toEqual([]); + }); + + it("offers the canonical name instead of dropping the region", () => { + // Filtering alone would have been the smaller change and the wrong one: + // these are the only entries the runtime lists for India, Ukraine and + // Argentina, so dropping them leaves those readers with no zone to pick. + for (const [alias, canonical] of LEGACY_ALIASES) { + expect(zones).not.toContain(alias); + expect(zones).toContain(canonical); + } + }); + + it("leads with UTC, then rises strictly — sorted and never repeated", () => { + // Substituting a name moves it (America/Buenos_Aires becomes + // America/Argentina/Buenos_Aires), so the list has to be re-sorted after, + // and a runtime that one day lists an alias beside its target must not + // offer the same zone twice. + const [first, ...rest] = zones; + + expect(first).toBe("UTC"); + expect(rest.every((zone, i) => i === 0 || rest[i - 1] < zone)).toBe(true); + expect(rest).not.toContain("UTC"); + }); +}); diff --git a/apps/web/app/modules/analytics/components/breakdown-panel.tsx b/apps/web/app/modules/analytics/components/breakdown-panel.tsx new file mode 100644 index 00000000..5007ff29 --- /dev/null +++ b/apps/web/app/modules/analytics/components/breakdown-panel.tsx @@ -0,0 +1,458 @@ +import { + ArrowUpRight, + CornerDownRight, + FileText, + Globe, + Languages, + Laptop, + type LucideIcon, + MapPin, + Megaphone, + Monitor, + Route, + Search, + Share2, + Smartphone, + Tablet, +} from "lucide-react"; +import { useMemo, useState } from "react"; +// Type-only: nothing in db/schema.ts reaches the client bundle, and the point is +// that a channel added there breaks the icon map below. +import type { ChannelType } from "~/db/schema"; +import { MetricHint } from "~/shared/components/metric-hint"; +import { + COLLAPSED_ROWS, + expandLabel, + PanelCaption, + PanelColumns, + ROW_CAP, + TruncationNote, +} from "./panel"; +import { Button } from "~/shared/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "~/shared/ui/card"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/shared/ui/empty"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/shared/ui/tabs"; +import { + countryFlag, + formatChannel, + formatCompactNumber, + formatCountry, + formatNumber, + formatReferrer, +} from "~/shared/lib/format"; +import type { Breakdown, BreakdownRow, BreakdownUnit } from "../types"; + +/** + * Closed on purpose, and `KIND_ICON` below is keyed by it: a dimension wired + * into a panel without deciding how it is labelled and what it is drawn with + * fails to compile. It is the only compile-time guard the dashboard has, since + * the loader payload reaches it as an identifier and never gets an + * excess-property check. + * + * The five utm dimensions share one kind. They are the same kind of thing — + * campaign parameters, raw strings, no per-value meaning to decode — and five + * near-identical entries would only be five chances for them to drift. + */ +export type BreakdownKind = + | "page" + | "referrer" + | "channel" + | "device" + | "os" + | "browser" + | "country" + | "locale" + | "campaign"; + +/** + * A tab is the panel plus the two labels that name it, and it carries the panel + * whole — `{ value, label, kind, ...breakdowns.referrers }` at the call site — + * rather than lifting `rows` out of it. The unit is what the count column is + * headed with, and separating the two is how a panel counting sessions ends up + * headed "Views". + */ +export type BreakdownTab = { + value: string; + label: string; + kind: BreakdownKind; +} & Breakdown; + +const EMPTY_HINT = + "Try a wider range, or check that the tracking snippet is installed."; + +const MOBILE_OS = new Set([ + "android", + "ios", + "ipados", + "harmonyos", + "windows phone", +]); + +const KIND_ICON: Record = { + page: FileText, + referrer: ArrowUpRight, + channel: Route, + device: Monitor, + os: Laptop, + // Per-vendor browser marks would mean shipping their logos; a globe says + // "browser" without borrowing anyone's trademark. + browser: Globe, + country: MapPin, + locale: Languages, + campaign: Megaphone, +}; + +/** + * The five channels are a closed set with five distinct meanings, and the whole + * list fits on screen at once — one mark repeated five times would be + * decoration. `direct` shares the referrer tab's mark because the two buckets + * largely hold the same visits and the tabs are read against each other. + * + * Keyed by `ChannelType` for the same reason as CHANNEL_LABELS in format.ts: a + * channel added to the schema has to be given a mark here rather than quietly + * falling back to the generic one. + */ +const CHANNEL_ICON: Record = { + direct: CornerDownRight, + search: Search, + social: Share2, + referral: ArrowUpRight, + campaign: Megaphone, +}; + +function iconFor(kind: BreakdownKind, value: string): LucideIcon { + const normalized = value.toLowerCase(); + + // The bucket with no external origin to point at gets its own mark. + if (kind === "referrer" && !value) { + return CornerDownRight; + } + + // Cast because the value arrives from the database as a plain string; the + // CHECK constraint is what makes the fallback unreachable, not the type. + if (kind === "channel") { + return CHANNEL_ICON[normalized as ChannelType] ?? KIND_ICON.channel; + } + + if (kind === "device" && normalized === "mobile") { + return Smartphone; + } + + if (kind === "device" && normalized === "tablet") { + return Tablet; + } + + if (kind === "os" && MOBILE_OS.has(normalized)) { + return Smartphone; + } + + return KIND_ICON[kind]; +} + +function labelFor(kind: BreakdownKind, value: string) { + if (kind === "referrer") { + return formatReferrer(value); + } + + // The column is stored lowercase and CHECK-constrained to five values, so it + // reaches the panel as `direct` rather than `Direct`. + if (kind === "channel") { + return formatChannel(value); + } + + // Alpha-2 codes are what the edge headers speak and what the query layer + // deliberately keeps; naming them is the dashboard's half of that bargain. + if (kind === "country") { + return formatCountry(value); + } + + return value || "Unknown"; +} + +/** + * The count column's header, and the noun the truncation note uses. + * + * Read off the panel's own `unit` and never passed in beside it. A panel scoped + * to arrivals reports sessions, and one still headed "Views" while doing so is + * the defect this dashboard exists to remove, stated in a word instead of a + * number. + */ +const UNIT_LABEL: Record = { + views: { column: "Views", noun: "views" }, + sessions: { column: "Sessions", noun: "sessions" }, +}; + +/** + * Rows that arrive under the same element are one row. + * + * The rows are grouped in SQL, so within a single dimension the database cannot + * hand back a duplicate — but the payload is rewritten on the way here. The + * locales panel is the live case: `toLocaleName` in metrics.server.ts replaces + * the stored BCP-47 tag with a display name, and several distinct tags share + * one name — `zh` and `zh-Hans` are both "Chinese (Simplified)", as are + * nb/no, sr/sr-Latn, uz/uz-Latn and az/az-Latn. Keyed by the post-transform + * value, React logged "two children with the same key" and drew the language + * twice with its counts split, so 10 views and 3 views read as two languages + * rather than one with 13. + * + * `unique` is summed, which is an upper bound rather than a distinct count: + * one visitor who reported two tags of the same language in one window would be + * counted twice. A browser sends one Accept-Language per request, so that costs + * nothing in practice, and the alternative — taking the larger of the two — + * understates by however many visitors the other tag had to itself. + */ +function mergeRows(rows: BreakdownRow[]) { + const merged = new Map(); + + for (const row of rows) { + const found = merged.get(row.element); + + if (found) { + found.count += row.count; + found.unique += row.unique; + } else { + merged.set(row.element, { ...row }); + } + } + + // The count first, then the element, matching the SQL the rows arrived + // ordered by: the tie-break is what stops the folded list reshuffling between + // renders. + // + // The rule guards against mutating a caller's array; this one was built on + // the line above and nothing else can see it. toSorted would need lib: es2023. + // oxlint-disable-next-line unicorn/no-array-sort + return [...merged.values()].sort( + (a, b) => b.count - a.count || a.element.localeCompare(b.element) + ); +} + +function BreakdownRowItem({ + row, + kind, + maxCount, +}: { + row: BreakdownRow; + kind: BreakdownKind; + maxCount: number; +}) { + const Icon = iconFor(kind, row.element); + const label = labelFor(kind, row.element); + // A flag identifies a country faster than its name reads, and costs an emoji. + // Empty for the unknown bucket and on platforms without flag glyphs, which is + // why the name is never carried by it alone. + const flag = kind === "country" ? countryFlag(row.element) : ""; + // Relative to the leader, not to the total: it keeps the shape readable when + // a long tail would otherwise flatten every bar to nothing. + const share = maxCount > 0 ? (row.count / maxCount) * 100 : 0; + + return ( + // The bar is an absolutely positioned element rather than a hard stop in a + // background gradient. A gradient cannot have a rounded end — there is no + // border-radius on a colour stop — and the square edge was the one thing + // that read as unfinished next to everything else on the card. + // + // That makes the row the containing block, which is what the gradient was + // avoiding. It is safe here and was not always: `position: relative` on a + // `
+ + + {flag ? ( + + {flag} + + ) : ( + + )} + + {label} + + + + {formatCompactNumber(row.count)} + + {formatCompactNumber(row.unique)} +
+ {name} + + + {visible.map((row) => ( + + ))} + +
+ + + {expanded && capped && ( + + )} + + {sorted.length > COLLAPSED_ROWS && ( + + )} + + ); +} + +export function BreakdownPanel({ + title, + tabs, + hint, +}: { + title: string; + tabs: BreakdownTab[]; + hint?: string; +}) { + const header = ( + + {title} + {hint ? {hint} : null} + + ); + + // One dimension is not a tab set. Base UI's TabsPanel emits role="tabpanel" + // with tabIndex={0} whether or not a Tab was registered for it, and hiding + // the TabsList left four panels on the dashboard as extra keyboard stops + // owning no tab, with no aria-labelledby and an accessible name flattened out + // of their own contents. + if (tabs.length === 1) { + return ( + + + {header} + + + + + + ); + } + + return ( + + + {/* The rule under the header is what the active tab's marker sits on, + so the tab reads as selecting a section of the card rather than + floating above it. -20px is the distance from a trigger's bottom + edge to that border: 3px of list padding, the header's 16px + padding-bottom, and 1px of border to cover. */} + + {header} + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + + {tabs.map((tab) => ( + // Kept mounted so each list holds its own expanded state across switches. + + + + + + ))} + + + ); +} diff --git a/apps/web/app/modules/analytics/components/dashboard.tsx b/apps/web/app/modules/analytics/components/dashboard.tsx new file mode 100644 index 00000000..8a31a98b --- /dev/null +++ b/apps/web/app/modules/analytics/components/dashboard.tsx @@ -0,0 +1,406 @@ +import { useEffect, useMemo } from "react"; +import { useNavigation, useSearchParams } from "react-router"; +import { BreakdownPanel } from "./breakdown-panel"; +import { BAR_BASIS_HINT } from "./panel"; +import { GoalsPanel } from "./goals-panel"; +import { DAILY_VISITORS_HINT } from "~/shared/components/metric-hint"; +import { RangePicker } from "./range-picker"; +import { StatCard } from "./stat-card"; +import { TimeseriesChart } from "./timeseries-chart"; +import { TimezonePicker } from "./timezone-picker"; +import { + durationChange, + formatCompactNumber, + formatDuration, + formatPercent, + NO_DATA, + pointChange, + type Trend, + trend, +} from "~/shared/lib/format"; +import type { RangeSelection } from "../range"; +import { bucketsWithin, canonicalTimeZone, isValidTimeZone } from "../timezone"; +import type { + Breakdowns, + CustomEventRow, + Statistics, + TimeseriesPoint, +} from "../types"; +import { cn } from "~/shared/lib/utils"; + +/** + * Exactly what `loadDashboard` returns. + * + * `breakdowns` is the shared `Breakdowns` rather than a local literal of the + * panels that happen to be drawn. The literal listed six of the twelve + * dimensions and omitted `events` entirely, and because the payload arrives as + * an identifier rather than an object literal TypeScript never ran an + * excess-property check over it: six panels and the whole goals list were + * queried, paid for and serialised into the document with nothing rendering + * them and nothing reporting it. Naming the shared type is what makes the next + * dimension a compile error here instead of a silent drop. + */ +export type DashboardData = { + range: string; + /** The resolved window, in epoch milliseconds. */ + from: number; + to: number; + unit: "hour" | "day"; + tz: string; + stats: Statistics; + previousStats: Statistics; + series: TimeseriesPoint[]; + breakdowns: Breakdowns; + events: CustomEventRow[]; +}; + +/** A window with no sessions has no bounce rate, not a rate of zero over zero. */ +function bounceRate(stats: Statistics) { + return stats.sessions > 0 ? stats.bounces / stats.sessions : null; +} + +/** + * The change against the previous window, when there is one to state. + * + * A figure that was never measured has no change in either direction: compared + * against a window that did measure, the difference would read as a fall to + * zero or a rise from it, and neither of those happened. + * + * The comparison itself is passed in, because the three tiles that use this are + * not the same kind of number. `trend`'s zero-baseline answer is the word + * "New", which is right about a pageview count that had nothing before it and + * wrong about a rate: a bounce rate that moved 0% -> 10% was labelled "New" and + * coloured as a regression, when what happened is that it rose ten points. + */ +function changeOf( + current: number | null, + previous: number | null, + as: (current: number, previous: number) => Trend +) { + return current === null || previous === null + ? undefined + : as(current, previous); +} + +const SESSIONS_HINT = + "A visit: one person's run of pageviews, ended by half an hour without another — or by midnight UTC, which starts a fresh visit because the identifier a visit is tracked by rotates then. A visit that spans midnight UTC therefore counts twice, and each half that stopped at one page counts as a bounce. Bounce rate is measured over exactly these. Average visit is not — its denominator is only the visits that reported a duration, which is a smaller and differently-selected set; the tile beside it says which."; + +const BOUNCE_HINT = + "Share of sessions that left after a single pageview. Stated against the previous window in percentage points, since the change between two rates is not itself a rate."; + +const SOURCES_HINT = + "Where visits came from, counted once each at the pageview that started them — acquisition is a fact about an arrival, not about every page read afterwards. Channel buckets the same arrivals five ways: campaign whenever the link carried utm parameters, then search, social and referral by the referring host, and direct when there was no referrer to read. 'No referrer' on the Referrers tab is that same last group plus any campaign link that arrived without one, which is why the two tabs do not add up the same way. Read Direct as 'no referrer was readable', not as 'typed the address'. A visit also starts fresh after half an hour of inactivity and again at midnight UTC, and someone who then carries on through a link inside the site opens one whose referrer is the site's own previous page — which is discarded as a self-referral. The half-hour restart keeps whatever acquired the visit it continues instead of becoming Direct, so that source is counted twice for the one visit. The midnight restart cannot: the identifier a visit is tracked by rotates then, so there is nothing left to carry it from, and those land in Direct and in 'No referrer' alongside the real arrivals. One consequence to read the Sessions column with: it counts visits that *started* inside this range, while the Sessions tile counts every visit that was active in it — so a visit already running when the range opened is counted by the tile and listed nowhere here, and these rows will always total slightly less than it. " + + BAR_BASIS_HINT; + +const CAMPAIGNS_HINT = + "The utm parameters on the link a visit arrived through, counted once per visit. Only the pageview that opened the visit is read, so a parameter picked up from a link inside the site is not an acquisition and is not counted here. Visits that carried no utm parameter are not listed at all — the absence of a campaign is not an unidentified one, and on a typical site it would be every row's worth of traffic sitting in a single row above them. What share of visits arrived through a campaign is the Campaign row under Sources, where it has a denominator. Like the Sources card, the Sessions column here counts visits that started inside this range rather than every visit active in it, so it does not reconcile with the Sessions tile. " + + BAR_BASIS_HINT; + +const DURATION_HINT = + "Time on page summed within a visit, then averaged across the visits that reported one — a five-page visit counts once. Only a page whose unload beacon arrives is timed, and that beacon is refused by many content blockers and never sent for a tab the system closes, so the visits behind this average are a subset of the visits counted beside it — and a subset that under-represents the ones which ended abruptly. Within a visit it is the same gap one step down: a visit that timed two of its five pages contributes two pages of time and still counts once. Stated against the previous window as a difference in time, not a percentage of it."; + +const NO_DURATION_HINT = + "No pageview in this window reported how long it stayed open, so there is no average to show. That is not the same as an average of zero."; + +export function AnalyticsDashboard({ data }: { data: DashboardData }) { + const [searchParams, setSearchParams] = useSearchParams(); + const navigation = useNavigation(); + + // Bucketing is timezone-sensitive and the server can't know the visitor's + // zone, so the first client render pins it into the URL. + useEffect(() => { + if (searchParams.has("tz")) { + return; + } + + // Canonicalised before it goes into the URL: a host whose TZ is a pre-2018 + // name hands one straight back here, and Postgres has no such zone — left + // as it arrived it would come back to the loader as a 400 on every + // navigation. Still validated afterwards, so a name neither table knows + // leaves the dashboard on its UTC default instead of looping through it. + const tz = canonicalTimeZone( + Intl.DateTimeFormat().resolvedOptions().timeZone + ); + + if (!tz || !isValidTimeZone(tz)) { + return; + } + + setSearchParams( + (prev) => { + prev.set("tz", tz); + return prev; + }, + { replace: true, preventScrollReset: true } + ); + }, [searchParams, setSearchParams]); + + // The window is a pair of instants either way, so re-grouping it in another + // zone is only a matter of relabelling the buckets. + const onTimeZoneChange = (tz: string) => { + setSearchParams( + (prev) => { + prev.set("tz", tz); + return prev; + }, + { preventScrollReset: true } + ); + }; + + // A preset and a pinned window are the same filter, so whichever one is + // chosen clears the other: the URL never carries both. + const onRangeChange = (selection: RangeSelection) => { + setSearchParams( + (prev) => { + if ("range" in selection) { + prev.set("range", selection.range); + prev.delete("from"); + prev.delete("to"); + } else { + prev.set("from", String(selection.from)); + prev.set("to", String(selection.to)); + prev.delete("range"); + } + + return prev; + }, + { preventScrollReset: true } + ); + }; + + const { stats, previousStats, breakdowns } = data; + const rate = bounceRate(stats); + + // `to` is exclusive and the padding the query generates is not, so the last + // bucket of a whole-day range is one the window stops at rather than one it + // contains — always empty, and never asked for. + const series = useMemo( + () => bucketsWithin(data.series, data.to, data.tz), + [data.series, data.to, data.tz] + ); + + // Every filter is a navigation, and the panels keep the previous window's + // figures on screen until the loader answers. Dimming them says the numbers + // are the old ones rather than letting a changed range look like it did + // nothing at all. + const pending = navigation.state === "loading"; + + return ( +
+
+ + + +
+ +
+
+ + {/* Not "Visitors". The identifier behind this count rotates at UTC + midnight, so the figure is the sum of the window's daily uniques + and grows with the window rather than with the audience. */} + + {/* The half-hour rule is not the only one that ends a visit: the + session lookup is keyed on visitor_id, and that id is an HMAC over + the UTC date, so at 00:00 UTC ingest finds no prior event and + opens a new session with is_a_bounce set. The hint has to say so — + 00:00 UTC is 20:00 US Eastern, inside the evening peak. */} + + + +
+ + + +
+ + {/* An acquisition report, which it was not until the two dimensions + behind it were scoped to `is_new_session`. Grouped over every + pageview they answered close to the opposite of the truth: + referrer_host is null on every pageview after the first in a visit + — the tracker reads document.referrer once per document, ingest + nulls self-referrals — and channel is resolved per event off that + same referrer, so pageviews 2..N were classified `direct`. A site + whose visitors all arrived from one search engine and read five + pages reported that engine at 100 and Direct at 400. + + Channel leads because it is the answer with no hole in it: every + arrival lands in exactly one of five buckets, where a third of the + referrer list is the visits that arrived without one and can never + be attributed further. Both tabs count sessions, and the column + header they share says so. */} + + + {/* Country and language are two answers, not one. This panel was fed + the locale breakdown under the name `countries` until the schema + gave it real edge-header geography; they disagree often, and the + pair of them is the point. */} + + + {/* Five dimensions, one card: campaign parameters are read together — + which source, through which medium, for which campaign — and five + cards of mostly-empty lists would bury the four panels above. + Acquisition like the Sources card, so these count sessions too. + + Every tab here is scoped to the arrivals that carried the + parameter it lists (BREAKDOWN_SCOPES, `empty: "omitted"`). The + ones that carried none used to coalesce into a row labelled + "Unknown" — on a normal site, nearly all of the traffic, sorted + first, drawn as the bar every real campaign's share was measured + against, with the site's whole audience in its Daily visitors + column under a card headed Campaigns. */} + + {/* Full width: its rows carry a line of revenue per currency, which + the half-width cards have no room for. */} +
+ +
+
+
+
+ ); +} diff --git a/apps/web/app/modules/analytics/components/goals-panel.tsx b/apps/web/app/modules/analytics/components/goals-panel.tsx new file mode 100644 index 00000000..b7d4efd7 --- /dev/null +++ b/apps/web/app/modules/analytics/components/goals-panel.tsx @@ -0,0 +1,176 @@ +import { Target } from "lucide-react"; +import { useState } from "react"; +import { + BAR_BASIS_HINT, + COLLAPSED_ROWS, + expandLabel, + PanelCaption, + PanelColumns, + ROW_CAP, + TruncationNote, +} from "./panel"; +import { MetricHint } from "~/shared/components/metric-hint"; +import { Button } from "~/shared/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/shared/ui/card"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/shared/ui/empty"; +import { + formatCompactNumber, + formatMoney, + formatNumber, +} from "~/shared/lib/format"; +import type { CustomEventRow } from "../types"; + +/** + * Goals: the named events a site reports itself through `aurora()`. + * + * Not a `BreakdownPanel` tab, and it can't be one. A goal is + * {name, count, unique, revenue[]} where a breakdown row is + * {element, views, unique}, and the revenue is a list of per-currency totals + * that the two fixed numeric columns have nowhere to put. Mapping it into a + * breakdown row would mean dropping the money, which is the half of the panel + * that pays for itself. + */ +function GoalRow({ row, maxCount }: { row: CustomEventRow; maxCount: number }) { + // Relative to the leader, matching the breakdown rows: a site with one goal + // firing thousands of times and another firing twice still shows both. + const share = maxCount > 0 ? (row.count / maxCount) * 100 : 0; + + return ( + // A row of a real table, for the reason given on PanelColumns: the column + // labels have to be programmatically attached to the figures under them. + // The bar is a positioned child rather than a background gradient, for the + // reason given on BreakdownRowItem — a gradient stop cannot be rounded — + // including why the two numeric cells have to be `relative`. + // Same tints as BreakdownRowItem, and the dark one is 18% for the reason + // given there: at 22% the muted-foreground cell fell to 4.38:1 on hover. + + + + + + + {row.name || "Unnamed"} + + + {row.revenue.length > 0 && ( + // One entry per currency, never a total across them: 49 EUR plus + // 10 USD is not 59 of anything, and reporting it as one number is + // the bug the query layer was corrected for. Whatever a site sells + // in, it sees. Inside the goal's own cell, because it is a fact + // about the goal and not a fourth column. + // + // On the goal's own line rather than under it. A second line made + // the one goal that earns money twice the height of every other + // row, and the bar behind it — which spans the row — stopped + // reading as a longer bar and started reading as a block. The name + // truncates and the money does not: a goal too long to fit is still + // identifiable from its first characters, whereas a truncated + // amount is worse than no amount. +
    + {row.revenue.map((entry) => ( +
  • + {formatMoney(entry.total, entry.currency)} +
  • + ))} +
+ )} +
+ + + {formatCompactNumber(row.count)} + + + {formatCompactNumber(row.unique)} + + + ); +} + +export function GoalsPanel({ rows }: { rows: CustomEventRow[] }) { + const [expanded, setExpanded] = useState(false); + + const maxCount = rows[0]?.count ?? 0; + const visible = expanded ? rows : rows.slice(0, COLLAPSED_ROWS); + // The goals CTE is cut by the same BREAKDOWN_LIMIT as every panel, so a list + // arriving at exactly that length is the top of a longer one. + const capped = rows.length >= ROW_CAP; + + return ( + + + + Goals + {/* Same bar, same basis, so the same sentence: GoalRow's `share` is + `row.count / rows[0].count` exactly as BreakdownRowItem's is. */} + {BAR_BASIS_HINT} + + + + + {rows.length === 0 ? ( + + + + + + No goals in this range + + Goals are the events your own site reports. Call{" "} + aurora("signup") to count one, and pass a + revenue amount to see what it earned. + + + + ) : ( +
+
+ + Goals + + {/* Already ordered by count then name in SQL, and the tie-break + is what keeps the folded list from reshuffling between + renders. */} + + {visible.map((row) => ( + + ))} + +
+
+ + {expanded && capped && ( + + )} + + {rows.length > COLLAPSED_ROWS && ( + + )} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/modules/analytics/components/panel.tsx b/apps/web/app/modules/analytics/components/panel.tsx new file mode 100644 index 00000000..b4227e45 --- /dev/null +++ b/apps/web/app/modules/analytics/components/panel.tsx @@ -0,0 +1,150 @@ +import { + DAILY_VISITORS_HINT, + MetricHint, +} from "~/shared/components/metric-hint"; + +/** + * The parts every panel on the dashboard is built out of. + * + * Their own module because the goals panel needs all of them and is not a + * breakdown: it was importing seven symbols out of breakdown-panel.tsx, which + * made one sibling component the other's library and put a 587-line file in the + * graph between the goals list and two constants. The rule these encode is that + * the two lists have to make the identical claim — same row cap, same expander + * wording, same note when the query truncated, same explanation of the bar — + * and that is easier to hold in one small file than across two large ones. + */ + +/** Rows past this are folded away until the reader asks for them. */ +export const COLLAPSED_ROWS = 8; + +/** + * The most rows a dimension can arrive with — BREAKDOWN_LIMIT in + * queries.server.ts, which every breakdown and the goals list are cut to after + * `ORDER BY sum(views) DESC`. + * + * Mirrored here because the expander used to read "Show all (100)" on any site + * with more than a hundred distinct paths — routine — and expand to exactly a + * hundred rows while asserting that was the whole list. Nothing else in the + * panel said the list was cut, so the count column could not be reconciled + * against the tile above it and there was no way to find out why. A list that + * arrives at exactly this length is the top of a longer one and says so. + */ +export const ROW_CAP = 100; + +/** + * What the bar behind each row is a share *of*, said on the panel. + * + * The length is `row.count / maxCount` — relative to the leading row, not to + * the column total — which keeps a long tail readable where normalising against + * the total would flatten every row after the first few to nothing. That is the + * right drawing and the wrong thing to leave unlabelled: the top row is always + * a full-width bar, so a leading page holding 12% of a site's traffic is painted + * exactly like one holding 98%, and no percentage appears anywhere in the panel + * to contradict the reading. Stated once here and appended to every panel's + * hint, including the goals list, which draws the identical bar. + */ +export const BAR_BASIS_HINT = + "The shading behind each row is drawn relative to the longest row, not to the total — the top row is always full width whatever share of the site it holds, so it shows the shape of the list rather than a percentage of anything. The figures are the counts."; + +/** + * The column labels, shared with the goals list so the two read as one table. + * + * A real `` of ``. These were a `
` of ``s + * sitting *beside* the list, so nothing associated "Views" with the number + * under it: seven three-column grids on the dashboard and not one `` or + * ` + + + + + + + ); +} + +/** + * A table's accessible name, as a `; +} + +/** + * What the expander says, given how many rows there are and whether the query + * cut them. + * + * Shared with the goals panel because both lists are cut by the same limit and + * the two claims have to stay identical. + */ +export function expandLabel(count: number, capped: boolean) { + return capped ? `Show top ${count}` : `Show all (${count})`; +} + +/** + * Stated under an expanded list that the query truncated, and only then. + * + * `by` is the unit the cut was made in — the panels order by views or by + * sessions and the goals list by events — so the note names the same quantity + * the column beside it is headed with. + */ +export function TruncationNote({ count, by }: { count: number; by: string }) { + return ( +

+ Top {count} by {by}. A longer tail exists and is not counted in this list. +

+ ); +} diff --git a/apps/web/app/modules/analytics/components/range-picker.tsx b/apps/web/app/modules/analytics/components/range-picker.tsx new file mode 100644 index 00000000..3af3219e --- /dev/null +++ b/apps/web/app/modules/analytics/components/range-picker.tsx @@ -0,0 +1,178 @@ +import { CalendarIcon } from "lucide-react"; +import { useState } from "react"; +import type { DateRange } from "react-day-picker"; +import { Button } from "~/shared/ui/button"; +import { ButtonGroup } from "~/shared/ui/button-group"; +import { Calendar } from "~/shared/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "~/shared/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "~/shared/ui/tooltip"; +import { useIsMobile } from "~/shared/hooks/use-mobile"; +import { formatDateRange } from "~/shared/lib/format"; +import { + CUSTOM_RANGE, + RANGES, + type RangeKey, + type RangeSelection, +} from "../range"; +import { + endOfZonedDayExclusive, + startOfZonedDay, + zonedCalendarDay, +} from "../timezone"; + +/** + * The toolbar is dense, so the segments carry the short label and the tooltip + * carries the full one. + */ +const PRESETS = Object.entries(RANGES) as [ + RangeKey, + (typeof RANGES)[RangeKey], +][]; + +export function RangePicker({ + range, + from, + to, + tz, + onChange, +}: { + range: string; + from: number; + to: number; + tz: string; + onChange: (selection: RangeSelection) => void; +}) { + const isMobile = useIsMobile(); + const custom = range === CUSTOM_RANGE; + + const [open, setOpen] = useState(false); + // Seeded when the popover opens, so the calendar starts from the window on + // screen and an abandoned edit doesn't linger into the next one. + const [draft, setDraft] = useState(undefined); + + function onOpenChange(next: boolean) { + if (next) { + setDraft( + custom + ? { + from: zonedCalendarDay(from, tz), + // `to` is the exclusive end, so the last selected square is the + // day the instant before it falls on. Reading the boundary itself + // would seed the calendar a day past the range on screen — and, + // because the loader clips the end to now, that day is often one + // the picker has disabled as being in the future. + to: zonedCalendarDay(to - 1, tz), + } + : undefined + ); + } + + setOpen(next); + } + + /** What Apply would send, so the footer label and the button cannot disagree. */ + function windowOf(start: Date, end: Date | undefined) { + return { + from: startOfZonedDay(start, tz), + to: endOfZonedDayExclusive(end ?? start, tz), + }; + } + + function apply() { + if (!draft?.from) { + return; + } + + // A single click selects one day, which reads as that whole day rather than + // an empty window. Both edges are snapped in the zone being charted, not + // the browser's: "Aug 1" has to mean the same midnights the buckets do. + // The end is the next day's first instant, not the last of this one — the + // query layer's range predicate is half-open and the comparison window is + // anchored at `from`, so only a boundary makes the two tile exactly. + onChange(windowOf(draft.from, draft.to)); + + setOpen(false); + } + + const preview = draft?.from ? windowOf(draft.from, draft.to) : null; + + return ( + + {PRESETS.map(([key, preset]) => { + const active = key === range; + + return ( + + {/* The full label is on the button as well as in the tooltip. Base + UI's tooltip trigger opens on hover (mouse only) and on + :focus-visible, and a tap gives neither — so on a phone the + segment read "7d" and nothing anywhere expanded it. */} + onChange({ range: key })} + > + {preset.short} + + } + /> + {preset.label} + + ); + })} + + + + + {custom ? formatDateRange(from, to, tz) : "Custom"} + + } + /> + + + + +
+ + {preview + ? formatDateRange(preview.from, preview.to, tz) + : "Pick a start and end day"} + + + +
+
+
+
+ ); +} diff --git a/apps/web/app/modules/analytics/components/stat-card.tsx b/apps/web/app/modules/analytics/components/stat-card.tsx new file mode 100644 index 00000000..311edd8e --- /dev/null +++ b/apps/web/app/modules/analytics/components/stat-card.tsx @@ -0,0 +1,70 @@ +import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react"; +import { MetricHint } from "~/shared/components/metric-hint"; +import { Card, CardContent } from "~/shared/ui/card"; +import type { Trend } from "~/shared/lib/format"; +import { cn } from "~/shared/lib/utils"; + +const TREND_ICONS = { + up: ArrowUpIcon, + down: ArrowDownIcon, + flat: MinusIcon, +}; + +/** + * Direction alone doesn't say whether a metric moved the right way: more + * pageviews is good, more bounces is not. + */ +function trendTone(direction: Trend["direction"], invert: boolean) { + if (direction === "flat") { + return "text-muted-foreground"; + } + + const good = invert ? direction === "down" : direction === "up"; + + return good ? "text-success" : "text-destructive"; +} + +export function StatCard({ + label, + value, + trend, + invertTrend = false, + hint, +}: { + label: string; + value: string; + trend?: Trend; + invertTrend?: boolean; + hint?: string; +}) { + const TrendIcon = trend ? TREND_ICONS[trend.direction] : null; + + return ( + + +
+ {label} + + {hint ? {hint} : null} +
+ +

{value}

+ + {trend && TrendIcon ? ( +

+ + + {trend.label} + + vs. previous period +

+ ) : null} +
+
+ ); +} diff --git a/apps/web/app/modules/analytics/components/timeseries-chart.tsx b/apps/web/app/modules/analytics/components/timeseries-chart.tsx new file mode 100644 index 00000000..932cb56e --- /dev/null +++ b/apps/web/app/modules/analytics/components/timeseries-chart.tsx @@ -0,0 +1,232 @@ +import { ChartArea } from "lucide-react"; +import { useId, useMemo } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { MetricHint } from "~/shared/components/metric-hint"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/shared/ui/card"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "~/shared/ui/chart"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/shared/ui/empty"; +import { + formatBucket, + formatBucketLong, + formatBucketWithDay, + formatCompactNumber, + formatNumber, +} from "~/shared/lib/format"; +import type { TimeseriesPoint } from "../types"; +import { cn } from "~/shared/lib/utils"; + +const chartConfig = { + count: { label: "Pageviews", color: "var(--color-chart-1)" }, +} satisfies ChartConfig; + +/** Chart and empty state share this so the card keeps its size either way. */ +const BODY_HEIGHT = "h-[240px] md:h-[300px]"; + +const BUCKET_HINT = + "The presets are rolling windows that do not begin on the hour or the day they are bucketed by, so the first and last columns cover part of a bucket rather than all of one. A low bar at either end may be the edge of the window rather than a dip in traffic; the same applies to the peak, which is read off these buckets."; + +/** + * The axis labels, with the day attached to any that would otherwise repeat. + * + * A 24 hour preset pads to 25 hourly buckets — `date_trunc('hour', now - 24h)` + * through `date_trunc('hour', now)` — so its first and last are the same hour + * of the clock and the axis drew "2 PM" at both ends, one hour of real traffic + * split between them. Only the tooltip carried the date that told them apart. + * Qualifying just the repeats keeps every unambiguous window as short as it was. + */ +function useBucketLabels(data: TimeseriesPoint[], unit: "hour" | "day") { + return useMemo(() => { + const labels = new Map(); + + if (unit === "day") { + for (const point of data) { + labels.set(point.timeseries, formatBucket(point.timeseries, "day")); + } + + return labels; + } + + const seen = new Map(); + + for (const point of data) { + const hour = formatBucket(point.timeseries, "hour"); + + seen.set(hour, (seen.get(hour) ?? 0) + 1); + } + + for (const point of data) { + const hour = formatBucket(point.timeseries, "hour"); + + labels.set( + point.timeseries, + (seen.get(hour) ?? 0) > 1 ? formatBucketWithDay(point.timeseries) : hour + ); + } + + return labels; + }, [data, unit]); +} + +export function TimeseriesChart({ + data, + unit, +}: { + data: TimeseriesPoint[]; + unit: "hour" | "day"; +}) { + // Two charts can share a page, and a shared gradient id would make the second + // one paint with the first one's fill. + const gradientId = `aurora-curtain-${useId().replace(/:/g, "")}`; + + const peak = data.reduce((max, point) => Math.max(max, point.count), 0); + const labels = useBucketLabels(data, unit); + + // `accessibilityLayer` makes Recharts emit `role="application"` with + // `tabindex="0"`, so the chart is a keyboard stop whose accessible name would + // otherwise be computed from its own contents — the axis ticks run together + // as "Jul 5Jul 605101520". The keyboard navigation is worth keeping; the name + // has to be stated. + const label = + data.length === 0 + ? "Pageviews chart, no data in this range" + : `Pageviews per ${unit}, ${formatBucketLong( + data[0].timeseries, + unit + )} to ${formatBucketLong( + data[data.length - 1].timeseries, + unit + )}, peak ${formatNumber(peak)}`; + + return ( + + + + Pageviews + {BUCKET_HINT} + + + {unit === "hour" ? "Hourly" : "Daily"} + + +
Peak
+
+ {formatCompactNumber(peak)} +
+
+
+ + {peak === 0 ? ( + + + + + + No pageviews in this range + + If the site is live, the tracking snippet may not be installed + yet. + + + + ) : ( + + + + {/* + * The signature: the aurora spectrum fading into the surface, + * the way the band sits over the horizon in the logo. The stop + * opacities are the fade, so the area is drawn at full opacity + * rather than Recharts' 0.6 default. + */} + + + + + + + + + labels.get(String(value)) ?? formatBucket(String(value), unit) + } + /> + + + formatBucketLong(String(value), unit) + } + /> + } + /> + + + + )} + +
+ ); +} diff --git a/apps/web/app/modules/analytics/components/timezone-picker.tsx b/apps/web/app/modules/analytics/components/timezone-picker.tsx new file mode 100644 index 00000000..03cff5d7 --- /dev/null +++ b/apps/web/app/modules/analytics/components/timezone-picker.tsx @@ -0,0 +1,92 @@ +import { GlobeIcon } from "lucide-react"; +import { useMemo } from "react"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "~/shared/ui/combobox"; +import { canonicalTimeZone, listTimeZones } from "../timezone"; + +/** + * Offsets for the list rows. + * + * The popup asks for four hundred of these the moment it opens, and each one + * builds a formatter, so the answers are kept. They're only as current as the + * page, which is close enough for a label that reads "GMT+2". + */ +const offsets = new Map(); + +function offsetOf(zone: string) { + const cached = offsets.get(zone); + + if (cached !== undefined) { + return cached; + } + + const label = + new Intl.DateTimeFormat("en-US", { + timeZone: zone, + timeZoneName: "shortOffset", + }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName")?.value ?? ""; + + offsets.set(zone, label); + + return label; +} + +export function TimezonePicker({ + value, + onChange, +}: { + value: string; + onChange: (tz: string) => void; +}) { + const zones = useMemo(listTimeZones, []); + + // Only ever read inside the popup, which doesn't exist until it's opened, so + // this can't disagree with the server the way it would in the markup. + // Canonicalised for the same reason the list is: a host reporting + // `Asia/Calcutta` would otherwise mark nothing at all, because the row that + // zone is offered under is `Asia/Kolkata`. + const detected = useMemo( + () => canonicalTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone), + [] + ); + + return ( + next && onChange(next)} + > + + + {value} + + + + + + No time zone found + + + {(zone: string) => ( + + {zone} + + + {zone === detected ? "detected" : offsetOf(zone)} + + + )} + + + + ); +} diff --git a/apps/web/app/modules/analytics/loader.server.ts b/apps/web/app/modules/analytics/loader.server.ts new file mode 100644 index 00000000..aa3cf4d2 --- /dev/null +++ b/apps/web/app/modules/analytics/loader.server.ts @@ -0,0 +1,269 @@ +import * as metrics from "./metrics.server"; +// Straight from the query layer rather than through metrics: it is the module +// that hands `tz` to Postgres, so the rule the loader turns into a 400 and the +// rule the statement enforces are one function and cannot drift apart. +import { isZoneName } from "./queries.server"; +import { CUSTOM_RANGE, DEFAULT_RANGE, isRangeKey, RANGES } from "./range"; +import { startOfZonedDay, zonedCalendarDay } from "./timezone"; + +const DAY_MS = 86_400_000; + +/** Hourly buckets stay readable across a couple of days; past that they crowd. */ +const HOURLY_MAX_SPAN = 2 * DAY_MS; + +/** A year of daily buckets is 366 points already, and the chart is the limit. */ +const MAX_SPAN = 366 * DAY_MS; + +/** Query params are strings; only a whole count of milliseconds is a timestamp. */ +function timestamp(value: string | null) { + if (value === null || value.trim() === "") { + return null; + } + + const ms = Number(value); + + return Number.isSafeInteger(ms) ? ms : null; +} + +/** + * Every filter resolves to a `from`/`to` pair of epoch milliseconds: the + * presets are just a rolling window ending now, so nothing downstream of here + * has to know which of the two the URL asked for. + */ +function resolveWindow(url: URL) { + const fromParam = url.searchParams.get("from"); + const toParam = url.searchParams.get("to"); + + // Either one present means a custom window was intended, so a half-written + // pair is an error rather than a silent fall back to the default range. + if (fromParam === null && toParam === null) { + const rangeParam = url.searchParams.get("range"); + const range = isRangeKey(rangeParam) ? rangeParam : DEFAULT_RANGE; + const to = Date.now(); + + // Exactly `days` x 24 hours, counted in milliseconds. This was date-fns + // `subDays`, which is calendar arithmetic in the *host's* zone + // (`setDate(getDate() - n)`), so the window was 24h/day only while the + // process's zone held one offset across it: on a server running + // America/New_York, "Last 24 hours" measured 23 or 25 hours for the 24 + // consecutive values of `to` around each transition, "Last 7 days" 167 or + // 169, "Last 30 days" 719 or 721. An hour out of 24 is ±4.2% on Pageviews, + // Daily visitors and Sessions, under a button that states the number. + // + // It also made the length of the window a property of the machine while + // every bucket, day boundary and comparison window is a property of `?tz`: + // two viewers of one instance in two zones got the same window, and a + // self-hoster who set the container's TZ changed what "Last 7 days" meant + // for everybody. A preset is a duration ending now — see RANGES, where + // `days` is documented as the length of the window — so it is measured as + // one, in the only unit no calendar can reinterpret. + return { range, from: to - RANGES[range].days * DAY_MS, to }; + } + + const from = timestamp(fromParam); + const to = timestamp(toParam); + + if (from === null || to === null) { + throw new Response("from and to must both be timestamps in milliseconds", { + status: 400, + }); + } + + // The picker sends the end of the selected day, which is in the future for a + // range ending today — and further ahead the more the viewer leads the + // server. Clamping keeps the chart from trailing off into empty buckets. + const end = Math.min(to, Date.now()); + + if (from >= end) { + throw new Response("from must be before to", { status: 400 }); + } + + if (end - from > MAX_SPAN) { + throw new Response("Range is longer than a year", { status: 400 }); + } + + return { range: CUSTOM_RANGE, from, to: end }; +} + +/** + * Where an instant sits in the charted zone: which calendar day it falls on, + * and whether it is that day's first instant. + * + * Alignment is a flag and not an offset into the day, deliberately. The elapsed + * milliseconds since local midnight are *not* the wall clock on a day that + * changed offset — 08:00 EDT on a spring-forward Sunday is seven hours elapsed, + * not eight — so carrying that remainder across a day boundary charged the + * transition of a neighbouring day to whatever it was carried into. The one + * question the comparison window has to ask is whether this window was drawn on + * calendar squares, and that is a yes or a no. + */ +function zonedDayPosition(at: number, tz: string) { + const day = zonedCalendarDay(at, tz); + + return { day, aligned: at === startOfZonedDay(day, tz) }; +} + +/** The same square of the calendar, `days` days earlier. */ +function daysBefore(day: Date, days: number) { + return new Date(day.getFullYear(), day.getMonth(), day.getDate() - days); +} + +/** + * The window immediately before this one, ending exactly where it starts. + * + * Two windows are only comparable if they are the same *length*, and what "the + * same length" means depends on what the current one is a window *of*. This + * dashboard draws two kinds, and they need opposite arithmetic. + * + * A pinned window is a run of calendar days: the picker builds it from + * `startOfZonedDay` to `endOfZonedDayExclusive`, and "Mar 8" means the whole of + * Mar 8 however many hours the zone gave it. Its comparison is the days before + * it. Counting back `to - from` milliseconds instead landed an hour inside the + * day before — the 23 hours preceding a 23-hour day start at 01:00 of the + * previous day, not at its midnight — so every stat card compared 23 hours of + * traffic against 24 and reported the ~4% as a trend. Read off the calendar + * with `startOfZonedDay`, the same zone database the query layer groups buckets + * by, so the comparison lines up with the buckets drawn over it. + * + * A preset is a *duration* ending now: "Last 24 hours" is 24 hours, at whatever + * o'clock now happens to be, and RANGES documents `days` as the length of the + * window for exactly that reason. Shifting one of those by calendar days is the + * same defect with its sign flipped, and worse — the comparison comes back 23 + * or 25 hours against a current 24, which is the phantom trend restored on the + * *default* range; it charges the window the transition of a day it need not + * even contain; and where the window ends at midnight but starts 24 hours into + * a 25-hour day it comes back inverted, `start` after `end`. `withinRange` + * answers an inverted window with zero rows and no error, so every count tile + * read "New" and the two rate tiles were compared against nothing at all. A + * window measured in milliseconds is compared in milliseconds, which is exact: + * neither end is pinned to a calendar square, so no day's length is being asked + * about. + * + * Which kind it is comes from the caller rather than from the instants. The two + * are not distinguishable after the fact: once every 24 hours `Date.now()` + * lands on a local midnight and a preset resolves to a window shaped exactly + * like a picked calendar day. Inferring from the shape gave "Last 24 hours" a + * 23-hour comparison on that one instant a year per zone — measured, against + * Postgres, on uniform hourly traffic: 24 against 23 at 2026-03-10 00:00 + * America/New_York, and nowhere else in the fortnight around the transition. + * + * The end is `from` in both cases, and that is what makes the two windows tile: + * every range predicate is half-open (`>= start`, `< end`, see withinRange), so + * the shared instant belongs to this window and to no other. A + * `previous.end = from - 1` patch would leave a millisecond in neither. + */ +function previousWindow( + from: number, + to: number, + tz: string, + { pinned }: { pinned: boolean } +) { + if (pinned) { + const start = zonedDayPosition(from, tz); + const end = zonedDayPosition(to, tz); + + // Rounded, not truncated: these are two midnights as the *host* zone reads + // them, and a host with its own DST puts 23 or 25 hours between a pair of + // them just as readily. + const days = Math.round((end.day.getTime() - start.day.getTime()) / DAY_MS); + + // Not every pinned window is whole days — `?from=&to=` is a public URL and + // takes any two instants — and one that is not spans no calendar to read. + if (days > 0 && start.aligned && end.aligned) { + const shifted = startOfZonedDay(daysBefore(start.day, days), tz); + + // Unreachable, since `days >= 1` puts `shifted` a whole calendar day or + // more before `from` in every zone. Checked rather than argued because + // what it guards against is a window the query layer answers with zero + // rows and no error. + if (shifted < from) { + return { start: shifted, end: from }; + } + } + } + + return { start: from - (to - from), end: from }; +} + +/** + * The old dashboard held filters in a reducer and fired eight SWR requests. + * The window and timezone now live in the URL so a single loader can resolve + * every panel server-side, and the view is shareable/bookmarkable. + */ +export function resolveFilters(url: URL) { + const tz = url.searchParams.get("tz") || "UTC"; + + // Rejected here so a bad ?tz surfaces as a 400 rather than a query error. + // Names only: Intl and Postgres both accept `+05:30` and read its sign the + // opposite way round, so an offset would silently relabel the chart eleven + // hours off. Same rule the query layer enforces as a last line of defence. + if (!isZoneName(tz)) { + throw new Response("Invalid time zone", { status: 400 }); + } + + const { range, from, to } = resolveWindow(url); + const span = to - from; + + return { + range, + tz, + from, + to, + // Derived from the span rather than carried by the range, so a custom + // window of a day buckets the same way the 24 hour preset does. + unit: span <= HOURLY_MAX_SPAN ? ("hour" as const) : ("day" as const), + // The window immediately before this one, so every figure on the dashboard + // can be stated as a change rather than a bare count. Anchored to `from`, + // which tiles the two windows exactly: every range predicate is half-open + // (`>= start`, `< end`), so the shared endpoint belongs to this window + // only. While both ends were inclusive an event landing exactly on `from` + // was counted in both, and the trend was a comparison of two overlapping + // sets. + // + // `pinned` is the one thing the instants cannot say for themselves: a + // window someone picked off the calendar is compared against the calendar + // days before it, and a preset — a duration ending now — against the same + // duration. See previousWindow. + previous: previousWindow(from, to, tz, { pinned: range === CUSTOM_RANGE }), + }; +} + +/** + * One render is 17 statements: both statistics windows, the series, one per + * breakdown dimension, and the goals. They are independent index ranges over + * the same window, so they go out together and the render costs the slowest of + * them rather than their sum. + * + * Every one of them is drawn. `channels` is the thirteenth and the newest — the + * column has been computed at ingest since the schema change and rendered + * nowhere — and it is the panel that makes this dashboard's acquisition figures + * true rather than an artefact of pages-per-visit. The seven acquisition + * dimensions cost slightly *less* than they did before they were scoped to + * `is_new_session`: the qual is a heap-side filter over rows the range scan has + * already fetched, and it halves what reaches the aggregate. + */ +export async function loadDashboard(wid: string, url: URL) { + const filters = resolveFilters(url); + const window = { start: filters.from, end: filters.to }; + + const [stats, previousStats, series, breakdowns, events] = await Promise.all([ + metrics.statistics(wid, window), + metrics.statistics(wid, filters.previous), + metrics.timeseries(wid, { ...window, unit: filters.unit, tz: filters.tz }), + metrics.breakdowns(wid, window), + metrics.customEvents(wid, window), + ]); + + return { + range: filters.range, + from: filters.from, + to: filters.to, + unit: filters.unit, + tz: filters.tz, + stats, + previousStats, + series, + breakdowns, + events, + }; +} diff --git a/apps/web/app/modules/analytics/metrics.server.ts b/apps/web/app/modules/analytics/metrics.server.ts new file mode 100644 index 00000000..c36d7938 --- /dev/null +++ b/apps/web/app/modules/analytics/metrics.server.ts @@ -0,0 +1,130 @@ +import localeCodes from "locale-codes"; +import { + BREAKDOWN_DIMENSIONS, + getWebsiteBreakdown, + getWebsiteCustomEvents, + getWebsiteStatistics, + getWebsiteViewsTimeSeries, + type CustomEventRow, + type EventRevenue, +} from "./queries.server"; +import type { + Breakdown, + BreakdownRow, + Breakdowns, + BreakdownUnit, + Statistics, + TimeseriesPoint, +} from "./types"; + +export type { + Breakdown, + BreakdownRow, + Breakdowns, + BreakdownUnit, + CustomEventRow, + EventRevenue, + Statistics, + TimeseriesPoint, +}; + +/** The window every panel is asked for, as epoch milliseconds. */ +type Window = { start: number; end: number }; + +/** + * The chart's series, one point per bucket, gaps included. + * + * The padding used to be generated here, by stepping milliseconds from a zone + * offset this module derived itself. That is a second implementation of the + * calendar Postgres already has, and it disagreed with the first one for 37 + * IANA zones, for every window containing a DST transition, and for numeric + * zones — each disagreement showing up as buckets the chart quietly read as + * zero, under stat tiles that still counted the same events. The series now + * arrives padded from the same statement that counts, so there is nothing left + * for the two sides to disagree about; see getWebsiteViewsTimeSeries. + */ +export async function timeseries( + wid: string, + filters: { start: number; end: number; unit: string; tz: string } +): Promise { + const rows = await getWebsiteViewsTimeSeries(wid, filters); + + return rows.map((row) => ({ + timeseries: row.ts.toISOString(), + count: row.count, + })); +} + +/** + * Every panel, in parallel. + * + * One query per dimension: they are independent index ranges over the same + * window, so the database reads each one on its own and nothing here waits on + * anything else. What used to be a `metadata.type` string threaded through two + * joins is now the choice of a column, which is why a panel costs a query and + * no more. + * + * Each panel arrives carrying the unit it is counted in — the acquisition + * dimensions are per-session and the rest per-pageview (see BREAKDOWN_SCOPES) — + * and that travels through untouched, so nothing between the query and the + * column header gets to decide what the numbers are. + */ +export async function breakdowns( + wid: string, + filters: Window +): Promise { + const panels = await Promise.all( + BREAKDOWN_DIMENSIONS.map(async (dimension) => { + const panel = await getWebsiteBreakdown(wid, dimension, filters); + + return [ + dimension, + dimension === "locales" + ? { ...panel, rows: panel.rows.map(toLocaleName) } + : panel, + ] as const; + }) + ); + + return Object.fromEntries(panels) as Breakdowns; +} + +/** + * `en-GB` is the only dimension the database stores in a form nobody reads. + * + * Resolved to the language, qualified by region when the tag carries one. + * Not to locale-codes' `location` alone, which is what this returned while the + * panel was still mislabelled as countries: a locales list reading "Italy" next + * to a countries list reading "Italy" is the same answer twice, and it drops + * the half of the tag the panel exists for. The browser language says who the + * reader is, the edge header says where they are, and they disagree often. + * `location` is also null on a bare `en` or `fr`, so that reading left the + * panel mixing country names with raw tags. + */ +function toLocaleName(row: BreakdownRow): BreakdownRow { + const locale = localeCodes.getByTag(row.element); + + // Unknown to locale-codes, including the empty bucket the panels label + // "Unknown": the tag survives rather than becoming "undefined". + if (!locale?.name) { + return row; + } + + return { + ...row, + element: locale.location + ? `${locale.name} (${locale.location})` + : locale.name, + }; +} + +export function statistics(wid: string, filters: Window): Promise { + return getWebsiteStatistics(wid, filters); +} + +export function customEvents( + wid: string, + filters: Window +): Promise { + return getWebsiteCustomEvents(wid, filters); +} diff --git a/apps/web/app/modules/analytics/queries.server.ts b/apps/web/app/modules/analytics/queries.server.ts new file mode 100644 index 00000000..1753d85b --- /dev/null +++ b/apps/web/app/modules/analytics/queries.server.ts @@ -0,0 +1,564 @@ +import { events, type EventType } from "~/db/schema"; +import { and, eq, gte, lt, sql } from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import { db } from "~/shared/lib/db.server"; +import { assertTimeseriesUnit, isValidTimeZone } from "./timezone"; +import type { Breakdown, Breakdowns, Statistics, TimeseriesRow } from "./types"; + +export type DateFilters = { + start?: string | number | null; + end?: string | number | null; +}; + +/** + * A bound as an instant, or null when there isn't one. + * + * Not `Number(value)` behind a truthiness test: `0` is a real epoch + * millisecond and a falsy JS number, so testing the raw value dropped the + * predicate rather than applying it. `?from=-1000&to=0` therefore asked for one + * second of 1970 and got the site's lifetime totals — on the public dashboard, + * an anonymous read straight past the loader's documented span cap. + * + * A bound that is present but unreadable throws instead of returning null for + * the same reason: silently widening a window is the one failure mode here that + * nobody can see from the outside. + */ +function instant(value: string | number | null | undefined) { + if (value === null || value === undefined || value === "") { + return null; + } + + const ms = Number(value); + + if (!Number.isFinite(ms)) { + throw new Error(`Invalid timestamp: ${value}`); + } + + return new Date(ms); +} + +/** + * Range predicate shared by every metric query, half-open: `>= start`, `< end`. + * + * Both ends used to be inclusive, which made the boundary instant a member of + * two windows — `resolveFilters` anchors the comparison window at + * `previous.end === from === current.start`, so an event landing exactly on it + * was counted in both and the trend arrow was computed from overlapping sets. + * Half-open is also what lets consecutive windows tile the timeline with no gap + * and no double count, which a `previous.end = from - 1` patch would not. + */ +function withinRange({ start, end }: DateFilters) { + const from = instant(start); + const to = instant(end); + + return [ + from ? gte(events.created_at, from) : undefined, + to ? lt(events.created_at, to) : undefined, + ].filter(Boolean); +} + +/** + * An IANA zone *name*, and not an offset. + * + * `Intl` accepts `+05:30` as a time zone and reads it as UTC+5:30. Postgres + * accepts it too and reads it as UTC-5:30, because a bare numeric zone is POSIX + * and POSIX signs them the other way round. Nothing downstream can reconcile + * that, so the two sides only ever agree on a name — and a name is all the + * picker offers and all `Intl.DateTimeFormat().resolvedOptions().timeZone` + * returns on any runtime the browser matrix contains. + * + * `Etc/GMT+5` is deliberately still allowed: it is a zone name, and both sides + * read it the same (POSIX) way. + */ +const ZONE_NAME = /^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/; + +export function isZoneName(tz: string) { + return ZONE_NAME.test(tz) && isValidTimeZone(tz); +} + +export function assertZoneName(tz: string) { + if (!isZoneName(tz)) { + throw new Error(`Invalid time zone: ${tz}`); + } + + return tz; +} + +/** + * `type` is named by every metric query, never left implicit. + * + * The dashboard index is (website_id, type, created_at) with type in the + * middle, and Postgres 16 has no skip scan: leaving the qual out does not cost + * a little, it demotes type to a heap filter that reads and discards every + * custom event in the window — sixteen times, once per query a render fires. + * It is also the difference between "pageviews" and "pageviews plus whatever + * the site's own `aurora()` calls happen to fire", which is the number the + * headline tile claims to be showing. + */ +const scopedTo = (wid: string, type: EventType, filters: DateFilters) => + and( + eq(events.website_id, wid), + eq(events.type, type), + ...withinRange(filters) + ); +/** + * Metrics + * + * These aggregate in Postgres rather than hydrating every matching event row + * into Node to be counted there, which is what the Prisma version did. Every + * `count(*)` is cast to int on the way out: bigint has no JSON representation + * and these all end up in a serialised loader payload. + */ + +/** + * One column per panel — the whole of what used to be a `metadata.type` string + * and two joins through a shared value table — and the rows that column is + * meaningful over. + * + * The acquisition dimensions are scoped to `is_new_session`, which is the whole + * of this table that is not mechanical. `referrer_host` is set only on the + * pageview that opened a visit: the tracker reads `document.referrer` once per + * document and ingest drops self-referrals, so every later pageview of a visit + * carries null. `channel` does not rescue it — it is resolved per event from + * that same referrer, so pageviews 2..N are classified `direct` for exactly the + * same reason — and neither do the utm columns, which are lifted off + * `location.search` at the moment of each view. Grouped over every pageview, + * those seven answered a question about page-reading and were labelled as an + * answer about acquisition: a site where 100 visitors arrive from google.com and + * read five pages each reported `google.com 100 / 400`, with every real + * referrer's share understated in proportion to pages-per-visit. + * + * `is_new_session` is the flag ingest sets on a session's first pageview and + * only there — custom events never carry it — so it is the arrival, and these + * become per-session counts. That changes what the panels count, which is why + * `unit` is here and is checked against `Breakdowns`: a dimension cannot change + * scope without the wire shape saying so, and the column header the dashboard + * draws is read off that same declaration rather than restated beside it. + * + * The technology dimensions stay scoped to pageviews. Browser, OS, device, + * country, locale and path are facts about the view, they are recorded on every + * one of them, and "which pages were read" is the question that panel exists to + * answer. + * + * `empty` is what to do with the rows where the column is null, and it is a + * per-dimension decision because null does not mean the same thing twice. + * + * For most of them it is an answer: no country is a deployment with no geo-aware + * proxy in front of it, no referrer is a visit that arrived without one. Those + * are `counted`, and the panels name the bucket ("Unknown", "No referrer"). + * + * For the five utm columns it is the *absence* of the thing the panel is a list + * of. Every visit that arrived without campaign parameters — on a normal site, + * substantially all of them — coalesced into one bucket the panel then labelled + * "Unknown", which reads as "a campaign we could not attribute" and is counted + * as "arrived without a campaign". It sorted first, so it was also the bar every + * real campaign's share was drawn relative to, and its Daily visitors figure was + * the site's whole audience sitting in a card headed "Campaigns". They are + * `omitted`: a session with no utm_source is not an unidentified source, it is + * not a row. How much traffic that is remains on the dashboard and one card + * over, where `channel` counts a visit as `campaign` if it carried any utm value + * at all — which is the question with a denominator behind it. + */ +const BREAKDOWN_SCOPES = { + pages: { column: events.path, unit: "views", empty: "counted" }, + referrers: { + column: events.referrer_host, + unit: "sessions", + empty: "counted", + }, + channels: { column: events.channel, unit: "sessions", empty: "counted" }, + browsers: { column: events.browser, unit: "views", empty: "counted" }, + os: { column: events.os, unit: "views", empty: "counted" }, + devices: { column: events.device, unit: "views", empty: "counted" }, + countries: { column: events.country, unit: "views", empty: "counted" }, + locales: { column: events.locale, unit: "views", empty: "counted" }, + utmSources: { + column: events.utm_source, + unit: "sessions", + empty: "omitted", + }, + utmMediums: { + column: events.utm_medium, + unit: "sessions", + empty: "omitted", + }, + utmCampaigns: { + column: events.utm_campaign, + unit: "sessions", + empty: "omitted", + }, + utmTerms: { column: events.utm_term, unit: "sessions", empty: "omitted" }, + utmContents: { + column: events.utm_content, + unit: "sessions", + empty: "omitted", + }, +} satisfies { + [D in keyof Breakdowns]: { + column: AnyPgColumn; + unit: Breakdowns[D]["unit"]; + empty: "counted" | "omitted"; + }; +}; + +export type BreakdownDimension = keyof typeof BREAKDOWN_SCOPES; + +export const BREAKDOWN_DIMENSIONS = Object.keys( + BREAKDOWN_SCOPES +) as BreakdownDimension[]; + +/** + * Panels fold their list at eight rows and the tail of `path` or `utm_term` is + * unbounded, so everything past this would be serialised into the document and + * never drawn. Twelve panels make that twelve times over. + */ +const BREAKDOWN_LIMIT = 100; + +/** + * One panel, with the unit its rows are in. + * + * Grouped twice on purpose. `count(DISTINCT visitor_id)` in the outer position + * is an *ordered* aggregate, and an ordered aggregate switches the whole node + * off hash aggregation (`numOrderedAggs > 0` disables `can_hash`) and off + * parallelism: every panel became a GroupAggregate over a full sort of the + * window, spilling to disk, twelve times per render. Grouping by + * (element, visitor_id) first and counting the groups asks the same question + * with plain `count(*)`, which hashes. + * + * Measured on 588k events, one site, default work_mem: at the 7 day preset all + * twelve panels go from ~125ms to ~25ms and stop writing temp files; at 30 days + * the input no longer fits in work_mem either way and it is a wash (~490ms + * both). Never slower, and it is the shape that benefits from more memory + * rather than the shape that cannot use it. Row-for-row identical output, + * checked against the previous query for all twelve dimensions. + * + * The acquisition scope (see BREAKDOWN_SCOPES) is a heap-side filter and not an + * access-path change — `is_new_session` is in no index and must not be put in + * one, since ADDENDUM v2 §D reserves this table's HOT-update headroom. It costs + * nothing: the qual is evaluated on rows the scan has already fetched for the + * `(website_id, type, created_at)` range, and it removes half of them before the + * aggregate. Measured warm on 494k pageviews / 240k sessions, one site, default + * work_mem, three runs each — 24h 5.0ms -> 3.3ms, 7d 25.3ms -> 19.2ms (same + * bitmap index scan, same 2341 buffers, `Filter: is_new_session` removing 27159 + * of 52358 rows), 30d 200ms -> 170ms (both seq-scan the 42% of the table the + * window covers; the filtered one stops the HashAggregate spilling 7MB to a + * temp file). Cheaper at every preset, and never a page more read. + */ +export async function getWebsiteBreakdown( + wid: string, + dimension: BreakdownDimension, + filters: DateFilters = {} +): Promise { + const { column, unit, empty } = BREAKDOWN_SCOPES[dimension]; + + const perVisitor = db + .select({ + // Null is an answer, not a missing row, wherever `empty` says so: no + // referrer means a visit that arrived without one, no country means a + // deployment with no geo-aware proxy in front of it. Dropped instead of + // bucketed, those would leave the panel's totals disagreeing with the + // headline count for no visible reason. The empty string is what the + // panels already label ("No referrer", "Unknown"); putting that wording in + // SQL would both hard-code UI copy and collide with a site whose real + // value is the word Unknown. + element: sql`coalesce(${column}, '')`.as("element"), + visitor: events.visitor_id, + count: sql`count(*)`.as("count"), + }) + .from(events) + .where( + and( + scopedTo(wid, "pageview", filters), + unit === "sessions" ? eq(events.is_new_session, true) : undefined, + // Written against the same `coalesce` the bucket is grouped by rather + // than as `is not null`, so what is filtered out is exactly the row the + // panel would have drawn as the empty bucket — including a legacy row + // holding '' rather than null, which the two spellings would otherwise + // disagree about. One more heap-side qual on rows the range scan has + // already fetched; on a site with no campaigns it removes all of them. + empty === "omitted" ? sql`coalesce(${column}, '') <> ''` : undefined + ) + ) + // By ordinal: drizzle renders a column unqualified inside an sql template + // and qualified in a groupBy position, so naming the bucket here would + // mean writing the expression a second time and trusting two spellings of + // it to stay identical. + .groupBy(sql`1, 2`) + .as("per_visitor"); + + const rows = await db + .select({ + element: perVisitor.element, + count: sql`sum(${perVisitor.count})::int`, + // Distinct visitors. One inner row per (element, visitor), so counting + // them is the distinct count — see the note above on why it is not + // spelled that way. + unique: sql`count(*)::int`, + }) + .from(perVisitor) + .groupBy(sql`1`) + // The tie-break is what makes the cut stable — without it two windows of + // the same dashboard can disagree about which rows are in the last places, + // and the panel reshuffles between renders. + .orderBy(sql`sum(${perVisitor.count}) DESC, 1 ASC`) + .limit(BREAKDOWN_LIMIT); + + return { unit, rows }; +} + +/** What one goal earned in one currency. Never merged with another currency. */ +export type EventRevenue = { currency: string; total: number }; + +export type CustomEventRow = { + name: string; + count: number; + unique: number; + /** + * One total per currency the goal was reported in, largest first, and empty + * when it carries no revenue at all. + * + * Not a single number: ingest stores `currency` alongside every amount + * precisely because a site can sell in more than one, and `sum(revenue)` + * across the group answered 49.00 EUR + 10.00 USD with "59" — a quantity in + * no unit, which no consumer could tell from money. + */ + revenue: EventRevenue[]; +}; + +/** + * Goals: the named events a site fires itself through `aurora()`. + * + * Deliberately the one metric that reads `type = 'event'` — everything else on + * the dashboard excludes them, which is why they can be counted here without + * any of it moving. + * + * Two passes over the same (small) slice rather than one: the counts have to be + * per name, the money has to be per name *and* currency, and rolling the two + * into one grouping would either split a goal's count across its currencies or + * double-count a visitor who paid in two of them. + */ +export async function getWebsiteCustomEvents( + wid: string, + filters: DateFilters = {} +): Promise { + const scope = scopedTo(wid, "event", filters); + + const result = await db.execute<{ + name: string; + count: number; + unique: number; + revenue: EventRevenue[]; + }>(sql` + with goals as ( + select + coalesce(${events.name}, '') as name, + count(*)::int as count, + count(distinct ${events.visitor_id})::int as "unique" + from ${events} + where ${scope} + group by 1 + -- The tie-break is what makes the cut stable: ordering by count alone + -- leaves the rows at the limit in an order Postgres may change between + -- two renders of the same window. + order by count(*) desc, 1 asc + limit ${BREAKDOWN_LIMIT} + ), + money as ( + select + coalesce(${events.name}, '') as name, + ${events.currency} as currency, + -- revenue is numeric(14,2), and sum(numeric) comes back from pg as a + -- string — an annotation of sql is a claim, not a conversion, + -- so without the cast "49.00" would reach the dashboard and the next + -- addition would concatenate. numeric is still the right column type: + -- float8 addition is non-associative and the total would change with + -- the scan order. Round at the display edge, not in the ledger. + sum(${events.revenue})::float8 as total + from ${events} + where ${scope} + and ${events.revenue} is not null + and ${events.currency} is not null + group by 1, 2 + ) + select + goals.name as name, + goals.count as count, + goals."unique" as "unique", + coalesce( + jsonb_agg( + jsonb_build_object('currency', money.currency, 'total', money.total) + order by money.total desc, money.currency asc + ) filter (where money.currency is not null), + '[]'::jsonb + ) as revenue + from goals + left join money on money.name = goals.name + group by goals.name, goals.count, goals."unique" + order by goals.count desc, goals.name asc + `); + + return result.rows.map((row) => ({ + name: row.name, + count: Number(row.count), + unique: Number(row.unique), + revenue: row.revenue ?? [], + })); +} + +/** + * The five headline figures in one pass. + * + * `uniqueVisits` and `sessions` count distinct ids rather than the rows carrying + * is_new_visitor / is_new_session, and `bounces` counts sessions rather than + * flagged rows: the definition is "sessions that stopped at one page", and the + * ingest path clears the flag on the whole session the moment a second pageview + * lands, so counting flagged rows against a denominator of sessions could and + * did report a bounce rate above 100%. + * + * Read `uniqueVisits` as visitor-*days*, not as an audience. + * `visitor_id` is an HMAC whose message starts with the UTC date (schema.ts), + * so one reader is a different id every day and no id spans two of them: over + * an N day window this is the sum of the N daily unique counts, and it grows + * with the window exactly as the flag count it replaced did. That is the + * definition the schema commits to — a daily pseudonym is what makes the + * identifier consent-free — and the tile has to be read that way. What the + * change actually buys is smaller than "the headline bug fix": a definition + * evaluated at read time instead of a stored derivative that depends on ingest + * having set a flag exactly once, and "seen in the window" instead of "first + * seen in the window", which differ across the window's leading edge. + * + * avgDuration is per session, not per event — a five page visit is one visit. + * The two-level aggregate that states literally (average over sessions of the + * sum of that session's durations) collapses into this single pass: a session's + * sum is null only when every one of its rows is null, and avg() skips exactly + * those, so the numerator is the plain sum over the window and the denominator + * is the number of sessions that timed at least one page. Pageviews whose + * beacon never arrived stay null rather than counting as zero, which is what + * keeps them from dragging the average down. + */ +export async function getWebsiteStatistics( + wid: string, + filters: DateFilters = {} +): Promise { + const [row] = await db + .select({ + visits: sql`count(*)::int`, + uniqueVisits: sql`count(DISTINCT ${events.visitor_id})::int`, + sessions: sql`count(DISTINCT ${events.session_id})::int`, + bounces: sql`count(DISTINCT ${events.session_id}) FILTER (WHERE ${events.is_a_bounce})::int`, + // duration is double precision, so this stays a JS number; the same + // expression over a numeric column would arrive as a string. + avgDuration: sql< + number | null + >`sum(${events.duration}) / nullif(count(DISTINCT ${events.session_id}) FILTER (WHERE ${events.duration} IS NOT NULL), 0)`, + }) + .from(events) + .where(scopedTo(wid, "pageview", filters)); + + return { + visits: row?.visits ?? 0, + uniqueVisits: row?.uniqueVisits ?? 0, + sessions: row?.sessions ?? 0, + bounces: row?.bounces ?? 0, + // The SQL answers null when nothing in the window timed a page at all, and + // that is a different fact from "the average was zero" — the column is + // nullable for exactly this reason, and so is `Statistics.avgDuration`. + // Flattened to 0, a self-hoster whose duration beacons are blocked read a + // confident "0s" where the honest answer is "no data". + avgDuration: row?.avgDuration ?? null, + }; +} + +/** + * A bucket label as an instant. + * + * `timestamptz AT TIME ZONE tz` yields `timestamp without time zone`, and + * drizzle's node-postgres session overrides the type parser for that OID to + * hand back the raw string ("2026-08-03 14:00:00"). Passing that to `new Date()` + * would read it in the *server's* zone, so a host that isn't UTC would shift + * every bucket; the `Z` pins it. The whole series is wall-clock-in-`tz` + * labelled as UTC, which is the only labelling under which two buckets an hour + * apart are an hour apart on the axis. + * + * The Date branch cannot run under the current driver. It is here rather than + * absent because raw `pg` parses OID 1114 in the host's local zone, so a driver + * change, a `types` option or a drizzle upgrade would otherwise put the naive + * timestamp back through exactly the shift this function exists to prevent. + * Reading the local fields back out is what undoes it. + */ +function bucketAt(value: string | Date): Date { + if (typeof value === "string") { + return new Date(`${value.replace(" ", "T")}Z`); + } + + return new Date( + Date.UTC( + value.getFullYear(), + value.getMonth(), + value.getDate(), + value.getHours(), + value.getMinutes(), + value.getSeconds(), + value.getMilliseconds() + ) + ); +} + +/** + * Bucketed pageview counts, already padded. + * + * The empty buckets used to be generated in JS from a single zone offset + * sampled at the start of the window, which is a reimplementation of Postgres' + * calendar and was wrong in three separate ways: `Date.parse` could not read + * the zone abbreviation for 37 IANA zones (Halifax, Anchorage, Honolulu, + * Bermuda, most of the Caribbean) and returned NaN, which produced *no* buckets + * and a blank chart; one offset for the whole window dropped the newest bucket + * whenever DST began inside it, because Postgres buckets each row at the offset + * in force for that row; and a numeric zone like `+05:30` means UTC+5:30 to + * Intl and UTC-5:30 to Postgres, so the two sides bucketed eleven hours apart. + * + * `generate_series` over the same `date_trunc(unit, created_at AT TIME ZONE tz)` + * expression removes the class rather than the three instances: there is now + * one calendar, one zone database and one interpretation of `tz`, and a label + * the query cannot produce is a label the padding cannot generate. Stepping is + * in naive wall-clock space, which is the space `date_trunc` returns, so a day + * is a local day and an hour a local hour across a transition. + * + * `unit` and `tz` are bound as parameters and additionally checked against an + * allow-list, so a bad value fails with a clear error rather than a Postgres + * type error. + */ +export async function getWebsiteViewsTimeSeries( + wid: string, + filters: { start: number; end: number; unit: string; tz: string } +): Promise { + const unit = assertTimeseriesUnit(filters.unit); + const tz = assertZoneName(filters.tz); + + const from = new Date(filters.start); + const to = new Date(filters.end); + + const result = await db.execute<{ ts: string | Date; count: number }>(sql` + with counts as ( + select + date_trunc(${unit}, ${events.created_at} AT TIME ZONE ${tz}) as ts, + count(*)::int as count + from ${events} + where ${scopedTo(wid, "pageview", filters)} + group by 1 + ) + select series.ts as ts, coalesce(counts.count, 0)::int as count + from generate_series( + date_trunc(${unit}, ${from}::timestamptz AT TIME ZONE ${tz}), + date_trunc(${unit}, ${to}::timestamptz AT TIME ZONE ${tz}), + ('1 ' || ${unit})::interval + ) as series(ts) + left join counts on counts.ts = series.ts + order by series.ts + `); + + return result.rows.map((row) => ({ + ts: bucketAt(row.ts), + count: Number(row.count), + })); +} diff --git a/apps/web/app/modules/analytics/range.ts b/apps/web/app/modules/analytics/range.ts new file mode 100644 index 00000000..f15f6a74 --- /dev/null +++ b/apps/web/app/modules/analytics/range.ts @@ -0,0 +1,42 @@ +/** + * The dashboard's time filter vocabulary, shared by the loader and the picker. + * + * Kept out of analytics.server for the reason given in types.ts: the picker + * needs these names, and importing them shouldn't drag the loader into the + * browser bundle. + * + * Every window ultimately reaches the loader as a `from`/`to` pair of epoch + * milliseconds. A preset is only shorthand for one that rolls with the clock, + * which is why it stays in the URL as a name instead of being expanded: a + * shared "last 7 days" link should mean the last seven days to whoever opens + * it, not the week the sender was looking at. + */ +/** + * `days` is the length of the window, and it has to be the number the label + * says. These read 6 and 29 — inherited from the pre-rewrite loader, where they + * were chosen so the day-bucketed chart would draw exactly 7 and 30 bars. That + * is an argument about the shape of a chart, and it was paid for with every + * figure on the dashboard: "Last 7 days" measured 144 hours, and the trend + * beneath it compared 144 hours against the 144 before them. The chart now pads + * from the window it is given, so the bar count follows the window rather than + * the window following the bar count. + */ +export const RANGES = { + LAST_24_HOURS: { label: "Last 24 hours", short: "24h", days: 1 }, + LAST_7_DAYS: { label: "Last 7 days", short: "7d", days: 7 }, + LAST_30_DAYS: { label: "Last 30 days", short: "30d", days: 30 }, +} as const; + +export type RangeKey = keyof typeof RANGES; + +/** What the loader reports when the window came from an explicit from/to. */ +export const CUSTOM_RANGE = "CUSTOM"; + +export const DEFAULT_RANGE: RangeKey = "LAST_24_HOURS"; + +export function isRangeKey(value: string | null): value is RangeKey { + return value !== null && value in RANGES; +} + +/** A preset by name, or a window pinned to two instants. */ +export type RangeSelection = { range: RangeKey } | { from: number; to: number }; diff --git a/apps/web/app/modules/analytics/timezone.ts b/apps/web/app/modules/analytics/timezone.ts new file mode 100644 index 00000000..e9b9e2d4 --- /dev/null +++ b/apps/web/app/modules/analytics/timezone.ts @@ -0,0 +1,411 @@ +/** + * `date_trunc`'s unit and the `AT TIME ZONE` operand end up in the timeseries + * query. Both are bound as parameters, but they are validated here as well so a + * bad value fails with a clear message instead of a Postgres type error. + * + * Shared by the loader (which turns a bad value into a 400) and the query layer + * (which treats it as a last line of defence). + */ + +export const TIMESERIES_UNITS = ["hour", "day", "month", "year"] as const; + +export type TimeseriesUnit = (typeof TIMESERIES_UNITS)[number]; + +/** + * The zone names this runtime offers that Postgres refuses. + * + * `Intl.supportedValuesOf("timeZone")` is not the canonical zone list. On the + * V8 the project runs it still reports 18 pre-2018 tzdata names — `Asia/Calcutta`, + * `Europe/Kiev`, `America/Buenos_Aires` and the rest below — and it does not + * canonicalise them either: `Asia/Calcutta` stays `Asia/Calcutta` through + * `DateTimeFormat`, so nothing in Intl turns one into a name the other side + * knows. Postgres carries only the current tzdata, where those names are gone; + * `AT TIME ZONE 'Asia/Calcutta'` is a `time zone "Asia/Calcutta" not recognized` + * error. Picking one from the dashboard therefore produced a 500 out of the + * query layer rather than a validation error out of the loader. + * + * Filtering them out of the offered list is not enough on its own: these are the + * *only* entries this runtime lists for India, Ukraine, Argentina, Greenland, + * Myanmar, Vietnam, Nepal and the rest, so dropping them would leave those + * regions with no zone to pick at all. They are substituted instead — each pair + * below is the same zone under its current name, checked to agree with its alias + * to the minute at every month of the year. + * + * Verified against postgres:16, the image in docker-compose.yml: all 419 names + * `listTimeZones` offers after substitution are accepted by `AT TIME ZONE`, and + * every one of them agrees with Intl on the wall clock it reads, in January and + * in July. Re-run that check when the runtime's Node or the image's tzdata moves. + * + * A Map rather than an object literal: `"constructor" in {}` is true, and this + * table is consulted with a string that arrives from a query parameter. + */ +const ZONE_ALIASES = new Map([ + ["Africa/Asmera", "Africa/Asmara"], + ["America/Buenos_Aires", "America/Argentina/Buenos_Aires"], + ["America/Catamarca", "America/Argentina/Catamarca"], + ["America/Cordoba", "America/Argentina/Cordoba"], + ["America/Godthab", "America/Nuuk"], + ["America/Indianapolis", "America/Indiana/Indianapolis"], + ["America/Jujuy", "America/Argentina/Jujuy"], + ["America/Louisville", "America/Kentucky/Louisville"], + ["America/Mendoza", "America/Argentina/Mendoza"], + ["Asia/Calcutta", "Asia/Kolkata"], + ["Asia/Katmandu", "Asia/Kathmandu"], + ["Asia/Rangoon", "Asia/Yangon"], + ["Asia/Saigon", "Asia/Ho_Chi_Minh"], + ["Atlantic/Faeroe", "Atlantic/Faroe"], + ["Europe/Kiev", "Europe/Kyiv"], + ["Pacific/Enderbury", "Pacific/Kanton"], + ["Pacific/Ponape", "Pacific/Pohnpei"], + ["Pacific/Truk", "Pacific/Chuuk"], +]); + +/** + * A zone name under the spelling both sides of the app know. + * + * Applied wherever a zone enters the app from outside the picker — chiefly + * `Intl.DateTimeFormat().resolvedOptions().timeZone`, which on a host whose + * `TZ` is set to a legacy name hands back that legacy name. Left alone it goes + * into the URL, comes back to the loader and is rejected; substituted, the + * viewer gets their own zone and never learns it had two names. + * + * Anything not in the table is returned unchanged, including names that are + * invalid outright — this canonicalises, it does not validate. + */ +export function canonicalTimeZone(tz: string) { + return ZONE_ALIASES.get(tz) ?? tz; +} + +/** + * Every zone the picker offers. + * + * Read from Intl rather than a bundled table so the list can't drift from what + * the runtime understands, then put through `canonicalTimeZone` so it can't + * drift from what Postgres understands either — see ZONE_ALIASES. Re-sorted + * afterwards because a substitution moves its entry (`America/Buenos_Aires` + * becomes `America/Argentina/Buenos_Aires`), and de-duplicated because a future + * tzdata may list an alias and its target side by side. + * + * "UTC" is the dashboard's fallback and is not part of the set Intl reports, so + * it is added by hand and kept at the top rather than sorted into the Us. + * `supportedValuesOf` is guarded because it only reached Safari in 15.4. + */ +export function listTimeZones() { + const zones = Intl.supportedValuesOf?.("timeZone") ?? []; + + const named = [...new Set(zones.map(canonicalTimeZone))] + .filter((zone) => zone !== "UTC") + // The rule guards against mutating a caller's array; this one was built two + // lines up and nothing else can see it. toSorted would need lib: es2023. + // oxlint-disable-next-line unicorn/no-array-sort + .sort(); + + return ["UTC", ...named]; +} + +/** + * The fixed-offset names, which are the one family Postgres knows and Intl does + * not list. `Etc/GMT+5` is deliberately allowed (see the note above `isZoneName` + * in the query layer): it is a zone *name*, and both sides read its POSIX sign + * the same way, which is exactly what a bare `+05:30` offset does not do. + */ +const POSIX_ZONE = /^Etc\/(?:UTC|GMT(?:[+-](?:\d|1[0-4]))?)$/; + +/** Built once. `listTimeZones` already canonicalises and de-duplicates. */ +let offered: Set | null = null; + +/** + * A zone name both Intl and Postgres accept. + * + * Membership of the set the pickers offer, not "whatever Intl will parse". This + * is the predicate `isZoneName` in the query layer defers to, and it runs on a + * value that is about to be interpolated into `AT TIME ZONE` as the last line of + * defence, so answering true for a name that statement cannot read is the whole + * defect — it turns a bad `?tz=` into a 500 instead of the loader's 400. + * + * Asking Intl was too generous by more than the 18 aliases below. Node's ICU + * accepts the entire tzdata `backward` link set — all of `US/*`, `Canada/*`, + * `Brazil/*`, the bare country names, some 80 more — and the Postgres image + * carries none of them, because Debian ships those in a separate `tzdata-legacy` + * package. `?tz=US/Eastern` therefore passed the loader and threw out of the + * statement, and `loadDashboard` is called from the anonymous loader in + * analytics.public.tsx: an unauthenticated 500 on a shared dashboard link. + * Enumerating the 80 would only move the problem, since which of them exist is a + * property of the image. The offered set is the one list this app has already + * checked name-by-name against `AT TIME ZONE` (see ZONE_ALIASES above), so it is + * the list to answer from. + * + * The aliases are still rejected explicitly by construction — `listTimeZones` + * substitutes them away, so none of them is in the set. Rewriting the value here + * instead of refusing it would be worse: the caller would keep using the name it + * passed in, and the chart's labels would come back grouped by a zone whose name + * is not the one in the URL. + */ +export function isValidTimeZone(tz: string) { + if (POSIX_ZONE.test(tz)) { + return true; + } + + // Lazily, because `listTimeZones` reads Intl and this module is imported by + // the client bundle as well as the loader. + offered ??= new Set(listTimeZones()); + + return offered.has(tz); +} + +export function isTimeseriesUnit(unit: string): unit is TimeseriesUnit { + return TIMESERIES_UNITS.includes(unit as TimeseriesUnit); +} + +export function assertTimeZone(tz: string) { + if (!isValidTimeZone(tz)) { + throw new Error(`Invalid time zone: ${tz}`); + } + + return tz; +} + +export function assertTimeseriesUnit(unit: string): TimeseriesUnit { + if (!isTimeseriesUnit(unit)) { + throw new Error(`Invalid unit: ${unit}`); + } + + return unit; +} + +/** + * Zoned calendar arithmetic for the range picker. + * + * The dashboard charts one zone and the browser runs in another as soon as the + * picker is touched, and a day is a different pair of instants in each. date-fns + * `startOfDay`/`endOfDay` snap in the host zone only, so picking "Aug 1" while + * charting Asia/Tokyo produced a window whose edges sat nine hours inside the + * day the button still claimed to be showing. Everything below works in the + * charted zone instead, using the same zone database Intl and Postgres share. + */ + +const formatters = new Map(); + +/** The wall-clock fields of an instant in `tz`, as numbers. */ +function fieldsAt(tz: string, at: number) { + let formatter = formatters.get(tz); + + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + // h23 rather than hour12:false — the latter reports midnight as hour 24 + // on some engines, which is a day out once it is fed back to Date.UTC. + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + formatters.set(tz, formatter); + } + + const fields: Record = {}; + + for (const part of formatter.formatToParts(at)) { + if (part.type !== "literal") { + fields[part.type] = Number(part.value); + } + } + + return fields; +} + +/** + * The zone's offset from UTC at a given instant, in milliseconds. + * + * Sampled per instant rather than once per window: an offset is only true until + * the next DST transition, and a range picked across one has a different offset + * at each end. + */ +function offsetAt(tz: string, at: number) { + const fields = fieldsAt(tz, at); + + // Intl has no millisecond field, so the instant is truncated to the second it + // was read at; offsets are whole minutes and the remainder would otherwise + // show up as one. + return ( + Date.UTC( + fields.year, + fields.month - 1, + fields.day, + fields.hour, + fields.minute, + fields.second + ) - + Math.floor(at / 1000) * 1000 + ); +} + +/** The instant at which a wall clock in `tz` reads these fields. */ +function instantOf( + tz: string, + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + ms: number +) { + const wall = Date.UTC(year, month - 1, day, hour, minute, second, ms); + + // Two passes. The first offset is sampled at an instant up to a day away from + // the answer, which is the wrong one whenever a transition falls between the + // two; the second is sampled within an hour of it, where the offset in force + // is the offset that applies. + const approximate = wall - offsetAt(tz, wall); + const offset = offsetAt(tz, approximate); + const instant = wall - offset; + + // A wall clock inside a spring-forward gap is one no instant reads, and the + // two passes above then answer with an instant *before* the gap. Postgres + // resolves the same clock forward — `timestamp AT TIME ZONE` and the + // `date_trunc(unit, created_at AT TIME ZONE tz)` the buckets are grouped by + // both land after it — so the picker and the query disagreed by an hour in + // every zone whose transition starts at 00:00. America/Havana, Santiago and + // Atlantic/Azores all do: clicking the transition day gave a window running + // from 23:00 of the day before, the range button read "Mar 7 - Mar 8" for a + // single click on Mar 8, and reopening the picker and pressing Apply without + // touching it doubled the window to 47 hours. + // + // Detected rather than assumed: the answer is only real if the zone reads it + // back at the offset it was built with. Ambiguous clocks (fall back, read by + // two instants) still satisfy that on the first pass and are left alone. + if (offsetAt(tz, instant) !== offset) { + return Math.max(instant, approximate); + } + + return instant; +} + +/** + * The instant a picked calendar day begins in `tz`. + * + * The calendar hands back a `Date` whose *local* fields are the square that was + * clicked, so the day is read off those and rebuilt in the charted zone. Taking + * the Date's instant instead would shift the day itself whenever the two zones + * disagree about which date it is. + */ +export function startOfZonedDay(day: Date, tz: string) { + return instantOf( + tz, + day.getFullYear(), + day.getMonth() + 1, + day.getDate(), + 0, + 0, + 0, + 0 + ); +} + +/** + * The instant the picked day *stops*, which is the next day's first one. + * + * Exclusive on purpose. Every range predicate in the query layer is half-open + * (`>= start`, `< end`, see withinRange), and `resolveFilters` anchors the + * comparison window at `previous.end === from`, so the two windows tile only if + * the picker's end is the boundary itself. The last-millisecond form this + * replaced was neither end of that: it left the final millisecond of the day in + * no window at all, and it made a one-day pick 86_399_999ms long, so the + * "previous day" it was compared against was the previous day shifted a + * millisecond off its own midnight. The opposite patch — the next day's first + * instant under an *inclusive* `<= end` — is what would double-count an event + * landing exactly on midnight, in this window and again in the next. + * + * `Date.UTC` normalises a day past the end of the month, so the 31st resolves + * to the 1st, and a day whose midnight DST skips resolves the same way + * startOfZonedDay does. + */ +export function endOfZonedDayExclusive(day: Date, tz: string) { + return instantOf( + tz, + day.getFullYear(), + day.getMonth() + 1, + day.getDate() + 1, + 0, + 0, + 0, + 0 + ); +} + +/** + * The calendar day an instant falls on in `tz`, as the local-field `Date` the + * calendar speaks — the inverse of `startOfZonedDay`, for seeding the picker + * from the window already on screen so that reopening it can't move the + * selection. + * + * The window's *end* is exclusive, so seeding the last selected square from it + * means asking for the day the instant before it falls on; a boundary handed + * here as-is answers with the day after the one the picker is showing. + */ +export function zonedCalendarDay(at: number, tz: string) { + const fields = fieldsAt(tz, at); + + return new Date(fields.year, fields.month - 1, fields.day); +} + +/** + * An instant re-labelled as the wall clock it reads in `tz`. + * + * This is the space the chart's bucket timestamps live in: the query layer + * groups by `created_at AT TIME ZONE tz` and labels the naive result as UTC, so + * that two buckets an hour apart are an hour apart on the axis (see `bucketAt` + * in queries.server.ts). Putting the window's own bounds through the same + * relabelling is what lets the two be compared at all — the alternative is + * comparing a bucket label against a real instant, which is off by the zone's + * offset and looks right for exactly the viewers in UTC. + * + * Truncated to the second, because Intl has no millisecond field. Bucket labels + * are whole hours or whole days, so nothing this is used for can see it. + */ +export function zonedWallClock(at: number, tz: string) { + const fields = fieldsAt(tz, at); + + return Date.UTC( + fields.year, + fields.month - 1, + fields.day, + fields.hour, + fields.minute, + fields.second + ); +} + +/** + * The chart's buckets that are actually inside the window. + * + * `generate_series` pads the series between `date_trunc(unit, from)` and + * `date_trunc(unit, to)` *inclusive*, and `to` is the window's exclusive end — + * for a range picked as whole days that is the next day's midnight, which + * truncates to a bucket of its own. That bucket can never hold anything, since + * the counts beside it are `< to`, so picking "Aug 1" drew a second, empty day + * after the one that was asked for (or, at hourly resolution, a 25th hour). + * + * Trimmed here rather than in the query, which is the module that owns the + * padding and is not this slice's to change. The comparison runs in bucket + * space — see zonedWallClock — because a label is a wall clock and the bound is + * an instant, and comparing the two directly is wrong by the zone's offset and + * looks right for exactly the viewers already in UTC. + * + * A bucket starting exactly at the window's end is outside it, by the same + * half-open rule the rest of the range follows. + */ +export function bucketsWithin( + series: T[], + to: number, + tz: string +) { + const end = zonedWallClock(to, tz); + + return series.filter((point) => Date.parse(point.timeseries) < end); +} diff --git a/apps/web/app/modules/analytics/types.ts b/apps/web/app/modules/analytics/types.ts new file mode 100644 index 00000000..a085242c --- /dev/null +++ b/apps/web/app/modules/analytics/types.ts @@ -0,0 +1,141 @@ +/** + * Shapes shared between loaders and components. Kept out of the .server modules + * so client components can import them without reaching into server-only code. + */ + +/** + * What a panel's first numeric column counts, and therefore what its header has + * to say. + * + * Acquisition is a property of an *arrival*. `referrer_host` is only ever set on + * the pageview that opened a visit — the tracker reads `document.referrer` once + * per document, and ingest nulls self-referrals — and `channel` is resolved + * per-event from that same referrer, so pageviews 2..N are classified `direct` + * for the same reason. Scoped to every pageview, those dimensions answered a + * question nobody asked: a site whose visitors all arrive from google.com and + * read five pages reported `google.com 100 / 400`, understating every + * real referrer's share in proportion to pages-per-visit. They are now scoped to + * `is_new_session`, which makes them per-session counts. + * + * Everything else is a property of the page that was viewed and still counts + * pageviews. The unit travels with the rows rather than being restated at each + * call site, because a panel whose header and scope are set in two places is a + * panel that can be mislabelled — which is the defect this exists to remove. + */ +export type BreakdownUnit = "views" | "sessions"; + +export type BreakdownRow = { + element: string; + /** + * In the panel's own `unit`. Named `views` while every dimension was scoped to + * pageviews, which is exactly the assumption that stopped holding. + */ + count: number; + /** + * Distinct visitors — `count(DISTINCT visitor_id)`. It used to be + * `count(*) FILTER (WHERE is_new_visitor)`, a per-row flag that grew with the + * length of the window instead of with the audience. The name stays because + * every panel already keys off it. + */ + unique: number; +}; + +/** One panel: its rows and the unit they are counted in, never one without the other. */ +export type Breakdown = { + unit: Unit; + rows: BreakdownRow[]; +}; + +export type TimeseriesPoint = { timeseries: string; count: number }; + +/** One bucket as returned by Postgres, before gaps are padded. */ +export type TimeseriesRow = { ts: Date; count: number }; + +export type Statistics = { + /** Pageviews only, so custom events cannot inflate the headline number. */ + visits: number; + /** Distinct visitor_id — the same correction as `BreakdownRow.unique`. */ + uniqueVisits: number; + /** Distinct session_id, not the count of rows flagged is_new_session. */ + sessions: number; + /** Sessions with a single pageview; the numerator of the bounce rate, whose + * denominator is `sessions`. Kept as the two inputs rather than a ratio so + * the dashboard can compare windows without dividing twice. */ + bounces: number; + /** + * Averaged per session, not per event: a five-page visit is one visit. + * + * Null when no pageview in the window carried a duration at all — an install + * whose beacons are blocked, or a window of pages nobody stayed on long + * enough to report. That is not an average of zero, and flattening it to one + * made the dashboard state a measurement it never took. + */ + avgDuration: number | null; +}; + +/** + * The dashboard panels. Each is one grouped scan over `events` now that the + * dimensions are columns, so adding a panel costs a query and nothing else. + * + * The unit is written into each panel's type rather than left to the query + * layer's discretion: `BREAKDOWN_SCOPES` in queries.server.ts is checked against + * this declaration, so scoping a dimension to arrivals without saying so here — + * or saying so here without scoping it — does not compile. + */ +export type Breakdowns = { + pages: Breakdown<"views">; + /** + * The host a visit arrived from, once per visit. The empty bucket is a visit + * whose first pageview carried no external referrer. + */ + referrers: Breakdown<"sessions">; + /** + * Direct / search / social / referral / campaign, resolved once per event at + * ingest and read here only off the event that opened the visit. Computed + * since the schema change and rendered nowhere until now. + */ + channels: Breakdown<"sessions">; + browsers: Breakdown<"views">; + os: Breakdown<"views">; + devices: Breakdown<"views">; + /** Edge-header geography. Until now this panel was fed the locale instead. */ + countries: Breakdown<"views">; + locales: Breakdown<"views">; + utmSources: Breakdown<"sessions">; + utmMediums: Breakdown<"sessions">; + utmCampaigns: Breakdown<"sessions">; + utmTerms: Breakdown<"sessions">; + utmContents: Breakdown<"sessions">; +}; + +/** What one goal earned in one currency. Never merged with another currency. */ +export type EventRevenue = { currency: string; total: number }; + +/** + * One goal: a named event the site fires itself through `aurora()`. + * + * Declared here rather than imported from the query layer that produces it, for + * the reason at the top of this file — the goals panel is a client component + * and the producer is a `.server` module. The loader's rows are assigned to + * this shape at the dashboard's prop boundary, so a producer that stopped + * carrying `revenue`, or carried it as a single number again, fails to compile + * here rather than rendering wrong. + */ +export type CustomEventRow = { + name: string; + count: number; + /** Distinct visitors — the same daily-rotation caveat as everywhere else. */ + unique: number; + /** One total per currency, largest first; empty when the goal earns nothing. */ + revenue: EventRevenue[]; +}; + +export type Website = { + id: string; + name: string; + url: string; + is_public: boolean; + user_id: string; + created_at: Date; + updated_at: Date; +}; diff --git a/apps/web/app/modules/auth/hash.server.ts b/apps/web/app/modules/auth/hash.server.ts new file mode 100644 index 00000000..f95ec9ce --- /dev/null +++ b/apps/web/app/modules/auth/hash.server.ts @@ -0,0 +1,9 @@ +import { compareSync, hashSync } from "bcryptjs"; + +export const hash = (plainText: string) => { + return plainText ? hashSync(plainText, 10) : null; +}; + +export const verify = (plainText: string, hashText: string) => { + return compareSync(plainText, hashText); +}; diff --git a/apps/web/app/modules/auth/queries.server.ts b/apps/web/app/modules/auth/queries.server.ts new file mode 100644 index 00000000..ee35e4f3 --- /dev/null +++ b/apps/web/app/modules/auth/queries.server.ts @@ -0,0 +1,74 @@ +import { users, type User } from "~/db/schema"; +import { eq } from "drizzle-orm"; +import { db } from "~/shared/lib/db.server"; +import { hash } from "./hash.server"; + +export function getUsers() { + return db.select().from(users); +} + +/** + * Annotated `User | null` rather than left to inference. `const [user] = rows` + * types as `User` without noUncheckedIndexedAccess, so the `?? null` reads as + * unreachable and callers were handed a type that says a missing row cannot + * happen — which is what `/signup`'s duplicate check turns on. + */ +export async function getUser(uid: string): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, uid)) + .limit(1); + + return user ?? null; +} + +export async function getUserByEmail(email: string): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + return user ?? null; +} + +export async function createUser(data: { + firstname: string; + lastname: string; + email: string; + password: string; +}) { + const [user] = await db + .insert(users) + .values({ ...data, password: hash(data.password)! }) + .returning(); + + return user; +} + +export async function updateUser( + uid: string, + data: Partial<{ + firstname: string; + lastname: string; + email: string; + password: string; + }> +) { + const { password, ...rest } = data; + + const [user] = await db + .update(users) + .set({ ...rest, ...(password && { password: hash(password)! }) }) + .where(eq(users.id, uid)) + .returning(); + + return user; +} + +export async function deleteUser(uid: string) { + const [user] = await db.delete(users).where(eq(users.id, uid)).returning(); + + return user; +} diff --git a/apps/web/app/modules/auth/session.server.ts b/apps/web/app/modules/auth/session.server.ts new file mode 100644 index 00000000..1b1817ba --- /dev/null +++ b/apps/web/app/modules/auth/session.server.ts @@ -0,0 +1,92 @@ +import { createCookieSessionStorage, redirect } from "react-router"; +import { getUser } from "./queries.server"; + +type SessionData = { userId: string }; +type SessionFlashData = { error: string }; + +const sessionSecret = process.env.SESSION_SECRET; + +if (!sessionSecret) { + throw new Error("SESSION_SECRET is not set"); +} + +export const sessionStorage = createCookieSessionStorage< + SessionData, + SessionFlashData +>({ + cookie: { + name: "__aurora_session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [sessionSecret], + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 30, + }, +}); + +export const { getSession, commitSession, destroySession } = sessionStorage; + +export type SessionUser = { + id: string; + firstname: string; + lastname: string; + email: string; + created_at: Date; + updated_at: Date; +}; + +/** Resolves the signed-in user, or null. Never throws. */ +export async function getCurrentUser( + request: Request +): Promise { + const session = await getSession(request.headers.get("Cookie")); + const userId = session.get("userId"); + + if (!userId) { + return null; + } + + const user = await getUser(userId); + + if (!user) { + return null; + } + + const { password: _password, ...safeUser } = user; + + return safeUser; +} + +/** Resolves the signed-in user or redirects to /signin, preserving the target. */ +export async function requireUser(request: Request): Promise { + const user = await getCurrentUser(request); + + if (!user) { + const url = new URL(request.url); + const params = new URLSearchParams({ + redirectTo: url.pathname + url.search, + }); + + throw redirect(`/signin?${params}`); + } + + return user; +} + +export async function createUserSession(userId: string, redirectTo: string) { + const session = await getSession(); + session.set("userId", userId); + + return redirect(redirectTo, { + headers: { "Set-Cookie": await commitSession(session) }, + }); +} + +export async function signout(request: Request) { + const session = await getSession(request.headers.get("Cookie")); + + return redirect("/signin", { + headers: { "Set-Cookie": await destroySession(session) }, + }); +} diff --git a/apps/web/app/modules/auth/website-access.server.ts b/apps/web/app/modules/auth/website-access.server.ts new file mode 100644 index 00000000..c4a72632 --- /dev/null +++ b/apps/web/app/modules/auth/website-access.server.ts @@ -0,0 +1,45 @@ +import { getCurrentUser } from "./session.server"; +import { getWebsite } from "~/modules/websites/queries.server"; + +/** + * Metric data is readable by the owner, or by anyone when the website is marked + * public. Mirrors the check the metric controllers each repeated inline. + */ +export async function requireWebsiteAccess(request: Request, wid: string) { + const website = await getWebsite(wid); + + if (!website) { + throw new Response("Not found", { status: 404 }); + } + + if (website.is_public) { + return website; + } + + const user = await getCurrentUser(request); + + if (!user) { + throw new Response("Unauthenticated", { status: 401 }); + } + + if (user.id !== website.user_id) { + throw new Response("Unauthorized", { status: 403 }); + } + + return website; +} + +/** Owner-only access, used by the edit/update/delete paths. */ +export async function requireWebsiteOwner(userId: string, wid: string) { + const website = await getWebsite(wid); + + if (!website) { + throw new Response("Not found", { status: 404 }); + } + + if (website.user_id !== userId) { + throw new Response("Unauthorized", { status: 403 }); + } + + return website; +} diff --git a/apps/web/app/modules/ingest/__tests__/cors.test.ts b/apps/web/app/modules/ingest/__tests__/cors.test.ts new file mode 100644 index 00000000..e812bd0f --- /dev/null +++ b/apps/web/app/modules/ingest/__tests__/cors.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + corsHeaders, + corsJson, + corsNoContent, + originAllowed, + preflight, +} from "../cors.server"; + +const SITE = "https://example.com"; + +describe("originAllowed", () => { + it("accepts the registered host and the www spelling of it", () => { + expect(originAllowed(SITE, SITE)).toBe(true); + expect(originAllowed("https://www.example.com", SITE)).toBe(true); + expect(originAllowed("https://EXAMPLE.COM", SITE)).toBe(true); + // The website form stores whatever was typed, scheme or not. + expect(originAllowed(SITE, "example.com")).toBe(true); + expect(originAllowed(SITE, "https://WWW.Example.com/blog")).toBe(true); + }); + + /** + * Near misses rather than unrelated hosts. `https://evil.test` shares no + * substring with the registered domain, so it fails a suffix match, a + * subdomain match and a scheme-blind parse just as readily as it fails the + * correct one — it cannot falsify any of the ways this could go wrong. + */ + it("refuses every host that merely resembles the registered one", () => { + for (const origin of [ + "https://notexample.com", + "https://myexample.com", + "https://example.com.attacker.test", + "https://example.com.evil.co", + "https://sub.example.com", + "https://docs.example.com", + "https://example.org", + ]) { + expect(originAllowed(origin, SITE)).toBe(false); + } + }); + + it("refuses an Origin that is not an absolute http(s) URL", () => { + // `null` is what a sandboxed iframe or a data: document sends, and a bare + // hostname is not an origin at all. Neither may be re-parsed into one. + expect(originAllowed("null", SITE)).toBe(false); + expect(originAllowed("example.com", SITE)).toBe(false); + expect(originAllowed("android-app://com.example", SITE)).toBe(false); + expect(originAllowed("", SITE)).toBe(false); + }); + + it("matches nothing at all when the website row has no usable url", () => { + expect(originAllowed(SITE, "")).toBe(false); + expect(originAllowed(SITE, "not a url")).toBe(false); + }); + + it("allows a local development origin outside production only", () => { + for (const origin of [ + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://[::1]:5173", + ]) { + expect(originAllowed(origin, SITE)).toBe(true); + } + + process.env.NODE_ENV = "production"; + + try { + expect(originAllowed("http://localhost:5173", SITE)).toBe(false); + } finally { + process.env.NODE_ENV = "test"; + } + }); +}); + +describe("corsHeaders", () => { + it("echoes the caller's own origin and never a wildcard", () => { + const headers = corsHeaders(SITE); + + expect(headers["Access-Control-Allow-Origin"]).toBe(SITE); + expect(Object.values(headers)).not.toContain("*"); + }); + + it("names the two methods this endpoint answers and the one header it takes", () => { + const headers = corsHeaders(SITE); + + expect(headers["Access-Control-Allow-Methods"]).toBe("POST,OPTIONS"); + // Covers both beacon shapes: a string sendBeacon is a simple request, and + // a Blob beacon or a keepalive fetch preflights asking only for this. + expect(headers["Access-Control-Allow-Headers"]).toBe("Content-Type"); + expect(headers["Access-Control-Max-Age"]).toBe("86400"); + }); + + it("announces Vary: Origin whether or not an origin was named", () => { + // The header a shared cache stores depends on the request's Origin either + // way, and announcing it only when one was present is how a cache serves + // one site's allowance to another. + expect(corsHeaders(SITE).Vary).toBe("Origin"); + expect(corsHeaders(null).Vary).toBe("Origin"); + expect(corsHeaders(null)["Access-Control-Allow-Origin"]).toBeUndefined(); + }); + + it("never hands the opaque origin an allowance", () => { + // `Access-Control-Allow-Origin: null` is matched by every sandboxed or + // data:/file: document there is, which is the opposite of naming a caller. + expect(corsHeaders("null")["Access-Control-Allow-Origin"]).toBeUndefined(); + expect(corsHeaders("null").Vary).toBe("Origin"); + }); + + it("asks for the client hints the ua columns are built from", () => { + expect(corsHeaders(SITE)["Accept-CH"]).toBe( + "Sec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model" + ); + }); +}); + +describe("responses", () => { + it("answers a preflight with the headers and nothing else", async () => { + const response = preflight(SITE); + + expect(response.status).toBe(204); + expect(await response.text()).toBe(""); + expect(response.headers.get("access-control-allow-origin")).toBe(SITE); + expect(response.headers.get("access-control-allow-methods")).toBe( + "POST,OPTIONS" + ); + }); + + it("answers success with no body at all", async () => { + const response = corsNoContent(SITE); + + expect(response.status).toBe(204); + expect(await response.text()).toBe(""); + }); + + it("answers an error with a short message and the same headers", async () => { + const response = corsJson({ message: "Not found" }, 404, SITE); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ message: "Not found" }); + expect(response.headers.get("access-control-allow-origin")).toBe(SITE); + expect(response.headers.get("vary")).toBe("Origin"); + }); +}); diff --git a/apps/web/app/modules/ingest/__tests__/geo.test.ts b/apps/web/app/modules/ingest/__tests__/geo.test.ts new file mode 100644 index 00000000..579203df --- /dev/null +++ b/apps/web/app/modules/ingest/__tests__/geo.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { country } from "../geo.server"; + +const headers = (init: Record) => new Headers(init); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("country", () => { + it("reads the header the edge in front happens to set", () => { + expect(country(headers({ "cf-ipcountry": "IT" }))).toBe("IT"); + expect(country(headers({ "x-vercel-ip-country": "DE" }))).toBe("DE"); + expect(country(headers({ "fastly-geo-country": " fr " }))).toBe("FR"); + }); + + it("ignores generic geo header names no edge owns", () => { + // `x-country-code` and `x-geo-country` are not written or stripped by any + // particular proxy, so on a deployment with no geo-aware edge — which the + // module explicitly supports — they arrived from the caller verbatim and + // the country breakdown was whatever they typed. + expect(country(headers({ "x-country-code": "VA" }))).toBeNull(); + expect(country(headers({ "x-geo-country": "VA" }))).toBeNull(); + expect( + country(headers({ "cf-ipcountry": "IT", "x-country-code": "VA" })) + ).toBe("IT"); + }); + + it("reads the header a deployment names for itself", async () => { + vi.stubEnv("AURORA_COUNTRY_HEADER", "x-country-code"); + vi.resetModules(); + + const configured = await import("../geo.server"); + + expect(configured.country(headers({ "x-country-code": "IT" }))).toBe("IT"); + // And only that one: naming a header is a claim about one hop, not an + // invitation to sniff the others as well. + expect(configured.country(headers({ "cf-ipcountry": "DE" }))).toBeNull(); + }); + + it("prefers the earlier header when several are set", () => { + expect( + country(headers({ "cf-ipcountry": "IT", "x-vercel-ip-country": "DE" })) + ).toBe("IT"); + }); + + it("falls through a placeholder to a proxy that does know", () => { + expect( + country(headers({ "cf-ipcountry": "XX", "x-vercel-ip-country": "IT" })) + ).toBe("IT"); + expect( + country(headers({ "cf-ipcountry": "T1", "x-vercel-ip-country": "IT" })) + ).toBe("IT"); + }); + + it("rejects the placeholders outright", () => { + expect(country(headers({ "cf-ipcountry": "XX" }))).toBeNull(); + expect(country(headers({ "cf-ipcountry": "t1" }))).toBeNull(); + }); + + it("rejects anything that is not two letters", () => { + expect(country(headers({ "cf-ipcountry": "ITA" }))).toBeNull(); + expect(country(headers({ "cf-ipcountry": "I" }))).toBeNull(); + expect(country(headers({ "cf-ipcountry": "12" }))).toBeNull(); + expect(country(headers({ "cf-ipcountry": "" }))).toBeNull(); + }); + + it("reports null behind a proxy with no geo awareness at all", () => { + expect(country(new Headers())).toBeNull(); + }); +}); diff --git a/apps/web/app/modules/ingest/__tests__/ratelimit.test.ts b/apps/web/app/modules/ingest/__tests__/ratelimit.test.ts new file mode 100644 index 00000000..f6959380 --- /dev/null +++ b/apps/web/app/modules/ingest/__tests__/ratelimit.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { limiter, RateLimiter, rateLimit } from "../ratelimit.server"; + +/** Defaults: 240 tokens, refilled at 120 a minute, so one token per 500ms. */ +const BURST = 240; +const TOKEN_MS = 500; +const IDLE_MS = 120_000; + +const clock = { now: 0 }; + +const build = (options: { maxKeys?: number } = {}) => + new RateLimiter({ ...options, now: () => clock.now }); + +const drain = (bucket: RateLimiter, key: string) => { + for (let index = 0; index < BURST; index += 1) { + bucket.take(key); + } +}; + +const spend = (bucket: RateLimiter, key: string, attempts: number) => { + let allowed = 0; + + for (let index = 0; index < attempts; index += 1) { + if (bucket.take(key).allowed) { + allowed += 1; + } + } + + return allowed; +}; + +beforeEach(() => { + clock.now = 0; + limiter.reset(); +}); + +describe("RateLimiter", () => { + it("spends the whole burst and then refuses", () => { + const bucket = build(); + + for (let index = 0; index < BURST; index += 1) { + expect(bucket.take("client")).toEqual({ + allowed: true, + retryAfterMs: 0, + }); + } + + expect(bucket.take("client")).toEqual({ + allowed: false, + retryAfterMs: TOKEN_MS, + }); + }); + + it("refills one token per 500ms and no faster", () => { + const bucket = build(); + + drain(bucket, "client"); + + clock.now += TOKEN_MS - 1; + expect(bucket.take("client").allowed).toBe(false); + + clock.now += 2; + expect(bucket.take("client").allowed).toBe(true); + expect(bucket.take("client").allowed).toBe(false); + }); + + it("sustains 120 a minute once the burst is gone", () => { + const bucket = build(); + + drain(bucket, "client"); + clock.now += 60_000; + + expect(spend(bucket, "client", 200)).toBe(120); + }); + + it("never banks more than the burst however long it idles", () => { + const bucket = build(); + + drain(bucket, "client"); + clock.now += 60 * 60_000; + + expect(spend(bucket, "client", 400)).toBe(BURST); + }); + + it("caps a bucket the sweep has not removed", () => { + const bucket = build(); + + // The test above idles past #idleMs, so the sweep deletes the bucket and + // the next take reads the fresh-bucket constant instead of the cap. One + // token spent and a wait just inside the window keeps the entry alive, so + // this is the only case where the cap is what answers. + expect(bucket.take("client").allowed).toBe(true); + clock.now += IDLE_MS - 1; + + // Uncapped the bucket would have banked 239 + 239.998 tokens. + expect(spend(bucket, "client", 400)).toBe(BURST); + }); + + it("counts down while over the limit instead of restarting the wait", () => { + const bucket = build(); + + drain(bucket, "client"); + + clock.now += 200; + expect(bucket.take("client").retryAfterMs).toBe(300); + + clock.now += 200; + expect(bucket.take("client").retryAfterMs).toBe(100); + }); + + it("keeps one client's flood off another's budget", () => { + const bucket = build(); + + drain(bucket, "client-a"); + + expect(bucket.take("client-a").allowed).toBe(false); + expect(bucket.take("client-b").allowed).toBe(true); + }); + + it("honours a custom rate", () => { + const bucket = new RateLimiter({ + ratePerMinute: 60, + burst: 2, + now: () => clock.now, + }); + + expect(bucket.take("k").allowed).toBe(true); + expect(bucket.take("k").allowed).toBe(true); + expect(bucket.take("k").allowed).toBe(false); + + clock.now += 1_000; + expect(bucket.take("k").allowed).toBe(true); + }); +}); + +describe("sweeping", () => { + it("drops the buckets a rotating-address flood leaves behind", () => { + const bucket = build(); + + for (let index = 0; index < 5_000; index += 1) { + bucket.take(`10.0.${index}`); + } + + expect(bucket.size).toBe(5_000); + + clock.now += IDLE_MS; + bucket.take("fresh"); + + expect(bucket.size).toBe(1); + }); + + it("keeps sweeping as the flood continues", () => { + const bucket = build(); + + for (let round = 0; round < 4; round += 1) { + for (let index = 0; index < 1_000; index += 1) { + bucket.take(`round-${round}-${index}`); + } + + clock.now += IDLE_MS; + } + + bucket.take("last"); + + expect(bucket.size).toBe(1); + }); + + it("does not forgive a client by forgetting it", () => { + const bucket = build(); + + drain(bucket, "client"); + // A swept bucket has provably refilled to full, so the entry is redundant + // rather than load-bearing: dropping it must not hand out a second burst. + clock.now += IDLE_MS; + bucket.take("other"); + + expect(bucket.size).toBe(1); + expect(spend(bucket, "client", 400)).toBe(BURST); + }); +}); + +describe("the size cap", () => { + it("bounds the map inside a window the sweep cannot reclaim", () => { + const bucket = build({ maxKeys: 100 }); + + // The sweep runs at most once per #idleMs and only evicts entries already + // idle that long, so nothing admitted during the current window can be + // dropped by it: without a cap this is 5000 live keys, and with an + // unbounded key it was 5000 keys of whatever length the caller sent. + for (let index = 0; index < 5_000; index += 1) { + clock.now += 1; + bucket.take(`10.0.${index}`); + } + + expect(bucket.size).toBe(100); + }); + + it("evicts the quietest key rather than the oldest one", () => { + const bucket = build({ maxKeys: 2 }); + + bucket.take("steady"); + bucket.take("noisy"); + // `steady` was seen first but is still active, so a plain insertion-order + // eviction would drop the wrong one. + bucket.take("steady"); + bucket.take("newcomer"); + + expect(bucket.size).toBe(2); + // Checked by budget rather than by a private field: an evicted key comes + // back with a full bucket, so 238 remaining is proof `steady` survived and + // 240 would be proof it did not. + expect(spend(bucket, "steady", 400)).toBe(BURST - 2); + }); +}); + +describe("rateLimit", () => { + it("scopes the process-wide limiter to the caller", () => { + expect(rateLimit("203.0.113.7")).toEqual({ + allowed: true, + retryAfterMs: 0, + }); + expect(limiter.size).toBe(1); + + rateLimit("203.0.113.8"); + + expect(limiter.size).toBe(2); + }); + + it("takes no key from anything the caller could pick per request", () => { + // It used to be `${ip}:${wid}` with `wid` straight out of the request body, + // so one address could mint a fresh full bucket per request forever. + for (let index = 0; index < 240; index += 1) { + rateLimit("203.0.113.7"); + } + + expect(rateLimit("203.0.113.7").allowed).toBe(false); + expect(limiter.size).toBe(1); + }); +}); diff --git a/apps/web/app/modules/ingest/__tests__/referrer.test.ts b/apps/web/app/modules/ingest/__tests__/referrer.test.ts new file mode 100644 index 00000000..44d7fe7b --- /dev/null +++ b/apps/web/app/modules/ingest/__tests__/referrer.test.ts @@ -0,0 +1,247 @@ +import { events } from "~/db/schema"; +import { getTableConfig, PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { acquisition, channelOf, siteHost, urlHost } from "../referrer.server"; + +const SITE = "https://example.com"; + +/** + * Read out of the schema rather than restated here. + * + * `channel` is a closed set written in three places — the ChannelType union, + * `channelOf`, and the events_channel_valid CHECK — with nothing linking them. + * A literal copy in this file would be a fourth: adding a channel to the code + * and forgetting the migration (or the reverse) would leave the suite green and + * every insert failing 23514 in production. Derived, the test either sees the + * new value or fails on the old one. + */ +const CHANNELS = (() => { + const check = getTableConfig(events).checks.find( + (constraint) => constraint.name === "events_channel_valid" + ); + + if (!check) { + throw new Error("events_channel_valid is gone from the schema"); + } + + const { sql } = new PgDialect().sqlToQuery(check.value); + + return [...sql.matchAll(/'([^']+)'/g)].map(([, value]) => value); +})(); + +describe("urlHost", () => { + it("keeps the host and nothing else", () => { + expect(urlHost("https://blog.example.org/posts/1?utm_source=x#top")).toBe( + "blog.example.org" + ); + }); + + it("normalises case, www and the root dot", () => { + expect(urlHost("https://WWW.Example.ORG/")).toBe("example.org"); + expect(urlHost("https://example.org./")).toBe("example.org"); + }); + + it("drops userinfo", () => { + expect(urlHost("https://user:secret@example.org/x")).toBe("example.org"); + }); + + it("refuses anything that is not an absolute http(s) URL", () => { + // 'Direct' is the sentinel the previous tracker wrote, and a bare hostname + // is a path rather than a site; neither may become a referrer. + expect(urlHost("Direct")).toBeNull(); + expect(urlHost("example.org")).toBeNull(); + expect(urlHost("/blog/post")).toBeNull(); + expect(urlHost("android-app://com.google.android.gm")).toBeNull(); + expect(urlHost("javascript:alert(1)")).toBeNull(); + expect(urlHost("")).toBeNull(); + expect(urlHost(null)).toBeNull(); + }); +}); + +describe("siteHost", () => { + it("accepts every spelling the website form allows", () => { + expect(siteHost("example.com")).toBe("example.com"); + expect(siteHost("https://WWW.Example.org/blog")).toBe("example.org"); + expect(siteHost("http://example.org:3000")).toBe("example.org"); + expect(siteHost(" example.com/ ")).toBe("example.com"); + }); + + it("reports null for an unusable row", () => { + expect(siteHost("")).toBeNull(); + expect(siteHost(null)).toBeNull(); + }); +}); + +describe("channelOf", () => { + it("calls a missing referrer direct", () => { + expect(channelOf(null)).toBe("direct"); + }); + + it("recognises search engines", () => { + expect(channelOf("google.com")).toBe("search"); + expect(channelOf("duckduckgo.com")).toBe("search"); + expect(channelOf("search.brave.com")).toBe("search"); + expect(channelOf("search.yahoo.com")).toBe("search"); + expect(channelOf("m.baidu.com")).toBe("search"); + }); + + it("keeps the rest of a portal out of search", () => { + // The reason the list is matched whole. A newsletter read in Gmail and a + // link out of a shared document are not organic search, and counting them + // as it is invisible from the panel that gets sized off the number. + expect(channelOf("mail.google.com")).toBe("referral"); + expect(channelOf("docs.google.com")).toBe("referral"); + expect(channelOf("drive.google.com")).toBe("referral"); + expect(channelOf("groups.google.com")).toBe("referral"); + expect(channelOf("news.google.com")).toBe("referral"); + expect(channelOf("mail.yahoo.com")).toBe("referral"); + expect(channelOf("mail.yandex.com")).toBe("referral"); + // Same shape one country domain over, where the brand rule is what could + // have let it back in. + expect(channelOf("mail.google.de")).toBe("referral"); + }); + + it("recognises the country domains the big engines run", () => { + expect(channelOf("google.de")).toBe("search"); + expect(channelOf("google.co.uk")).toBe("search"); + expect(channelOf("yahoo.co.jp")).toBe("search"); + expect(channelOf("yandex.com.tr")).toBe("search"); + }); + + it("recognises social hosts and their subdomains", () => { + // Subdomains stay in for these: `m.` and `l.` are the same product on + // another surface, and none of these domains hosts a mailbox. + expect(channelOf("t.co")).toBe("social"); + expect(channelOf("x.com")).toBe("social"); + expect(channelOf("m.facebook.com")).toBe("social"); + expect(channelOf("l.instagram.com")).toBe("social"); + expect(channelOf("old.reddit.com")).toBe("social"); + expect(channelOf("news.ycombinator.com")).toBe("social"); + }); + + it("calls anything else a referral", () => { + expect(channelOf("example.org")).toBe("referral"); + expect(channelOf("googleblog.com")).toBe("referral"); + expect(channelOf("notgoogle.com")).toBe("referral"); + }); + + it("lets any utm parameter outrank the host", () => { + expect(channelOf("google.com", { source: "newsletter" })).toBe("campaign"); + expect(channelOf(null, { medium: "email" })).toBe("campaign"); + expect(channelOf("example.org", { content: "footer" })).toBe("campaign"); + }); + + it("ignores a utm object whose values are all blank", () => { + expect(channelOf("google.com", { source: "", medium: " " })).toBe( + "search" + ); + expect(channelOf(null, {})).toBe("direct"); + }); + + it("only ever returns a value the channel check accepts", () => { + const hosts = [ + null, + "google.com", + "news.google.com", + "google.de", + "yahoo.co.jp", + "x.com", + "t.co", + "news.ycombinator.com", + "example.org", + "192.0.2.1", + "xn--80ak6aa92e.com", + ]; + + for (const host of hosts) { + expect(CHANNELS).toContain(channelOf(host)); + expect(CHANNELS).toContain(channelOf(host, { source: "n" })); + expect(CHANNELS).toContain(channelOf(host, { medium: "email" })); + } + }); + + it("can produce every channel the check constraint allows", () => { + // The other half of the same guarantee: a value only the database knows + // about is a bucket the dashboard can never fill. + const produced = new Set([ + channelOf(null), + channelOf("google.com"), + channelOf("x.com"), + channelOf("example.org"), + channelOf(null, { source: "newsletter" }), + ]); + + expect(produced).toEqual(new Set(CHANNELS)); + }); +}); + +describe("acquisition", () => { + it("stores the host of an external referrer with its channel", () => { + expect( + acquisition({ + referrer: "https://www.google.com/search?q=aurora+analytics", + siteUrl: SITE, + }) + ).toEqual({ referrer_host: "google.com", channel: "search" }); + }); + + it("drops a self-referral to direct", () => { + expect( + acquisition({ referrer: "https://example.com/pricing", siteUrl: SITE }) + ).toEqual({ referrer_host: null, channel: "direct" }); + }); + + it("compares with www stripped from both sides", () => { + expect( + acquisition({ + referrer: "https://www.example.com/pricing", + siteUrl: "example.com", + }).referrer_host + ).toBeNull(); + expect( + acquisition({ + referrer: "https://example.com/pricing", + siteUrl: "https://WWW.Example.com/", + }).referrer_host + ).toBeNull(); + }); + + it("keeps a subdomain of the site, which is a different host", () => { + expect( + acquisition({ referrer: "https://docs.example.com/x", siteUrl: SITE }) + ).toEqual({ referrer_host: "docs.example.com", channel: "referral" }); + }); + + it("survives an unusable website url", () => { + expect( + acquisition({ referrer: "https://x.com/post/1", siteUrl: "" }) + ).toEqual({ referrer_host: "x.com", channel: "social" }); + }); + + it("reports direct for the legacy sentinel and for no referrer at all", () => { + expect(acquisition({ referrer: "Direct", siteUrl: SITE })).toEqual({ + referrer_host: null, + channel: "direct", + }); + expect(acquisition({ siteUrl: SITE })).toEqual({ + referrer_host: null, + channel: "direct", + }); + }); + + it("reports an untagged newsletter read in webmail as a referral", () => { + expect( + acquisition({ referrer: "https://mail.google.com/", siteUrl: SITE }) + ).toEqual({ referrer_host: "mail.google.com", channel: "referral" }); + }); + + it("keeps the host of a campaign link while calling it a campaign", () => { + expect( + acquisition({ + referrer: "https://mail.google.com/", + siteUrl: SITE, + utm: { source: "newsletter", medium: "email" }, + }) + ).toEqual({ referrer_host: "mail.google.com", channel: "campaign" }); + }); +}); diff --git a/apps/web/app/modules/ingest/__tests__/ua.test.ts b/apps/web/app/modules/ingest/__tests__/ua.test.ts new file mode 100644 index 00000000..24d9d0cd --- /dev/null +++ b/apps/web/app/modules/ingest/__tests__/ua.test.ts @@ -0,0 +1,555 @@ +import { describe, expect, it } from "vitest"; +import { + ACCEPT_CH, + isBot, + parseClientHints, + parseUserAgent, + parseUserAgentString, + screenClass, +} from "../ua.server"; + +const headers = (init: Record) => new Headers(init); + +const CHROME_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"; + +/** + * The reduced strings Chromium actually ships. Every platform version in them + * is frozen: `Mac OS X 10_15_7` whatever the Mac is running, `Windows NT 10.0` + * for both 10 and 11, `Android 10` for every phone. + */ +const CHROME_WINDOWS = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"; + +const CHROME_ANDROID = + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Mobile Safari/537.36"; + +/** What Chrome 139 sends unasked on a secure cross-origin subresource. */ +const CHROME_BRANDS = + '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"'; + +const IPHONE = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1"; + +const IPAD = + "Mozilla/5.0 (iPad; CPU OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1"; + +const SMART_TV = + "Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36 (KHTML, like Gecko) 76.0.3809.146 Safari/537.36"; + +const GOOGLEBOT = + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + +describe("ACCEPT_CH", () => { + it("asks for every hint something in this file reads", () => { + // Named one by one rather than matched loosely: every entry starts with + // "Sec-CH-UA", so a `toContain('Sec-CH-UA')` was satisfied by any prefix of + // the list, and each of these feeds a column. + for (const hint of [ + "Sec-CH-UA", // browser, browser_version + "Sec-CH-UA-Platform", // os + "Sec-CH-UA-Platform-Version", // os_version + "Sec-CH-UA-Mobile", // device + "Sec-CH-UA-Model", // device, for the tablets ?0 would call desktops + ]) { + expect(ACCEPT_CH.split(", ")).toContain(hint); + } + }); +}); + +describe("parseClientHints", () => { + it("picks the branded browser out of the list", () => { + const chrome = parseClientHints( + headers({ + "sec-ch-ua": + '"Not)A;Brand";v="8", "Chromium";v="139", "Google Chrome";v="139"', + }) + ); + + expect(chrome).toMatchObject({ + // Not "Google Chrome": Sec-CH-UA is only sent on secure requests, so + // spelling it the hints' way put the same browser in two buckets decided + // by whether the customer's page was served over TLS. + browser: "Chrome", + browser_version: "139", + }); + }); + + it("spells a browser the way the user agent string does", () => { + const brand = (value: string) => + parseClientHints(headers({ "sec-ch-ua": value })).browser; + + expect(brand('"Google Chrome";v="139", "Chromium";v="139"')).toBe("Chrome"); + expect(brand('"Microsoft Edge";v="139", "Chromium";v="139"')).toBe("Edge"); + // Everything else already agrees, and inventing a spelling for it would be + // worse than the split. + expect(brand('"Opera";v="119", "Chromium";v="133"')).toBe("Opera"); + expect(brand('"Brave";v="139", "Chromium";v="139"')).toBe("Brave"); + }); + + it("discards every spelling of the GREASE brand", () => { + const spellings = [ + '"Not;A=Brand";v="99", "Microsoft Edge";v="139", "Chromium";v="139"', + '"Not_A Brand";v="24", "Microsoft Edge";v="139", "Chromium";v="139"', + '"(Not(A:Brand";v="8", "Microsoft Edge";v="139", "Chromium";v="139"', + '" Not A;Brand";v="99", "Microsoft Edge";v="139", "Chromium";v="139"', + ]; + + for (const value of spellings) { + expect(parseClientHints(headers({ "sec-ch-ua": value })).browser).toBe( + "Edge" + ); + } + }); + + it("keeps the fork's own version rather than Chromium's", () => { + // Opera 119 ships on Chromium 133; taking the version field by field would + // report Opera 133, a release that does not exist. + expect( + parseClientHints( + headers({ + "sec-ch-ua": + '"Opera";v="119", "Chromium";v="133", "Not(A:Brand";v="24"', + }) + ) + ).toMatchObject({ browser: "Opera", browser_version: "119" }); + }); + + it("falls back to Chromium when that is all the browser claims", () => { + expect( + parseClientHints( + headers({ "sec-ch-ua": '"Chromium";v="139", "Not_A Brand";v="24"' }) + ).browser + ).toBe("Chromium"); + }); + + it("says nothing when the list is only GREASE, or absent", () => { + expect( + parseClientHints(headers({ "sec-ch-ua": '"Not_A Brand";v="24"' })) + ).toEqual({}); + expect(parseClientHints(new Headers())).toEqual({}); + }); + + it("maps the Windows platform version to the release it means", () => { + const windows = (version: string) => + parseClientHints( + headers({ + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform-version": version, + }) + ); + + expect(windows('"15.0.0"')).toMatchObject({ + os: "Windows", + os_version: "11", + }); + expect(windows('"13.0.0"')).toMatchObject({ os_version: "11" }); + expect(windows('"10.0.0"')).toMatchObject({ os_version: "10" }); + expect(windows('"1.0.0"')).toMatchObject({ os_version: "10" }); + // 0.x is 7, 8 or 8.1, which the header cannot tell apart. + expect(windows('"0.3.0"')).toMatchObject({ + os: "Windows", + os_version: null, + }); + }); + + it("keeps the major of every other platform version", () => { + expect( + parseClientHints( + headers({ + "sec-ch-ua-platform": '"macOS"', + "sec-ch-ua-platform-version": '"15.3.1"', + }) + ) + ).toMatchObject({ os: "macOS", os_version: "15" }); + }); + + it("keeps the platform when its version was never requested", () => { + expect( + parseClientHints(headers({ "sec-ch-ua-platform": '"macOS"' })) + ).toMatchObject({ os: "macOS", os_version: null }); + }); + + it("ignores the Unknown platform", () => { + expect( + parseClientHints(headers({ "sec-ch-ua-platform": '"Unknown"' })).os + ).toBeUndefined(); + }); + + it("reads the form factor from mobile, platform and model together", () => { + const device = (init: Record) => + parseClientHints(headers(init)).device; + + expect(device({ "sec-ch-ua-mobile": "?1" })).toBe("mobile"); + expect( + device({ "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"' }) + ).toBe("desktop"); + // Chrome reports ?0 on Android tablets, and a model is only ever sent by a + // device that has one. + expect( + device({ "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Android"' }) + ).toBe("tablet"); + expect( + device({ "sec-ch-ua-mobile": "?0", "sec-ch-ua-model": '"SM-X200"' }) + ).toBe("tablet"); + expect(device({ "sec-ch-ua-platform": '"Windows"' })).toBeUndefined(); + }); +}); + +describe("parseUserAgentString", () => { + it("reads a desktop browser", () => { + expect(parseUserAgentString(CHROME_MAC)).toEqual({ + browser: "Chrome", + browser_version: "139", + os: "macOS", + os_version: "10", + device: "desktop", + }); + }); + + it("separates phones from tablets", () => { + expect(parseUserAgentString(IPHONE).device).toBe("mobile"); + expect(parseUserAgentString(IPAD).device).toBe("tablet"); + }); + + it("leaves a form factor the column cannot hold unknown", () => { + // 'smarttv' is not one of desktop/mobile/tablet, and folding it into + // desktop would be an invention rather than a fallback. + expect(parseUserAgentString(SMART_TV).device).toBeNull(); + }); + + it("keeps a name whose version it could not find", () => { + const macOS = parseUserAgentString( + "Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36" + ); + + expect(macOS.os).toBe("macOS"); + expect(macOS.os_version).toBeNull(); + }); + + it("stores the major and nothing more", () => { + expect(parseUserAgentString(CHROME_MAC).browser_version).toBe("139"); + }); + + it("tolerates a missing user-agent header", () => { + const nothing = { + browser: null, + browser_version: null, + os: null, + os_version: null, + device: null, + }; + + expect(parseUserAgentString("")).toEqual(nothing); + expect(parseUserAgentString(null)).toEqual(nothing); + expect(parseUserAgentString(undefined)).toEqual(nothing); + }); +}); + +describe("parseUserAgent", () => { + it("prefers the hints over the string", () => { + expect( + parseUserAgent( + headers({ + "user-agent": CHROME_MAC, + "sec-ch-ua": + '"Opera";v="119", "Chromium";v="133", "Not(A:Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform-version": '"15.0.0"', + }) + ) + ).toEqual({ + browser: "Opera", + browser_version: "119", + os: "Windows", + os_version: "11", + device: "desktop", + }); + }); + + it("never mixes one source's name with the other's version", () => { + const mixed = parseUserAgent( + headers({ + "user-agent": CHROME_MAC, + "sec-ch-ua": '"Opera";v=""', + }) + ); + + expect(mixed.browser).toBe("Opera"); + expect(mixed.browser_version).toBeNull(); + // The string still answers everything the hints did not. + expect(mixed.os).toBe("macOS"); + }); + + it("falls back per dimension, not all or nothing", () => { + expect( + parseUserAgent( + headers({ "user-agent": IPHONE, "sec-ch-ua-platform": '"iOS"' }) + ) + ).toEqual({ + browser: "Mobile Safari", + browser_version: "18", + os: "iOS", + // The string reports 18.3 too, so the platforms agreeing is what lets + // the version through — see the next test. + os_version: "18", + device: "mobile", + }); + }); + + /** + * `Accept-CH` is only honoured on a top-level navigation response, and this + * origin serves nothing but beacons — so Sec-CH-UA-Platform-Version never + * arrives while the low-entropy Sec-CH-UA-Platform arrives unasked on every + * secure request. Coupling the version to the name therefore wrote null into + * os_version for ~100% of Chromium traffic, permanently. + */ + it("keeps the string's os version when the hints name the same platform", () => { + expect( + parseUserAgent( + headers({ + "user-agent": CHROME_MAC, + "sec-ch-ua": '"Google Chrome";v="139", "Chromium";v="139"', + "sec-ch-ua-platform": '"macOS"', + }) + ) + ).toMatchObject({ os: "macOS", os_version: "10", browser: "Chrome" }); + }); + + it("does not lend one platform's version to another", () => { + expect( + parseUserAgent( + headers({ "user-agent": CHROME_MAC, "sec-ch-ua-platform": '"Android"' }) + ) + ).toMatchObject({ os: "Android", os_version: null }); + }); + + it("prefers the hinted version when the hints actually sent one", () => { + expect( + parseUserAgent( + headers({ + "user-agent": CHROME_MAC, + "sec-ch-ua-platform": '"macOS"', + "sec-ch-ua-platform-version": '"15.3.1"', + }) + ) + ).toMatchObject({ os: "macOS", os_version: "15" }); + }); + + /** + * The four columns below are the only values in the whole insert that come + * from a header rather than from the request body, and every string in the + * body is byte-bound before it reaches a column. `sec-ch-ua` is client-set up + * to Node's ~16KB header limit, so without this one request could write 16KB + * into the browsers panel and another 16KB into the OS panel. + */ + it("drops a header-supplied name too long to be one", () => { + const long = "B".repeat(16_000); + + expect( + parseUserAgent( + headers({ + "user-agent": CHROME_MAC, + "sec-ch-ua": `"${long}";v="1"`, + "sec-ch-ua-platform": `"${long}"`, + "sec-ch-ua-platform-version": '"15.0.0"', + }) + ) + ).toMatchObject({ + browser: null, + // Dropped with its name: a version beside no browser is a row every + // panel files as unknown while still claiming a version was known. + browser_version: null, + os: null, + os_version: null, + }); + }); + + it("bounds those names by bytes, like every other stored string", () => { + const brand = (length: number) => + parseUserAgent(headers({ "sec-ch-ua": `"${"é".repeat(length)}";v="1"` })) + .browser; + + // 32 two-byte characters is 64 bytes; 33 is 66 and does not fit. + expect(brand(32)).toBe("é".repeat(32)); + expect(brand(33)).toBeNull(); + }); + + it("returns nulls for a request with no client information at all", () => { + expect(parseUserAgent(new Headers())).toEqual({ + browser: null, + browser_version: null, + os: null, + os_version: null, + device: null, + }); + }); +}); + +/** + * The version the headers cannot deliver, read out of the browser by the + * tracker and posted in the beacon body instead. `Accept-CH` is stored only + * from a top-level navigation response and this origin serves nothing but + * beacons, so `Sec-CH-UA-Platform-Version` never arrives — while the string it + * falls back to has been frozen by UA reduction, which makes the fallback wrong + * rather than merely thin. + */ +describe("parseUserAgent with a payload hint", () => { + const chromium = (ua: string, platform: string) => + headers({ + "user-agent": ua, + "sec-ch-ua": CHROME_BRANDS, + "sec-ch-ua-mobile": platform === "Android" ? "?1" : "?0", + "sec-ch-ua-platform": `"${platform}"`, + }); + + it("recovers the macOS major the frozen string cannot give", () => { + // `Mac OS X 10_15_7` is what every Chromium Mac reports and has since 2021. + expect(parseUserAgent(chromium(CHROME_MAC, "macOS")).os_version).toBe("10"); + expect( + parseUserAgent(chromium(CHROME_MAC, "macOS"), "15.3.1") + ).toMatchObject({ + browser: "Chrome", + browser_version: "139", + os: "macOS", + os_version: "15", + device: "desktop", + }); + }); + + /** + * The Microsoft table at the top of the file was dead code: it only ever ran + * on a header that never arrives. The payload is what makes it run, and 11 is + * the answer no UA string can give — the string says `Windows NT 10.0` for + * both releases. + */ + it("maps a Windows payload version through the Microsoft table", () => { + const windows = (reported?: string) => + parseUserAgent(chromium(CHROME_WINDOWS, "Windows"), reported).os_version; + + expect(windows()).toBe("10"); + expect(windows("15.0.0")).toBe("11"); + expect(windows("13.0.0")).toBe("11"); + expect(windows("10.0.0")).toBe("10"); + // 0.x is 7, 8 or 8.1, which nothing can tell apart — and the frozen "10" + // the string offers would be a worse answer than none. + expect(windows("0.3.0")).toBeNull(); + }); + + it("keeps the major of every other platform", () => { + expect( + parseUserAgent(chromium(CHROME_ANDROID, "Android"), "14.0.0") + ).toMatchObject({ os: "Android", os_version: "14", device: "mobile" }); + }); + + it("uses the string's platform when a permissions policy hid the hint", () => { + // Low-entropy hints are sent by default but a customer page can restrict + // them; `userAgentData` keeps working in the same document. + expect( + parseUserAgent(headers({ "user-agent": CHROME_WINDOWS }), "15.0.0") + ).toMatchObject({ os: "Windows", os_version: "11" }); + }); + + it("has no platform to lend a version to", () => { + expect(parseUserAgent(new Headers(), "15.0.0")).toEqual({ + browser: null, + browser_version: null, + os: null, + os_version: null, + device: null, + }); + }); + + /** + * Attacker-controlled like every other body field: the zod bound stops it + * being long, and everything that is not a leading run of digits falls + * through to the sources that were there before. + */ + it("falls back exactly as before on a value it cannot read", () => { + for (const junk of ["", " ", "not-a-version", "v15", "🙂", null]) { + expect( + parseUserAgent(chromium(CHROME_WINDOWS, "Windows"), junk).os_version + ).toBe("10"); + } + }); + + it("changes nothing else about the row", () => { + // Safari has no userAgentData at all, so a browser could not produce this + // pairing — it is here because a payload field is whatever a caller posts. + expect( + parseUserAgent( + headers({ "user-agent": IPHONE, "sec-ch-ua-platform": '"iOS"' }), + "18.3.1" + ) + ).toEqual({ + browser: "Mobile Safari", + browser_version: "18", + os: "iOS", + os_version: "18", + device: "mobile", + }); + }); + + /** + * A version the table cannot resolve is an answer, not a silence: 0.x is + * Windows 7, 8 or 8.1, and the string's frozen "10" would be a confident + * wrong answer where null is a correct missing one. + */ + it("does not fall back to the frozen string once the payload has spoken", () => { + expect( + parseUserAgent(chromium(CHROME_WINDOWS, "Windows"), "0.3.0") + ).toMatchObject({ os: "Windows", os_version: null }); + }); + + it("is dropped with a platform name too long to store", () => { + expect( + parseUserAgent( + headers({ + "user-agent": CHROME_WINDOWS, + "sec-ch-ua-platform": `"${"W".repeat(16_000)}"`, + }), + "15.0.0" + ) + ).toMatchObject({ os: null, os_version: null }); + }); +}); + +describe("screenClass", () => { + it("buckets on the layout's own breakpoints", () => { + expect(screenClass(390)).toBe("mobile"); + expect(screenClass(639)).toBe("mobile"); + expect(screenClass(640)).toBe("tablet"); + expect(screenClass(1023)).toBe("tablet"); + expect(screenClass(1024)).toBe("laptop"); + expect(screenClass(1535)).toBe("laptop"); + expect(screenClass(1536)).toBe("desktop"); + expect(screenClass(3840)).toBe("desktop"); + }); + + it("has no bucket for a width nobody reported", () => { + expect(screenClass(undefined)).toBeNull(); + expect(screenClass(null)).toBeNull(); + expect(screenClass(0)).toBeNull(); + expect(screenClass(-1)).toBeNull(); + expect(screenClass(Number.NaN)).toBeNull(); + expect(screenClass(Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); + +describe("isBot", () => { + it("recognises a crawler", () => { + expect(isBot(GOOGLEBOT)).toBe(true); + }); + + it("leaves real browsers alone", () => { + expect(isBot(CHROME_MAC)).toBe(false); + expect(isBot(IPHONE)).toBe(false); + }); + + it("does not call a missing header a bot", () => { + expect(isBot(null)).toBe(false); + expect(isBot(undefined)).toBe(false); + expect(isBot("")).toBe(false); + }); +}); diff --git a/apps/web/app/modules/ingest/__tests__/visitor.test.ts b/apps/web/app/modules/ingest/__tests__/visitor.test.ts new file mode 100644 index 00000000..5d3b488b --- /dev/null +++ b/apps/web/app/modules/ingest/__tests__/visitor.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clientIp, + clientKey, + SESSION_WINDOW_MS, + visitorId, +} from "../visitor.server"; + +const headers = (init: Record) => new Headers(init); + +const CLIENT = headers({ + "x-forwarded-for": "203.0.113.7", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", +}); + +const NOON = new Date("2026-08-04T12:00:00.000Z"); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("clientIp", () => { + it("takes the hop the nearest proxy added, not the one the caller typed", () => { + // nginx's $proxy_add_x_forwarded_for and Cloudflare both *append* the + // address they saw to whatever the client sent, so the front of the list is + // the caller's own text and the back is the only entry with any evidence + // behind it. Reading the front made the visitor id and the rate-limit key + // both attacker-chosen: three requests, three "unique visitors". + const spoofed = "9.9.9.9, 10.1.1.1, 203.0.113.7"; + + expect(clientIp(headers({ "x-forwarded-for": spoofed }))).toBe( + "203.0.113.7" + ); + }); + + it("prefers the headers an edge overwrites over the one it appends to", () => { + // Cloudflare appends to a client-supplied XFF but replaces + // cf-connecting-ip, so consulting XFF first let a caller shadow the one + // header that could be trusted. + expect( + clientIp( + headers({ + "cf-connecting-ip": "203.0.113.9", + "x-forwarded-for": "9.9.9.9", + }) + ) + ).toBe("203.0.113.9"); + expect( + clientIp(headers({ "x-real-ip": "203.0.113.10", "x-forwarded-for": "" })) + ).toBe("203.0.113.10"); + }); + + it("believes only the named header once a deployment names one", async () => { + vi.stubEnv("AURORA_IP_HEADER", "cf-connecting-ip"); + vi.resetModules(); + + const configured = await import("../visitor.server"); + + expect( + configured.clientIp( + headers({ + "cf-connecting-ip": "203.0.113.9", + "x-real-ip": "9.9.9.9", + "x-forwarded-for": "8.8.8.8", + }) + ) + ).toBe("203.0.113.9"); + expect( + configured.clientIp(headers({ "x-forwarded-for": "203.0.113.7" })) + ).toBe(""); + }); + + it("refuses anything that is not an address", () => { + // The value is an HMAC input and a Map key, so a caller must not be able to + // make it an arbitrary string of arbitrary length. + expect(clientIp(headers({ "x-real-ip": "not-an-ip" }))).toBe(""); + expect(clientIp(headers({ "x-real-ip": "W".repeat(4_000) }))).toBe(""); + expect(clientIp(headers({ "x-forwarded-for": " , " }))).toBe(""); + }); + + it("reads IPv6 as readily as IPv4", () => { + expect(clientIp(headers({ "x-real-ip": "2001:db8::1" }))).toBe( + "2001:db8::1" + ); + }); + + it("falls through a header that carries nothing usable", () => { + expect( + clientIp( + headers({ "cf-connecting-ip": "unknown", "x-real-ip": "10.0.0.1" }) + ) + ).toBe("10.0.0.1"); + }); + + it("reports no address when nothing proxies the request", () => { + expect(clientIp(new Headers())).toBe(""); + }); +}); + +describe("clientKey", () => { + it("is the address whenever there is one", () => { + expect(clientKey(CLIENT)).toBe("203.0.113.7"); + }); + + it("still separates callers behind no proxy at all", () => { + // One shared constant here would be one bucket for the whole process, and + // an attacker draining it takes every site's ingest down together. + const chrome = headers({ "user-agent": "Mozilla/5.0 Chrome/140" }); + const firefox = headers({ "user-agent": "Mozilla/5.0 Firefox/142" }); + + expect(clientKey(chrome)).toBe(clientKey(chrome)); + expect(clientKey(chrome)).not.toBe(clientKey(firefox)); + expect(clientKey(new Headers())).toEqual(expect.any(String)); + }); + + it("stays short whatever the header it was built from", () => { + const huge = headers({ "user-agent": "W".repeat(16_000) }); + + expect(clientKey(huge).length).toBeLessThanOrEqual(24); + }); +}); + +describe("visitorId", () => { + it("is 22 url-safe characters", () => { + expect(visitorId(CLIENT, "site", NOON)).toMatch(/^[\w-]{22}$/); + }); + + it("is stable across a UTC day and rotates at midnight", () => { + const open = new Date("2026-08-04T00:00:00.000Z"); + const close = new Date("2026-08-04T23:59:59.999Z"); + const next = new Date("2026-08-05T00:00:00.000Z"); + + expect(visitorId(CLIENT, "site", open)).toBe( + visitorId(CLIENT, "site", close) + ); + expect(visitorId(CLIENT, "site", next)).not.toBe( + visitorId(CLIENT, "site", close) + ); + }); + + it("separates websites, addresses and user agents", () => { + const base = visitorId(CLIENT, "site", NOON); + const elsewhere = headers({ + "x-forwarded-for": "203.0.113.8", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }); + const other = headers({ + "x-forwarded-for": "203.0.113.7", + "user-agent": "Mozilla/5.0 (X11; Linux x86_64)", + }); + + expect(visitorId(CLIENT, "other-site", NOON)).not.toBe(base); + expect(visitorId(elsewhere, "site", NOON)).not.toBe(base); + expect(visitorId(other, "site", NOON)).not.toBe(base); + }); + + it("cannot be split into fresh visitors by a forged forwarding header", () => { + const forge = (spoof: string) => + visitorId( + headers({ + "cf-connecting-ip": "203.0.113.7", + "x-forwarded-for": `${spoof}, 203.0.113.7`, + "user-agent": "Mozilla/5.0 Chrome/140", + }), + "site", + NOON + ); + + expect(forge("9.9.9.1")).toBe(forge("9.9.9.2")); + expect(forge("9.9.9.1")).toBe(forge("9.9.9.3")); + }); + + it("does not leak the address it was derived from", () => { + expect(visitorId(CLIENT, "site", NOON)).not.toContain("203.0.113"); + }); + + it("still identifies a reader behind a proxy that forwards nothing", () => { + const bare = headers({ "user-agent": "Mozilla/5.0" }); + + expect(visitorId(bare, "site", NOON)).toBe(visitorId(bare, "site", NOON)); + }); + + it("keys the digest on the salt", async () => { + vi.stubEnv("AURORA_SALT", "a-different-salt"); + vi.resetModules(); + + const other = await import("../visitor.server"); + + expect(other.visitorId(CLIENT, "site", NOON)).not.toBe( + visitorId(CLIENT, "site", NOON) + ); + }); +}); + +describe("configuration", () => { + it("refuses to boot in production without a salt", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("AURORA_SALT", ""); + vi.resetModules(); + + await expect(import("../visitor.server")).rejects.toThrow(/AURORA_SALT/); + }); + + it("boots in production once a salt is configured", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("AURORA_SALT", "a-configured-salt"); + vi.resetModules(); + + await expect(import("../visitor.server")).resolves.toBeDefined(); + }); +}); + +describe("SESSION_WINDOW_MS", () => { + it("is the 30 minutes the dashboard's session figures assume", () => { + expect(SESSION_WINDOW_MS).toBe(1_800_000); + }); +}); diff --git a/apps/web/app/modules/ingest/cors.server.ts b/apps/web/app/modules/ingest/cors.server.ts new file mode 100644 index 00000000..035f590a --- /dev/null +++ b/apps/web/app/modules/ingest/cors.server.ts @@ -0,0 +1,122 @@ +import { siteHost, urlHost } from "./referrer.server"; +import { ACCEPT_CH } from "./ua.server"; + +/** + * The tracker script runs on third-party sites, so the collect routes are the + * only part of the app that answers cross-origin. The previous deployment + * applied these headers to every response via vercel.json; scoping them to the + * two collect routes keeps the authenticated surface same-origin. + */ +const BASE: Record = { + "Access-Control-Allow-Methods": "POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + /** + * Client hints above the low-entropy floor only start arriving once the + * server has asked for them, and the beacon is often the only request this + * origin ever makes — so the ask has to ride on the ingest response itself + * rather than on a document response that never happens here. + */ + "Accept-CH": ACCEPT_CH, + /** + * Sent unconditionally, including on the responses that carry no + * Allow-Origin: the header a shared cache stores depends on the request's + * Origin either way, and announcing that only when one was present is how a + * cache ends up serving one site's allowance to another. + */ + Vary: "Origin", +}; + +/** + * The caller's own origin, never `*`. Echoing is what keeps a wildcard from + * ever being combined with credentials, and `*` would in any case be a claim + * about who may read a response whose only body is an error message. + * + * No Origin means no cross-origin check to satisfy — a server-to-server post, + * or a beacon the browser sent without one — and naming an origin there would + * be a header with nothing to answer. + * + * `null` is suppressed rather than echoed. It is a non-empty string, so it + * would otherwise flow through here from a sandboxed iframe or a data:/file: + * document and come back as `Access-Control-Allow-Origin: null` — a value every + * opaque-origin document on the internet matches, and the one spelling of an + * origin that names no site at all. `originAllowed` already rejects it on the + * request side; this is the response side of the same answer, and it also + * applies to the preflight, which has no body and so no `wid` to validate + * against in the first place. + */ +export function corsHeaders(origin?: string | null): Record { + return origin && origin !== "null" + ? { ...BASE, "Access-Control-Allow-Origin": origin } + : BASE; +} + +/** + * Whether an Origin may post events for this website. + * + * Request-side half of the same policy the headers above express, kept beside + * them so there is one answer to "is this caller ours" rather than one per + * route. A *missing* Origin is the caller's to decide about — several beacon + * paths and every server-to-server post omit it — but a present and foreign one + * is somebody posting a neighbour's website id from their own page. + */ +export function originAllowed(origin: string, siteUrl: string): boolean { + const host = urlHost(origin); + + if (!host) { + return false; + } + + if (host === siteHost(siteUrl)) { + return true; + } + + // The tracker is developed against a site served from the machine running the + // app, which never matches the registered hostname. Production has no such + // caller, and treating one as legitimate there would let any page opt itself + // into a tenant by proxying through a local address. + return ( + process.env.NODE_ENV !== "production" && + (host === "localhost" || host === "127.0.0.1" || host === "[::1]") + ); +} + +/** Success carries no body: §1 forbids echoing the row, the id, or anything. */ +export function corsNoContent(origin?: string | null) { + return new Response(null, { status: 204, headers: corsHeaders(origin) }); +} + +/** A preflight answer is the headers and nothing else, which is this exactly. */ +export function preflight(origin?: string | null) { + return corsNoContent(origin); +} + +export function corsJson(data: unknown, status = 200, origin?: string | null) { + return Response.json(data, { status, headers: corsHeaders(origin) }); +} + +/** + * Nothing may leave a collect route by being thrown. + * + * React Router answers an uncaught resource-route error with + * `returnLastResortErrorResponse`, which builds its own `text/plain` Response — + * so none of the CORS headers those routes are careful about are on it, not even + * `Vary: Origin`, and outside a production server mode the body is + * `String(error)`. Drizzle's DrizzleQueryError stringifies to + * `Failed query: \nparams: `, which for the ingest INSERT is + * the whole event row — visitor id, session id, path, referrer, props — echoed + * to a third-party origin. Reachable without any misconfiguration: a deadlock + * between the bounce clear and a concurrent insert, a statement timeout, a + * dropped connection. + * + * It lives here rather than beside the routes because a route module may only + * export `loader`, `action`, `middleware` and `headers` on top of client-safe + * values: React Router strips those four from the client build and nothing else, + * so a fifth export reaching for `corsJson` drags this whole module into the + * browser graph and fails the client build outright. + */ +export function serverError(error: unknown, origin: string | null) { + console.error("aurora: ingest failed", error); + + return corsJson({ message: "Internal error" }, 500, origin); +} diff --git a/apps/web/app/modules/ingest/geo.server.ts b/apps/web/app/modules/ingest/geo.server.ts new file mode 100644 index 00000000..4dd5be0d --- /dev/null +++ b/apps/web/app/modules/ingest/geo.server.ts @@ -0,0 +1,69 @@ +/** + * Country comes from whichever edge is already terminating TLS, and never from + * a GeoIP database: shipping one would mean a 60MB file, a licence, and a + * monthly update job on every self-hosted install, to answer a question the + * proxy in front of it usually already answered. + * + * A deployment with no geo-aware proxy therefore reports null everywhere, which + * is a supported setup — the column is nullable and the breakdown treats the + * bucket as unknown rather than as an error. + */ + +/** + * The header this deployment's edge writes, when it is not one of the three + * below. Named rather than sniffed for the same reason AURORA_IP_HEADER is: + * a header is only evidence if some hop is known to overwrite it. + */ +const CONFIGURED_HEADER = + process.env.AURORA_COUNTRY_HEADER?.trim().toLowerCase(); + +/** + * Each of these is written by one specific edge and stripped by it on the way + * in, so a value that arrives in one is that edge's answer rather than the + * caller's. + * + * The list used to also carry `x-country-code` and `x-geo-country`. Those are + * generic names no particular proxy owns, which means nothing overwrites them + * and they pass through from the client verbatim: on a deployment with no + * geo-aware edge at all — the setup the docstring above calls supported — three + * curl requests were enough to put a country of the caller's choosing into the + * breakdown. The ALPHA2 shape check constrains the value and says nothing about + * where it came from. + */ +export const COUNTRY_HEADERS = [ + "cf-ipcountry", + "x-vercel-ip-country", + "fastly-geo-country", +] as const; + +const headers = () => + CONFIGURED_HEADER + ? ([CONFIGURED_HEADER] as readonly string[]) + : COUNTRY_HEADERS; + +/** + * ISO-shaped values that are not countries. Cloudflare answers XX when it + * cannot place the client and T1 for Tor exit nodes; stored as-is they become a + * top-five "country" on any site with privacy-minded readers. + */ +const PLACEHOLDERS = new Set(["XX", "T1"]); + +const ALPHA2 = /^[A-Z]{2}$/; + +/** + * The first *usable* value wins rather than the first header present: an edge + * that cannot place the client still sends its header, and a second proxy + * further in may well know. Anything that is not two letters is dropped + * silently, because nothing in the database checks this column. + */ +export function country(requestHeaders: Headers): string | null { + for (const header of headers()) { + const value = requestHeaders.get(header)?.trim().toUpperCase(); + + if (value && ALPHA2.test(value) && !PLACEHOLDERS.has(value)) { + return value; + } + } + + return null; +} diff --git a/apps/web/app/modules/ingest/payload.server.ts b/apps/web/app/modules/ingest/payload.server.ts new file mode 100644 index 00000000..8bf79fa4 --- /dev/null +++ b/apps/web/app/modules/ingest/payload.server.ts @@ -0,0 +1,309 @@ +import { z } from "zod"; + +/** + * What a beacon's body is allowed to be, before anything reads what it means. + * + * Extracted from routes/api/collect.ts, for two reasons that turned out to be + * the same one. The duration endpoint needed `bounded` and `readPayload` and + * was importing them from the other route file — the only route-to-route edge + * in the graph. And a route module's non-route exports survive into the client + * build, so a schema exported from the route is a schema in the browser bundle; + * React Router only strips `loader`, `action`, `middleware` and `headers`. With + * both schemas here the two routes export nothing but those, which is what lets + * this module be `.server` at all. + */ + +/** + * Postgres cannot hold either of these, and the endpoint is unauthenticated. + * + * `text` rejects U+0000 outright (22021) and `jsonb` rejects both U+0000 + * (22P05) and an unpaired UTF-16 surrogate (22P02) — the latter because + * JSON.stringify faithfully emits `\ud800` for one. `"\u0000"` is legal JSON, so + * it passes JSON.parse and every zod check, and none of those SQLSTATEs is + * 23505, so all three used to escape the duplicate-token catch below and 500 + * the route with the whole INSERT in the log. All four cases were reproduced + * against pg 16. + * + * Repaired rather than rejected. A NUL is never the meaningful part of a path + * or a prop, and a lone surrogate is what `label.slice(0, 32)` leaves behind + * when it cuts an emoji in half — an ordinary bug on a customer's page, not an + * attack, and dropping the whole beacon for it would lose a real pageview. + * U+FFFD is exactly what Node's UTF-8 encoder already substitutes on the way to + * a `text` column, so this only makes `jsonb` agree with the columns beside it. + */ +/** One day, matching the events_duration_range check. */ +const MAX_DURATION = 86_400_000; + +const LONE_SURROGATE = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? + value.replaceAll("\u0000", "").replace(LONE_SURROGATE, "\uFFFD"); + +/** + * Zod's `.max()` counts UTF-16 code units, so a 1024-character multibyte string + * passes it and then breaks a byte-sized limit downstream — btree refuses an + * index tuple over ~2704 bytes, and by then the ingest transaction is already + * open. Bound the bytes the database will actually be handed, after repairing + * the characters it cannot hold at all: every client string in this file goes + * through here, which is the only way both rules apply to all of them. + */ +export const bounded = (max: number) => + z + .string() + .transform(storable) + .refine((value) => Buffer.byteLength(value, "utf8") <= max, { + message: "Value is too long", + }); + +/** + * Every bound above added up, with room to spare: 24 props of 64 + 512 bytes is + * ~14KB, plus a 1024 byte path, a 1024 byte referrer, five 255 byte utm values + * and the JSON syntax around them comes to roughly 18KB, so a payload this + * endpoint would actually accept always fits. + * + * The *read* has to be bounded rather than the parse. Every byte-bounded + * validator above defends the btree and none of them defends the heap: they run + * after the body is already in memory, and `request.text()` had no cap at all. + * Nothing upstream supplies one either — `@react-router/serve` mounts + * compression, express.static and morgan around the handler and no body parser + * — so one unauthenticated POST could buffer as much as it liked. Content-Length + * is checked first and the stream is capped regardless, because a chunked + * request declares no length. + */ +const MAX_BODY_BYTES = 32 * 1024; + +/** `null` for a body that was too large, unreadable, or not JSON — one 422. */ +export async function readPayload( + request: Request +): Promise<{ payload: unknown } | null> { + const declared = request.headers.get("content-length"); + + if (declared !== null && Number(declared) > MAX_BODY_BYTES) { + return null; + } + + if (!request.body) { + return null; + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + + for (;;) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + size += value.byteLength; + + if (size > MAX_BODY_BYTES) { + await reader.cancel(); + + return null; + } + + chunks.push(value); + } + + try { + // sendBeacon posts text/plain, so the content type is evidence of nothing + // and the body is parsed rather than negotiated. + return { payload: JSON.parse(Buffer.concat(chunks).toString("utf8")) }; + } catch { + return null; + } +} + +/** + * Normalising the path is the tracker's job, but it arrives from an + * unauthenticated client on somebody else's page: one stray `?utm_source=` or + * `#section` splits a single page into a row per campaign and per anchor, and + * the breakdown that results is unrecoverable after the fact. + * + * A fragment is kept when — and only when — it is shaped like a route, meaning + * it starts with `#/`. That is the convention every hash router writes: Vue + * Router's hash mode, Angular's HashLocationStrategy, `createHashRouter`, and + * any static host that cannot serve a rewrite. Collapsing it gave those apps one + * row per site, always `/`, one pageview per document however deep the visit + * went, and — because the bounce clear needs a second pageview — a bounce on + * every single visit. + * + * Nothing looser than `#/`, because a fragment is where the web puts secrets. + * An OAuth implicit-flow or magic-link callback lands as + * `#access_token=…&refresh_token=…`, and this column is unbounded text rendered + * in a dashboard panel; `#pricing` and `#comment-1234` are meanwhile positions + * inside one page, and counting them would split that page into a row per + * anchor. Both keep collapsing exactly as they did. + * + * The route itself ends at the first `?`, `&` or `#`. `?` is where a hash router + * puts its search params, so `#/orders?page=2` is one page. The other two are + * how a secret gets past the `#/` test: a redirect URI that already carries a + * fragment is undefined territory in RFC 6749 and providers resolve it by + * appending, so `#/callback&access_token=…` and `#/callback#access_token=…` are + * both shapes a hash-routed app's OAuth callback really lands on. A route + * segment holding a literal `&` is truncated as the price of that. + * + * The byte bound is unchanged and still runs first: `bounded` measures the value + * as it arrived, and everything here only ever removes from it. + */ +const path = bounded(1024) + .pipe(z.string().min(1)) + .transform((value) => { + const cut = value.indexOf("#"); + const fragment = cut === -1 ? "" : value.slice(cut); + const [pathname = ""] = (cut === -1 ? value : value.slice(0, cut)).split( + "?" + ); + // Sliced past the `#` before splitting, or the leading one is the first + // separator and takes the whole route with it. + const [route = ""] = fragment.startsWith("#/") + ? fragment.slice(1).split(/[?&#]/) + : []; + const rooted = pathname.startsWith("/") ? pathname : `/${pathname}`; + + return route ? `${rooted}#${route}` : rooted; + }); + +/** Blank is how the tracker spells "this parameter was not in the URL". */ +const param = bounded(255) + .nullish() + .transform((value) => value?.trim() || null); + +/** + * Custom event properties, bounded on every axis. The column is jsonb and the + * endpoint is unauthenticated, so without these a single beacon could park a + * document in the events table; scalars only, because a nested value has no + * meaning in a breakdown anyway. + */ +const props = z + .record( + bounded(64), + z.union([bounded(512), z.number().finite(), z.boolean()]) + ) + .refine((value) => Object.keys(value).length <= 24, { + message: "Too many properties", + }); + +/** + * `numeric(14, 2)` overflows above this, and an overflow arrives as a 22003 + * that aborts the transaction — a client-supplied number gets to be rejected, + * not to 500 the endpoint. Negatives are allowed: a refund is revenue too. + */ +const AMOUNT = 999_999_999_999.99; + +const revenue = z.object({ + amount: z.number().finite().min(-AMOUNT).max(AMOUNT), + // ISO-4217, uppercased here so `eur` and `EUR` are not two currencies. + currency: z + .string() + .regex(/^[a-z]{3}$/i, "Invalid currency") + .transform((value) => value.toUpperCase()), +}); + +/** + * A cuid2 from `~/db/id` is 25 characters, so nothing longer can name a + * website. Bound anyway, and by bytes like everything else: this value is fed + * to `getWebsite` on an unauthenticated path, and a `wid` of two million + * characters is a query parameter and a log line before it is a 404. + */ +const wid = bounded(32).pipe(z.string().min(1)); + +/** What the tracker posts on every pageview and every `aurora()` call. */ +export const collectSchema = z + .object({ + wid, + /** + * Lowercase, and the check constraint means it: the old tracker sent + * `pageView`, which now fails the insert outright instead of quietly + * matching zero rows in every panel the way it used to. + */ + type: z.enum(["pageview", "event"]), + name: bounded(200).optional(), + // Stored as `view_token` and part of a unique btree key, hence the bound. + vid: bounded(64).pipe(z.string().min(1)), + path, + /** + * A pageview that repairs the path of the one `vid` already named, rather + * than a second pageview. The tracker sends it when a router replaced the + * URL while the route it had just announced was still settling — a mount + * redirect: an auth guard, a locale prefix, a boot rewrite. + * + * It is a flag and not a second endpoint because it is the same beacon with + * the same bounds, and because the tracker must be able to decide between + * the two after it has already sent the pageview. + */ + corrects: z.boolean().optional(), + referrer: bounded(1024).optional(), + language: bounded(64).optional(), + screen: z.number().finite().optional(), + /** + * `navigator.userAgentData.getHighEntropyValues(["platformVersion"])`, read + * by the tracker and posted here because the header carrying the same value + * cannot reach this route: a browser stores an `Accept-CH` ask only from a + * top-level navigation response, and this origin serves nothing but beacons. + * + * Additive and optional, so a tracker that predates it — or any browser + * without `userAgentData`, which is every non-Chromium one — is accepted + * unchanged and falls back to the headers exactly as before. + * + * Absent on a document's *first* pageview even where the browser has an + * answer, and that is the tracker's deliberate trade rather than a gap here: + * `getHighEntropyValues` resolves a task later than the view it would ride + * on, and the first view is the one a fast bounce depends on. So a + * one-pageview visit keeps the frozen fallback, one session can hold two + * `os_version` values, and neither is a defect this route can see. Stated + * here because this is where the field's contract lives — a reader + * comparing the column against the panel is entitled to know which rows + * carry the repaired value. + * + * Attacker-controlled like every other string in this body, hence the same + * byte bound. "10.0.19045.2846" is the widest real answer at 15 bytes; the + * server reduces whatever arrives to a major, or on Windows to the release + * that major names, and drops anything that is not digits. + */ + platformVersion: bounded(32).optional(), + // Accepted so a tracker that reports it is not rejected; there is no column + // for it, because `screen_class` is the question a layout change asks. + viewport: z.number().finite().optional(), + utm: z + .object({ + source: param, + medium: param, + campaign: param, + term: param, + content: param, + }) + .optional(), + props: props.optional(), + revenue: revenue.optional(), + }) + .refine((payload) => payload.type !== "event" || Boolean(payload.name), { + message: "A custom event needs a name", + path: ["name"], + }); + +/** + * The unload beacon that reports how long a view lasted. + * + * It names the view by the tracker's own ephemeral token rather than by an + * event id, which is the whole reason the id never leaves the server: a token + * is meaningless the moment the page is gone, an id is a row anyone could then + * write to. The bounds matter for the same reason the endpoint is + * unauthenticated — without them one beacon skews a site's average visit time + * permanently. + * + * `bounded` is shared with /collect rather than restated: it strips the NUL + * that Postgres refuses even in a `text` comparison, and a `vid` carrying one + * used to throw 22021 straight out of the UPDATE below. + */ +export const durationSchema = z.object({ + // A cuid2 website id is 25 characters; a stored token is at most 64 bytes, so + // nothing longer than either can match anything. + wid: bounded(32).pipe(z.string().min(1)), + vid: bounded(64).pipe(z.string().min(1)), + duration: z.number().min(0).max(MAX_DURATION), +}); diff --git a/apps/web/app/modules/ingest/ratelimit.server.ts b/apps/web/app/modules/ingest/ratelimit.server.ts new file mode 100644 index 00000000..f2574ded --- /dev/null +++ b/apps/web/app/modules/ingest/ratelimit.server.ts @@ -0,0 +1,172 @@ +/** + * 120 events a minute is roughly one every half second sustained, which no + * reader produces and a broken SPA router does. The bucket holds twice that so + * a burst of queued beacons flushing after a bfcache restore is not punished + * for arriving at once. + */ +const RATE_PER_MINUTE = 120; +const BURST = 240; + +/** + * A ceiling on live buckets, because the sweep alone is not one. + * + * The sweep runs at most once per idle window and only evicts entries already + * idle that long, so nothing admitted during the current window can be + * reclaimed — a flood of distinct keys grows the map for two full minutes + * before anything is dropped. At 50k entries the map is a few MB; past that the + * least recently used key is evicted, which hands that one caller a fresh + * bucket and is the correct trade against an unbounded heap. + */ +const MAX_KEYS = 50_000; + +export type RateLimiterOptions = { + ratePerMinute?: number; + burst?: number; + maxKeys?: number; + /** Injected so the refill maths can be tested without waiting for a clock. */ + now?: () => number; +}; + +export type RateLimitResult = { + allowed: boolean; + /** 0 when allowed; otherwise how long until one token exists again. */ + retryAfterMs: number; +}; + +type Bucket = { tokens: number; at: number }; + +/** + * A token bucket per key, held in a Map in least-recently-used order. + * + * Aurora is a single self-hosted process, so the alternative is putting Redis + * on the ingest path to defend an endpoint whose worst case is a skewed chart. + * Behind N instances the effective limit is N times the configured one, which + * is the trade being made knowingly. + * + * Tokens are only ever recomputed when a key is touched, so an idle bucket + * costs nothing but its own entry — the sweep and the size cap below are what + * stop those entries accumulating. + */ +export class RateLimiter { + #buckets = new Map(); + #refillPerMs: number; + #burst: number; + #maxKeys: number; + #now: () => number; + #idleMs: number; + #sweptAt: number; + + constructor(options: RateLimiterOptions = {}) { + const ratePerMinute = options.ratePerMinute ?? RATE_PER_MINUTE; + + this.#burst = options.burst ?? BURST; + this.#maxKeys = options.maxKeys ?? MAX_KEYS; + this.#refillPerMs = ratePerMinute / 60_000; + this.#now = options.now ?? Date.now; + // Time to refill an empty bucket to full. Past it a bucket is + // indistinguishable from one that never existed, which is what makes + // dropping it lossless rather than an amnesty. + this.#idleMs = Math.ceil(this.#burst / this.#refillPerMs); + this.#sweptAt = this.#now(); + } + + /** Live bucket count — the number the sweep and the cap keep bounded. */ + get size() { + return this.#buckets.size; + } + + take(key: string): RateLimitResult { + const now = this.#now(); + + this.#sweep(now); + + const bucket = this.#buckets.get(key); + const tokens = bucket + ? Math.min( + this.#burst, + bucket.tokens + (now - bucket.at) * this.#refillPerMs + ) + : this.#burst; + + if (tokens < 1) { + // Still written back: the timestamp has to keep moving or the next call + // would credit the same elapsed milliseconds twice. + this.#store(key, { tokens, at: now }); + + return { + allowed: false, + retryAfterMs: Math.ceil((1 - tokens) / this.#refillPerMs), + }; + } + + this.#store(key, { tokens: tokens - 1, at: now }); + + return { allowed: true, retryAfterMs: 0 }; + } + + reset() { + this.#buckets.clear(); + this.#sweptAt = this.#now(); + } + + /** + * Delete-then-set so the Map's insertion order is recency order: `set` on a + * key that already exists leaves it where it was, which would make the + * eviction below drop whichever client happened to arrive first rather than + * whichever has been quiet longest. + */ + #store(key: string, bucket: Bucket) { + this.#buckets.delete(key); + this.#buckets.set(key, bucket); + + while (this.#buckets.size > this.#maxKeys) { + const oldest = this.#buckets.keys().next(); + + if (oldest.done) { + return; + } + + this.#buckets.delete(oldest.value); + } + } + + /** + * Lazy rather than on a timer: a `setInterval` would keep the process awake + * and would run in tests, and there is nothing to sweep between requests + * anyway. Sweeping no more often than the refill window bounds the map to the + * distinct keys seen in one such window; the size cap bounds it inside one. + */ + #sweep(now: number) { + if (now - this.#sweptAt < this.#idleMs) { + return; + } + + this.#sweptAt = now; + + for (const [key, bucket] of this.#buckets) { + if (now - bucket.at >= this.#idleMs) { + this.#buckets.delete(key); + } + } + } +} + +export const limiter = new RateLimiter(); + +/** + * Keyed on the caller and on nothing the caller chose. + * + * The key used to be `${ip}:${wid}`, and the website id came straight out of an + * unvalidated request body: rotating one character minted a brand-new full + * bucket, so the limiter counted requests without ever being able to refuse + * one, and each rotation left a Map entry behind for two minutes. Per-site + * budgets were worth having — one office behind a NAT address reading two sites + * was two budgets — but not at the price of letting the counted party pick the + * counter. + * + * `client` is `clientKey()`: a validated address, or a hash of the user agent + * where nothing forwards one. + */ +export function rateLimit(client: string) { + return limiter.take(client); +} diff --git a/apps/web/app/modules/ingest/referrer.server.ts b/apps/web/app/modules/ingest/referrer.server.ts new file mode 100644 index 00000000..0411d079 --- /dev/null +++ b/apps/web/app/modules/ingest/referrer.server.ts @@ -0,0 +1,203 @@ +import type { ChannelType } from "~/db/schema"; + +/** + * Hosts that mean "arrived from a search results page", matched whole. The + * hostname reaching this list is already lowercased and `www.`-less, so + * `www.google.com` is `google.com` by the time it is looked up. + * + * Subdomains deliberately do not match. Three of these are portals before they + * are engines: `mail.`, `docs.`, `drive.`, `groups.` and `news.google.com` all + * sit under `google.com`, and `mail.` under the other two — so a suffix match + * read a newsletter opened in Gmail, a link out of a shared document and a + * headline off Google News as organic search. That is the one direction this + * list must never be wrong in: the number is what SEO work gets sized off, and + * inflating it with webmail is not visible from the panel. + * + * Which is why the search surfaces that are not the bare domain are spelled out + * — `search.yahoo.com` is where Yahoo's results page sends people and + * `m.baidu.com` is most of Baidu's — and why the list stays deliberately short + * otherwise. The long tail of engines is a rounding error next to the cost of a + * list nobody can read, and anything missing lands in `referral`, which is + * wrong by one bucket rather than lost. + */ +export const SEARCH_HOSTS = [ + "google.com", + "bing.com", + "duckduckgo.com", + "yahoo.com", + "search.yahoo.com", + "yandex.com", + "baidu.com", + "m.baidu.com", + "ecosia.org", + "search.brave.com", + "startpage.com", + "qwant.com", +] as const; + +/** + * Referrers that are somebody's feed or timeline. These do match subdomains, + * because none of them is a portal: `m.`, `l.`, `old.` and `music.` are the + * same product on a different surface, and there is no webmail or document + * host under any of these domains to be mistaken for one. + */ +export const SOCIAL_HOSTS = [ + "facebook.com", + "instagram.com", + "x.com", + "twitter.com", + "t.co", + "linkedin.com", + "lnkd.in", + "reddit.com", + "youtube.com", + "youtu.be", + "tiktok.com", + "pinterest.com", + "mastodon.social", + "bsky.app", + "threads.net", + "news.ycombinator.com", +] as const; + +/** + * These three run one domain per country — google.de, yahoo.co.jp, + * yandex.com.tr — and between them that is most of the search traffic outside + * the US. Listing ~190 hostnames would bury the readable list above, so their + * country domains are recognised by brand label instead. + */ +const SEARCH_BRANDS = new Set(["google", "yahoo", "yandex"]); + +/** + * `brand.tld` or `brand.cc.tld`, which is the shape every one of those takes — + * and, since the brand has to be the whole first label, the same rule that lets + * `google.de` in keeps `mail.google.de` out. + */ +const COUNTRY_DOMAIN = /^([a-z0-9-]+)\.(?:[a-z]{2,3}\.)?[a-z]{2,3}$/; + +/** The campaign parameters as the tracker lifts them off `location.search`. */ +export type Utm = { + source?: string | null; + medium?: string | null; + campaign?: string | null; + term?: string | null; + content?: string | null; +}; + +const stripWww = (host: string) => + host.startsWith("www.") ? host.slice(4) : host; + +/** + * A hostname is only comparable once it is lowercased, `www.`-less and free of + * the trailing root dot that `example.com.` is still a legal spelling of. + * Userinfo needs no stripping — `URL.hostname` never carries it — but that is + * exactly why the parse goes through `URL` rather than a regex. + */ +const normalize = (hostname: string) => + stripWww(hostname.toLowerCase().replace(/\.$/, "")); + +/** + * Hostname of an absolute http(s) URL. The scheme check is not ceremony: the + * legacy tracker wrote the sentinel string `Direct` into this field, and a bare + * `example.com` is a relative path rather than a site, so neither may be + * allowed to parse into a referrer that never happened. + */ +export function urlHost(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + + if (!trimmed) { + return null; + } + + try { + const url = new URL(trimmed); + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + + return normalize(url.hostname) || null; + } catch { + return null; + } +} + +/** + * The same, for `websites.url`. That column is a free-text form field with no + * validation, so `example.com` and `https://WWW.Example.org/blog` are both + * already in the table: the scheme is supplied when it is missing rather than + * treating half the rows as unusable. + */ +export function siteHost(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + + if (!trimmed) { + return null; + } + + return urlHost( + /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + ); +} + +const SEARCH = new Set(SEARCH_HOSTS); + +const isSocial = (host: string) => + SOCIAL_HOSTS.some((entry) => host === entry || host.endsWith(`.${entry}`)); + +const isSearchBrand = (host: string) => { + const brand = COUNTRY_DOMAIN.exec(host)?.[1]; + + return brand !== undefined && SEARCH_BRANDS.has(brand); +}; + +const hasUtm = (utm: Utm | null | undefined) => + utm !== null && + utm !== undefined && + Object.values(utm).some((value) => typeof value === "string" && value.trim()); + +/** + * Any utm parameter outranks the referrer host: a campaign link opened from a + * newsletter still reports whatever webmail the reader used, and the tag is the + * deliberate answer while the host is an accident of delivery. That also covers + * the `utm_medium=email|cpc|...` case, which cannot occur without a utm. + */ +export function channelOf(host: string | null, utm?: Utm | null): ChannelType { + if (hasUtm(utm)) { + return "campaign"; + } + + if (!host) { + return "direct"; + } + + if (SEARCH.has(host) || isSearchBrand(host)) { + return "search"; + } + + return isSocial(host) ? "social" : "referral"; +} + +/** + * How the visit was acquired, resolved once at ingest. + * + * Only the host is kept. The full referrer is a path on somebody else's site — + * a search query, a private forum thread, a document title in a URL — which is + * data we would never display and must not hold just because the browser + * offered it. + * + * A self-referral is internal navigation rather than acquisition, so it is + * dropped to null and reads as `direct`; leaving it in would make every site's + * own domain its top referrer. + */ +export function acquisition(input: { + referrer?: string | null; + siteUrl?: string | null; + utm?: Utm | null; +}): { referrer_host: string | null; channel: ChannelType } { + const host = urlHost(input.referrer); + const site = siteHost(input.siteUrl); + const external = host && host !== site ? host : null; + + return { referrer_host: external, channel: channelOf(external, input.utm) }; +} diff --git a/apps/web/app/modules/ingest/ua.server.ts b/apps/web/app/modules/ingest/ua.server.ts new file mode 100644 index 00000000..1abe53fc --- /dev/null +++ b/apps/web/app/modules/ingest/ua.server.ts @@ -0,0 +1,366 @@ +import type { DeviceType, ScreenClass } from "~/db/schema"; +import { isbot } from "isbot"; +import { UAParser } from "ua-parser-js"; + +/** The five columns a request's client can be described by. */ +export type UserAgent = { + browser: string | null; + browser_version: string | null; + os: string | null; + os_version: string | null; + device: DeviceType | null; +}; + +/** + * Client hints are opt-in above a low-entropy floor: `Sec-CH-UA`, `-Mobile` and + * `-Platform` arrive unasked on every secure request, while the platform version + * and the model only start arriving after a server has advertised that it wants + * them. + * + * The ask never lands here, and cannot. A browser stores an `Accept-CH` only + * from a *top-level navigation* response, and this origin serves nothing but + * third-party subresource beacons — and even a populated store would not send a + * high-entropy hint to a cross-origin subresource without a + * `Permissions-Policy` delegation from the customer's own document, which + * Aurora has no way to set. `Critical-CH` carries the same navigation-only + * constraint. + * + * So this is kept for the browsers that *do* honour it — a self-hosted install + * whose dashboard and collector share an origin gets the store populated by the + * dashboard's own navigations — and the version it cannot deliver is read from + * the client instead: the tracker calls `getHighEntropyValues` and posts the + * answer in the beacon body, which `parseUserAgent` prefers over both. + */ +export const ACCEPT_CH = + "Sec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model"; + +const EMPTY: UserAgent = { + browser: null, + browser_version: null, + os: null, + os_version: null, + device: null, +}; + +/** + * `Sec-CH-UA` is a structured-header list of `"brand";v="major"` pairs, read + * pair by pair rather than by splitting on commas: the brand is a quoted string + * and the version parameter belongs to the brand it follows, and a split loses + * that pairing the moment a value contains a separator. + */ +const BRAND = /"([^"]*)";\s*v="([^"]*)"/g; + +/** + * Chromium pads the list with a randomised GREASE brand whose name is some + * punctuation-mangled spelling of "Not A Brand" — `"Not;A=Brand"`, + * `"(Not(A:Brand"`, `";Not A Brand"` — specifically so that servers cannot + * match the list literally. Dropping the punctuation is what collapses every + * spelling of it back to one value. + */ +const isGrease = (brand: string) => + brand + .replace(/[^a-z]/gi, "") + .toLowerCase() + .includes("notabrand"); + +/** + * The two brands `Sec-CH-UA` spells differently from every UA string. + * + * `Sec-CH-UA` is only sent on secure requests, so without this the same browser + * lands in two buckets decided by the customer page's TLS posture: "Google + * Chrome" from an https page and "Chrome" from an http one, and the browsers + * breakdown shows Chrome twice with its counts split. The UA-string spelling + * wins because it is the one that also appears on every row written before + * client hints existed. + * + * ua-parser's Mobile-prefixed names ("Mobile Chrome", "Mobile Safari") are not + * in here on purpose: that is a distinction it draws deliberately, and the + * `device` column already carries the same answer in a form a panel can group + * on. + */ +const BRAND_NAMES = new Map([ + ["google chrome", "Chrome"], + ["microsoft edge", "Edge"], +]); + +/** + * Every Chromium browser lists "Chromium" beside its own brand, and the two + * carry different versions — Opera 119 ships on Chromium 133 — so the entry + * that is not Chromium is both the more useful name and the owner of the + * version that goes with it. + */ +function pickBrand(header: string | null) { + const brands = [...(header ?? "").matchAll(BRAND)] + .map(([, name, version]) => ({ + name: BRAND_NAMES.get(name.trim().toLowerCase()) ?? name.trim(), + version, + })) + .filter((brand) => brand.name && !isGrease(brand.name)); + + return brands.find((brand) => brand.name !== "Chromium") ?? brands[0] ?? null; +} + +/** + * Major only. A full version string buckets one row per Chrome patch release + * and turns the browsers panel into a histogram of noise. + */ +function major(version: string | null | undefined): string | null { + return /^\d+/.exec(version?.trim() ?? "")?.[0] ?? null; +} + +/** + * Windows reports a platform version from a table Microsoft publishes rather + * than its own name: 13 and above is Windows 11, 1 through 12 is Windows 10, + * and 0.x is 7, 8 or 8.1 which the header genuinely cannot tell apart. Taking + * the major would file those readers under "Windows 15". + */ +function platformVersion(platform: string, version: string): string | null { + if (platform !== "Windows") { + return major(version); + } + + const reported = Number(major(version)); + + if (!Number.isFinite(reported) || reported < 1) { + return null; + } + + return reported >= 13 ? "11" : "10"; +} + +const unquote = (value: string | null) => + (value ?? "").trim().replace(/^"|"$/g, "").trim(); + +/** + * `?0` is not enough to say desktop: Chrome sends it on Android tablets, which + * would file every one of them as a desktop. A model is only ever populated on + * the form factors that have one, so it settles the same question for the + * tablets that do not run Android. + */ +function hintedDevice( + mobile: string, + platform: string, + model: string +): DeviceType | null { + if (mobile === "?1") { + return "mobile"; + } + + if (mobile !== "?0") { + return null; + } + + return platform === "Android" || model ? "tablet" : "desktop"; +} + +/** + * Only the fields the hints could actually answer, so the caller can tell "the + * browser did not say" from "the browser said nothing useful". + */ +export function parseClientHints(headers: Headers): Partial { + const hints: Partial = {}; + const brand = pickBrand(headers.get("sec-ch-ua")); + + if (brand) { + hints.browser = brand.name; + hints.browser_version = major(brand.version); + } + + const platform = unquote(headers.get("sec-ch-ua-platform")); + + if (platform && platform !== "Unknown") { + hints.os = platform; + hints.os_version = platformVersion( + platform, + unquote(headers.get("sec-ch-ua-platform-version")) + ); + } + + const device = hintedDevice( + (headers.get("sec-ch-ua-mobile") ?? "").trim(), + platform, + unquote(headers.get("sec-ch-ua-model")) + ); + + if (device) { + hints.device = device; + } + + return hints; +} + +/** + * ua-parser leaves the type undefined for desktops, and also reports console, + * smarttv, wearable, xr and embedded — none of which the device check + * constraint accepts. A television is not a desktop, so it is stored as unknown + * rather than folded into whichever of the three is closest. + */ +function deviceOf(type: string | undefined): DeviceType | null { + if (!type) { + return "desktop"; + } + + return type === "mobile" || type === "tablet" ? type : null; +} + +/** + * Names survive a missing version. The previous implementation dropped the + * whole dimension unless both halves were present, which silently loses more + * data every year as UA reduction freezes and hides version numbers. + */ +export function parseUserAgentString(ua: string | null | undefined): UserAgent { + const value = ua?.trim(); + + if (!value) { + return EMPTY; + } + + const result = new UAParser(value).getResult(); + + return { + browser: result.browser.name ?? null, + browser_version: major(result.browser.major ?? result.browser.version), + os: result.os.name ?? null, + os_version: major(result.os.version), + device: deviceOf(result.device.type), + }; +} + +/** + * These four are the only values in the whole insert that come from a header + * rather than from the request body, and the body's every string is byte-bound + * before it reaches a column. Nothing bound these: `sec-ch-ua` is a client-set + * header up to Node's ~16KB limit, and `pickBrand` handed the quoted brand + * straight through, so one request could write 16KB into the browsers panel and + * another 16KB into the OS panel, arbitrarily many distinct values each. + * + * Dropped rather than truncated — a 64-byte prefix of a 16KB brand is still a + * value nobody browses with, and truncating would file it as a real one. + */ +const NAME_BYTES = 64; + +const fits = (value: string | null) => + value !== null && Buffer.byteLength(value, "utf8") <= NAME_BYTES; + +/** + * A name with its version, or neither. Dropping the name has to drop the + * version with it: "139" beside no browser is a row every panel groups into the + * unknown bucket while still claiming a version was known. + */ +function named(name: string | null, version: string | null) { + if (!fits(name)) { + return { name: null, version: null }; + } + + return { name, version: fits(version) ? version : null }; +} + +/** + * Payload hints first, request headers second, user agent string last. + * + * Each name is taken with its own version rather than field by field: the hint + * list and the UA string disagree about Chromium forks — the hints say Opera + * 119 where the string says Chrome 133 — so a per-field merge would attach one + * browser's version to another browser's name. + * + * The OS version is the one field with three sources, because it is the one the + * headers structurally cannot answer. `Accept-CH` is only honoured on a + * top-level navigation response, which an ingest beacon never is, so + * `Sec-CH-UA-Platform-Version` does not arrive while the low-entropy + * `Sec-CH-UA-Platform` arrives unasked on every secure request — and UA + * reduction has meanwhile frozen the string's platform version, so the fallback + * is not merely thin but wrong: `Mac OS X 10_15_7` for every Chromium Mac + * forever, `Windows NT 10.0` for 10 and 11 alike, `Android 10` for every phone. + * + * `reported` is what the tracker read out of + * `navigator.userAgentData.getHighEntropyValues`, which needs no `Accept-CH`, + * no delegation and no navigation, and exists on exactly the browsers whose + * string is frozen. It arrives from the second pageview of a document onward + * and not the first — the promise resolves a task after the view it would ride + * on, and the tracker will not hold the view a fast bounce depends on for it — + * so the branch below that reads it is the *reduction* of the defect and not + * its removal: a one-pageview visit still lands on `fallback`, frozen value and + * all. It is passed through the same Microsoft table the header + * would have gone through, so "15.0.0" on Windows is the release 11 rather than + * a version 15 nobody ships — and it is paired with whichever source named the + * platform, since a browser cannot report one platform's version while running + * on another. + * + * Below it the previous rule stands unchanged: the two remaining sources cannot + * disagree about the platform the way they disagree about a Chromium fork, so + * when they name the same OS the string's version is the same OS's version. + */ +export function parseUserAgent( + headers: Headers, + reported?: string | null +): UserAgent { + const hints = parseClientHints(headers); + const ua = parseUserAgentString(headers.get("user-agent")); + + const browser = named( + hints.browser ?? ua.browser, + hints.browser ? (hints.browser_version ?? null) : ua.browser_version + ); + + const platform = hints.os ?? ua.os; + /** + * Whether the payload answered at all, which is not the same as the answer + * resolving to a release. A Windows platform version of 0.x means 7, 8 or + * 8.1, and the table above deliberately reduces it to null — falling through + * from there to the string would file a reader who is demonstrably not on + * Windows 10 under Windows 10, which is the whole defect being repaired. + */ + const answered = major(reported) !== null; + // Never `major()` on its own: on Windows the platform version is an index + // into a table Microsoft publishes rather than a release number, and taking + // the major of it files those readers under "Windows 15". + const reduced = platform ? platformVersion(platform, reported ?? "") : null; + const fallback = hints.os + ? (hints.os_version ?? (hints.os === ua.os ? ua.os_version : null)) + : ua.os_version; + + const os = named(platform, answered ? reduced : fallback); + + return { + browser: browser.name, + browser_version: browser.version, + os: os.name, + os_version: os.version, + device: hints.device ?? ua.device, + }; +} + +/** + * The breakpoints are the layout's own (Tailwind's sm, lg and 2xl), because the + * question this column answers is which layout the reader actually got — the + * user agent only ever claims a form factor and cannot tell a 13" laptop from a + * 32" monitor. + */ +export function screenClass( + width: number | null | undefined +): ScreenClass | null { + if (typeof width !== "number" || !Number.isFinite(width) || width <= 0) { + return null; + } + + if (width < 640) { + return "mobile"; + } + + if (width < 1024) { + return "tablet"; + } + + return width < 1536 ? "laptop" : "desktop"; +} + +/** + * Wrapped rather than imported at the call site so the ingest route holds one + * opinion about what a bot is, and so replacing the list later is a change to + * this file. A crawler's request is answered 204 and written nowhere: it is not + * a reader, and at the volume a well-indexed site attracts it is the difference + * between a traffic chart and a crawl log. + */ +export function isBot(ua: string | null | undefined): boolean { + return isbot(ua); +} diff --git a/apps/web/app/modules/ingest/visitor.server.ts b/apps/web/app/modules/ingest/visitor.server.ts new file mode 100644 index 00000000..498ed79d --- /dev/null +++ b/apps/web/app/modules/ingest/visitor.server.ts @@ -0,0 +1,187 @@ +import { createHash, createHmac } from "node:crypto"; +import { isIP } from "node:net"; + +/** + * Thirty minutes of inactivity ends a session. The old client-side timer used + * fifteen, which split a single reading session in two whenever someone left a + * tab to make coffee, and inflated both the session count and the bounce rate. + */ +export const SESSION_WINDOW_MS = 30 * 60_000; + +/** + * The salt is the whole privacy claim: with it a visitor id can be recomputed + * from an IP and a user agent, without it the ids are one-way. A deployment + * that booted with this literal in production would be pseudonymising with a + * value published on GitHub, so production refuses to start without its own. + * Dev and test still boot with no configuration at all. + */ +const DEV_SALT = "aurora-development-salt"; + +function resolveSalt() { + const configured = process.env.AURORA_SALT?.trim(); + + if (configured) { + return configured; + } + + if (process.env.NODE_ENV === "production") { + throw new Error( + "AURORA_SALT is required in production: without it visitor ids would be derivable by anyone." + ); + } + + return DEV_SALT; +} + +const salt = resolveSalt(); + +/** + * Which header carries the client's address, when the deployment knows. + * + * Every forwarding header is client-supplied until some hop overwrites it, so + * "which one do I believe" is a fact about the topology in front of this + * process and cannot be guessed from the request. A deployment that cares names + * its trusted hop here — `AURORA_IP_HEADER=cf-connecting-ip` — and nothing else + * is consulted. + */ +const CONFIGURED_HEADER = process.env.AURORA_IP_HEADER?.trim().toLowerCase(); + +/** + * Said out loud at boot, because it cannot be fixed from inside this file. + * + * Every header consulted below is client-supplied until a hop overwrites it, + * and whether a hop does is a fact about the topology that no request carries. + * So on a deployment with nothing in front of this process, a caller who knows + * only the public `wid` can put any address they like in `cf-connecting-ip`, + * `x-real-ip` or `x-forwarded-for` and get it believed — a fresh `visitor_id` + * and a fresh 240-token rate bucket per value, which inflates Daily Visitors, + * Sessions and Bounce rate at will and takes the limiter out of the picture + * entirely. Verified against a scratch database: five forged addresses produce + * five visitors and five sessions, all flagged new. + * + * Distrusting the two single-address headers and keeping XFF — the obvious + * half-measure — closes nothing: XFF alone reproduces it exactly, because with + * no proxy the caller owns the rightmost entry too. The only sound rule is to + * believe no header the operator has not named, and defaulting to that would + * silently collapse every proxied install that relies on the guess below into + * one visitor for the whole day. So the guess stays and the condition on it + * gets stated, once, where an operator will see it. + */ +if (!CONFIGURED_HEADER && process.env.NODE_ENV === "production") { + console.warn( + "aurora: AURORA_IP_HEADER is not set. Visitor identity and rate limiting fall back to guessing among cf-connecting-ip, x-real-ip and x-forwarded-for — all of which any client can set. If this process is not behind a proxy that overwrites one of them, set AURORA_IP_HEADER to that header, or treat the visitor, session and bounce figures as forgeable." + ); +} + +/** + * The guess when it does not. + * + * `cf-connecting-ip` and `x-real-ip` first, and X-Forwarded-For last, which is + * the opposite of the obvious order and the point of it: the first two are + * single-address headers an edge *overwrites*, while Cloudflare and nginx's + * `$proxy_add_x_forwarded_for` both *append* to whatever XFF the client sent. + * Reaching XFF first means a caller who sends their own shadows the one header + * that could have been trusted. + */ +const FALLBACK_HEADERS = ["cf-connecting-ip", "x-real-ip", "x-forwarded-for"]; + +/** + * The *rightmost* entry, not the leftmost. + * + * A proxy appends the address it saw itself, so the last element is the only + * one this process has any evidence for; the front of the list is whatever the + * caller typed. Single-hop — one nginx, one Cloudflare — makes that last entry + * the real client, and behind two appending hops it degrades to the inner + * proxy's address rather than to an attacker's choice. That is what + * AURORA_IP_HEADER exists to fix. + * + * Then parsed as an IP and dropped if it is not one: this value is an HMAC + * input and a rate-limit key, and neither may be an arbitrary-length string a + * caller picked. + */ +function trustedHop(value: string | null): string { + const last = value?.split(",").at(-1)?.trim() ?? ""; + + return isIP(last) ? last : ""; +} + +/** + * The address has exactly two legitimate uses — HMAC input below and the rate + * limit key — and it is never persisted, never logged and never in a response. + * Separate from visitorId only so those two rules can be tested. + * + * Empty rather than null when nothing forwards it: a deployment with no proxy + * still gets one stable id per user agent, instead of a fresh visitor per + * request. + */ +export function clientIp(headers: Headers): string { + if (CONFIGURED_HEADER) { + return trustedHop(headers.get(CONFIGURED_HEADER)); + } + + for (const header of FALLBACK_HEADERS) { + const hop = trustedHop(headers.get(header)); + + if (hop) { + return hop; + } + } + + return ""; +} + +/** + * Who the rate limiter is counting. + * + * The address when there is one. When there is not — no proxy in front, which + * is a supported deployment — the alternative to *some* key is one bucket for + * the whole process, and an attacker who drains it takes every site's ingest + * down with it. The user agent is the only other thing the request carries that + * a caller does not pick per-request for free, so it stands in: the blackout + * radius shrinks from "every visitor" to "every visitor on this browser build", + * which is the same granularity `visitorId` already degrades to. + * + * Hashed and truncated because the raw header is up to ~16KB and the key is + * held in a Map for two minutes. + */ +export function clientKey(headers: Headers): string { + const ip = clientIp(headers); + + if (ip) { + return ip; + } + + return `ua:${createHash("sha256") + .update(headers.get("user-agent") ?? "") + .digest("base64url") + .slice(0, 16)}`; +} + +/** + * A daily pseudonym, not a device id: the UTC date is part of the message, so + * every id rotates at midnight and yesterday's cannot be correlated with + * today's. That rotation is what makes the identifier consent-free, and it is + * also the dashboard's definition of a unique visitor — "first seen today". + * + * Scoped by website so the same reader on two sites hosted by the same instance + * is two unrelated visitors. 22 base64url characters is 132 bits, which is far + * past collision range for a day of one site's traffic and shorter than the + * full digest the index would otherwise carry. + */ +export function visitorId( + headers: Headers, + websiteId: string, + at: Date = new Date() +): string { + const message = [ + at.toISOString().slice(0, 10), + websiteId, + clientIp(headers), + headers.get("user-agent") ?? "", + ].join(":"); + + return createHmac("sha256", salt) + .update(message) + .digest("base64url") + .slice(0, 22); +} diff --git a/apps/web/app/modules/websites/__tests__/queries.test.ts b/apps/web/app/modules/websites/__tests__/queries.test.ts new file mode 100644 index 00000000..8db00a8b --- /dev/null +++ b/apps/web/app/modules/websites/__tests__/queries.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getUserWebsitesOverview, OVERVIEW_DAYS } from "../queries.server"; + +const { answers, statements } = vi.hoisted(() => ({ + /** SQL fragment identifying a query ⇒ the rows Postgres would answer with. */ + answers: new Map(), + statements: [] as { text: string; params: unknown[] }[], +})); + +/** + * The connection pool is the seam, not the query layer — the same arrangement + * the analytics suite uses, and deliberately duplicated rather than shared: + * `vi.mock` is hoisted per file, so a helper module would have to be imported + * before the module under test to avoid a TDZ error, which is a load-bearing + * import order nothing in the file would explain. + * + * `types` has to stay the real export — drizzle's node-postgres driver reads + * `pg.types.builtins` while building the type parsers for every query. + */ +vi.mock("pg", async (importOriginal) => { + const actual = await importOriginal(); + + class RecordingPool { + /** db.server installs an idle-client error handler on the pool. */ + on() {} + + query(config: { text: string }, params: unknown[] = []) { + statements.push({ text: config.text, params }); + + for (const [fragment, rows] of answers) { + if (config.text.includes(fragment)) { + return Promise.resolve({ rows }); + } + } + + return Promise.resolve({ rows: [] }); + } + } + + const Pool = RecordingPool as unknown as typeof actual.Pool; + + return { ...actual, default: { ...actual, Pool }, Pool }; +}); + +beforeEach(() => { + statements.length = 0; + answers.clear(); +}); + +const answer = (fragment: string, rows: unknown[]) => + answers.set(fragment, rows); + +/** + * A `created_at` bound in a statement's WHERE clause, resolved through the + * parameter it is bound to — the bound is what the window's length *is*, and + * reading it positionally out of the parameter list would depend on where else + * in the statement a timestamp happens to appear. + */ +function boundAt( + statement: { text: string; params: unknown[] } | undefined, + op: string +) { + const at = new RegExp(`"events"\\."created_at" ${op} \\$(\\d+)`).exec( + statement?.text ?? "" + ); + + return at ? Date.parse(String(statement?.params[Number(at[1]) - 1])) : NaN; +} + +/** + * The websites index says "last {days} days" over its figures, and it was six + * whole UTC days plus however much of the current one had elapsed: 145 hours at + * 01:00 UTC against a label that claims 168, and a different quantity from the + * dashboard's "Last 7 days" preset, so clicking a row led to a Pageviews tile + * that disagreed with the row it was clicked from. + */ +describe("websites overview", () => { + // Positional, because drizzle's query builder asks for rowMode: "array" — + // id, name, url, is_public, user_id, created_at, updated_at. + const site = [ + "w1", + "A", + "https://a.dev", + false, + "u1", + new Date(), + new Date(), + ]; + + const overviewStatement = () => + statements.find((statement) => statement.text.includes("grouping sets")); + + it("bounds the window at both ends, seven whole days apart", async () => { + answer(`from "websites"`, [site]); + + await getUserWebsitesOverview("u1"); + + const statement = overviewStatement(); + + expect(statement).toBeDefined(); + + // There was no upper bound at all, which is what let the window's length + // follow the clock instead of the label: 145 hours at 01:00 UTC, 168 only + // in the last second before midnight. + expect(boundAt(statement, ">=")).toBeLessThan(boundAt(statement, "<")); + expect(boundAt(statement, "<") - boundAt(statement, ">=")).toBe( + OVERVIEW_DAYS * 86_400_000 + ); + }); + + it("buckets in whole 24 hour steps back from the window's own end", async () => { + answer(`from "websites"`, [site]); + + await getUserWebsitesOverview("u1"); + + // Not date_trunc: a UTC-day bucketing of a rolling window makes the newest + // bar a part-day stub, which draws a fall in traffic that did not happen. + expect(overviewStatement()?.text).toContain("/ 86400"); + expect(overviewStatement()?.text).not.toContain("date_trunc"); + }); + + it("takes the totals from the grouping rather than adding the buckets up", async () => { + answer(`from "websites"`, [site]); + answer("grouping sets", [ + { + website_id: "w1", + bucket: 0, + is_total: 0, + views: 3, + visitors: 3, + last: null, + }, + { + website_id: "w1", + bucket: 6, + is_total: 0, + views: 1, + visitors: 1, + last: null, + }, + { + website_id: "w1", + bucket: null, + is_total: 1, + views: 4, + // Deliberately below the sum of the buckets: a visitor_id is unique to + // a UTC date and a rolling bucket boundary falls inside one, so the + // same reader can appear in two buckets. Adding the per-bucket distinct + // counts would count them twice; the window-wide count is exact. + visitors: 3, + last: "2026-08-04T09:00:00.000Z", + }, + ]); + + const [overview] = await getUserWebsitesOverview("u1"); + + expect(overview.views).toBe(4); + expect(overview.visitors).toBe(3); + expect(overview.spark).toEqual([1, 0, 0, 0, 0, 0, 3]); + expect(overview.spark.reduce((a, b) => a + b, 0)).toBe(overview.views); + expect(overview.lastEventAt?.toISOString()).toBe( + "2026-08-04T09:00:00.000Z" + ); + }); + + it("gives a site with no traffic a full row of empty buckets", async () => { + answer(`from "websites"`, [site]); + + const [overview] = await getUserWebsitesOverview("u1"); + + expect(overview.spark).toHaveLength(OVERVIEW_DAYS); + expect(overview.spark.every((value) => value === 0)).toBe(true); + expect(overview.views).toBe(0); + expect(overview.lastEventAt).toBeNull(); + }); +}); diff --git a/apps/web/app/modules/websites/components/add-website-sheet.tsx b/apps/web/app/modules/websites/components/add-website-sheet.tsx new file mode 100644 index 00000000..d30ad962 --- /dev/null +++ b/apps/web/app/modules/websites/components/add-website-sheet.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import { useFetcher } from "react-router"; +import { Alert, AlertDescription } from "~/shared/ui/alert"; +import { Button } from "~/shared/ui/button"; +import { + Field, + FieldContent, + FieldDescription, + FieldLabel, +} from "~/shared/ui/field"; +import { Input } from "~/shared/ui/input"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "~/shared/ui/sheet"; +import { Spinner } from "~/shared/ui/spinner"; +import { Switch } from "~/shared/ui/switch"; + +/** + * Adding a site is three fields, which never justified a whole page. + * + * It posts to the /websites/new action with a fetcher rather than navigating, + * so the panel can show its own validation error without the surrounding page + * changing. The action redirects on success, which React Router follows and + * which unmounts the panel — no manual close needed for the happy path. + */ +export function AddWebsiteSheet({ trigger }: { trigger: React.ReactElement }) { + const [open, setOpen] = useState(false); + const fetcher = useFetcher<{ error?: string }>(); + + const isSubmitting = fetcher.state !== "idle"; + const error = fetcher.data?.error; + const wasSubmitting = useRef(false); + + /** + * A successful create answers with a redirect, so there is nothing in + * `fetcher.data` to react to — the only signal is the submission finishing + * without an error. A rejected one leaves the panel open so the message is + * readable. + */ + useEffect(() => { + if (fetcher.state === "submitting") { + wasSubmitting.current = true; + + return; + } + + if (fetcher.state === "idle" && wasSubmitting.current) { + wasSubmitting.current = false; + + if (!fetcher.data?.error) { + setOpen(false); + } + } + }, [fetcher.state, fetcher.data]); + + return ( + + + + + + Add website + + Aurora starts collecting as soon as the snippet is live. + + + + + + Website name + + + + + Website URL + + + + + + + Share statistics + + + Publishes a read-only copy of this dashboard at a public link. + Turn it off at any time. + + + + + + {error && ( + + {error} + + )} + + + + + + + + + ); +} diff --git a/apps/web/app/modules/websites/components/website-form.tsx b/apps/web/app/modules/websites/components/website-form.tsx new file mode 100644 index 00000000..510a1d86 --- /dev/null +++ b/apps/web/app/modules/websites/components/website-form.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { Form, useNavigation } from "react-router"; +import { CopyIcon, TriangleAlertIcon } from "lucide-react"; +import { toast } from "sonner"; +import { Alert, AlertTitle } from "~/shared/ui/alert"; +import { Button } from "~/shared/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/shared/ui/card"; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldTitle, +} from "~/shared/ui/field"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "~/shared/ui/input-group"; +import { Input } from "~/shared/ui/input"; +import { Spinner } from "~/shared/ui/spinner"; +import { Switch } from "~/shared/ui/switch"; +import { z } from "zod"; + +/** + * Lives with the form it validates — the create and edit routes both submit + * these exact fields, so keeping one schema here avoids the two drifting. + */ +export const websiteSchema = z.object({ + name: z.string().min(1, "Name is required"), + url: z.string().min(1, "URL is required"), + is_public: z.boolean(), +}); + +export type WebsiteFormValues = { + name?: string; + url?: string; + is_public?: boolean; +}; + +// Stable reference so the default doesn't change identity on every render. +const NO_VALUES: WebsiteFormValues = {}; + +function copyToClipboard(text: string, message: string) { + navigator.clipboard.writeText(text).then( + () => toast.success(message), + () => toast.error("Clipboard is blocked. Select the text and copy it.") + ); +} + +export function WebsiteForm({ + isNew = false, + values = NO_VALUES, + error, + shareLink, + snippet, +}: { + isNew?: boolean; + values?: WebsiteFormValues; + error?: string; + shareLink?: string; + snippet?: string; +}) { + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + // Mirrors the uncontrolled switch so the share link appears the moment it is + // turned on, without waiting for a save round-trip. + const [isPublic, setIsPublic] = useState(values.is_public ?? false); + + return ( +
+ {error && ( + + + {error} + + )} + + + + Details + + + + + Website name + + + + + Website URL + + + + + + Share statistics + + Publishes a read-only copy of this dashboard at a public link. + Turn it off at any time. + + + + + + + + + {!isNew && ( + + + Install + + + + + Tracking snippet +
+
+                    {snippet}
+                  
+ +
+ + Paste this in the <head> of every page you want to + track. + +
+ + {isPublic && shareLink && ( + + Public link + + + + + copyToClipboard(shareLink, "Link copied") + } + > + + + + + + )} +
+
+
+ )} + +
+ +
+ + ); +} diff --git a/apps/web/app/modules/websites/queries.server.ts b/apps/web/app/modules/websites/queries.server.ts new file mode 100644 index 00000000..8fb9e428 --- /dev/null +++ b/apps/web/app/modules/websites/queries.server.ts @@ -0,0 +1,207 @@ +import { events, websites, type Website } from "~/db/schema"; +import { and, asc, eq, gte, inArray, lt, sql } from "drizzle-orm"; +import { db } from "~/shared/lib/db.server"; + +export function getUserWebsites(uid: string) { + return db + .select() + .from(websites) + .where(eq(websites.user_id, uid)) + .orderBy(asc(websites.created_at)); +} + +/** Days of history behind each site's row on the websites index. */ +export const OVERVIEW_DAYS = 7; + +const DAY_MS = 86_400_000; + +export type WebsiteOverview = Website & { + views: number; + visitors: number; + /** + * One bucket per 24 hours, oldest first, the last of them ending now. Always + * OVERVIEW_DAYS long, and they sum to `views`. + */ + spark: number[]; + lastEventAt: Date | null; +}; + +/** + * The websites index used to be a plain list, which meant the first screen of + * an analytics product carried no numbers. This attaches a week of pageviews to + * every site in one grouped query. + * + * The window is exactly OVERVIEW_DAYS x 24 hours ending now, because that is + * what the page says it is. It was "midnight UTC of (today − 6), with no upper + * bound": six whole UTC days plus however much of the current one had elapsed, + * which is 145 hours at 01:00 UTC and 168 only in the last second before + * midnight — the figures under "Pageviews" and "Daily visitors" were up to 14% + * short of the label above them, worst first thing in the UTC morning. It was + * also a different quantity from the dashboard's "Last 7 days", so clicking a + * row led to a Pageviews tile that disagreed with the row it was clicked from + * with nothing on either page to explain it. Both are now the same rolling + * 168 hours. + * + * The buckets follow the window rather than the calendar: bucket 0 is the last + * 24 hours, bucket 6 the 24 before the other six. That keeps every bar the same + * width — a UTC-day bucketing of a rolling window would have made the newest + * bar a part-day stub and drawn a fall in traffic that had not happened — and it + * means the sparkline sums to the figure printed beside it. It still takes no + * timezone, which is the one thing the UTC-day bucketing had going for it: a + * shape-at-a-glance sparkline is not a figure anyone reads off an axis. + * + * One scan, two groupings. GROUPING SETS gives the per-bucket counts and the + * per-site totals from the same pass; the totals cannot be added up from the + * buckets because `visitors` is a distinct count and a rolling bucket boundary + * falls inside a UTC day — the same visitor_id can appear in two buckets, and + * summing them would count that reader twice. Over the window itself the count + * is exact and needs no correction: visitor_id is an HMAC over the UTC date, so + * one person is a different id each day and no id spans two of them. Which is + * also to say that this figure, like the dashboard's, is visitor-*days* and not + * an audience — a reader who came every morning is seven of them. + */ +export async function getUserWebsitesOverview( + uid: string +): Promise { + const sites = await getUserWebsites(uid); + + if (sites.length === 0) { + return []; + } + + const now = new Date(); + const since = new Date(now.getTime() - OVERVIEW_DAYS * DAY_MS); + + const scope = and( + inArray( + events.website_id, + sites.map((site) => site.id) + ), + eq(events.type, "pageview"), + gte(events.created_at, since), + lt(events.created_at, now) + ); + + const result = await db.execute<{ + website_id: string; + bucket: number | null; + is_total: number; + views: number; + visitors: number; + last: string | Date | null; + }>(sql` + with scoped as ( + select + ${events.website_id} as website_id, + ${events.visitor_id} as visitor_id, + ${events.created_at} as created_at, + -- Whole 24-hour steps back from the same instant the window was cut at, + -- so bucket 0 ends exactly where the window does. \`least\` catches the + -- single event that can land on the inclusive lower bound and index one + -- past the end, which would drop it from the sparkline while leaving it + -- in the total the sparkline is supposed to add up to. + least( + floor( + extract(epoch from (${now}::timestamptz - ${events.created_at})) / 86400 + )::int, + ${OVERVIEW_DAYS - 1} + ) as bucket + from ${events} + where ${scope} + ) + select + website_id, + bucket, + -- Which of the two groupings a row came from, asked of Postgres rather + -- than inferred from a null bucket: the expression is not nullable, but + -- reading the total row off "bucket is null" would be a claim about that + -- rather than about the grouping. + grouping(bucket)::int as is_total, + count(*)::int as views, + count(distinct visitor_id)::int as visitors, + max(created_at) as last + from scoped + group by grouping sets ((website_id, bucket), (website_id)) + `); + + const byWebsite = new Map< + string, + { views: number; visitors: number; last: Date | null; spark: number[] } + >(); + + for (const row of result.rows) { + const entry = byWebsite.get(row.website_id) ?? { + views: 0, + visitors: 0, + last: null, + spark: Array.from({ length: OVERVIEW_DAYS }, () => 0), + }; + + if (row.is_total) { + entry.views = Number(row.views); + entry.visitors = Number(row.visitors); + entry.last = row.last ? new Date(row.last) : null; + } else if (row.bucket !== null) { + // Oldest first, so the sparkline reads left to right into the present. + entry.spark[OVERVIEW_DAYS - 1 - row.bucket] = Number(row.views); + } + + byWebsite.set(row.website_id, entry); + } + + return sites.map((site) => { + const entry = byWebsite.get(site.id); + + return { + ...site, + views: entry?.views ?? 0, + visitors: entry?.visitors ?? 0, + lastEventAt: entry?.last ?? null, + spark: entry?.spark ?? Array.from({ length: OVERVIEW_DAYS }, () => 0), + }; + }); +} + +export async function getWebsite(wid: string) { + const [website] = await db + .select() + .from(websites) + .where(eq(websites.id, wid)) + .limit(1); + + return website ?? null; +} + +export async function createWebsite(data: { + name: string; + url: string; + is_public: boolean; + user_id: string; +}) { + const [website] = await db.insert(websites).values(data).returning(); + + return website; +} + +export async function updateWebsite( + wid: string, + data: Partial<{ name: string; url: string; is_public: boolean }> +) { + const [website] = await db + .update(websites) + .set(data) + .where(eq(websites.id, wid)) + .returning(); + + return website; +} + +/** Events cascade via the schema's foreign key. */ +export async function deleteWebsite(wid: string) { + const [website] = await db + .delete(websites) + .where(eq(websites.id, wid)) + .returning(); + + return website; +} diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx new file mode 100644 index 00000000..a0be046b --- /dev/null +++ b/apps/web/app/root.tsx @@ -0,0 +1,109 @@ +import { + isRouteErrorResponse, + Link, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +import { Logo } from "~/shared/components/logo"; +import { Button } from "~/shared/ui/button"; +import { Toaster } from "~/shared/ui/sonner"; +import { TooltipProvider } from "~/shared/ui/tooltip"; + +import type { Route } from "./+types/root"; +import "./app.css"; + +export const meta: Route.MetaFunction = () => [ + { title: "Aurora" }, + { + name: "description", + content: "Aurora — 100% cookie-free open website analytics.", + }, +]; + +/** + * Applies the stored theme before first paint so there is no flash. Kept out of + * React so the server-rendered markup and the hydrated tree stay identical. + */ +const themeScript = ` +try { + var stored = localStorage.getItem("aurora-theme"); + var dark = stored ? stored === "dark" + : window.matchMedia("(prefers-color-scheme: dark)").matches; + if (dark) document.documentElement.classList.add("dark"); +} catch (e) {} +`; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + `, + }; +} + +export async function action({ request, params }: Route.ActionArgs) { + const user = await requireUser(request); + await requireWebsiteOwner(user.id, params.id); + + const formData = await request.formData(); + + // Ownership is re-checked above, so delete can no longer remove another + // user's website the way the old DELETE /websites/:id endpoint allowed. + if (formData.get("intent") === "delete") { + await deleteWebsite(params.id); + + return redirect("/"); + } + + const parsed = websiteSchema.safeParse({ + name: formData.get("name"), + url: formData.get("url"), + is_public: formData.get("is_public") === "on", + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0].message }; + } + + await updateWebsite(params.id, parsed.data); + + return { ok: true }; +} + +export default function EditWebsite({ + loaderData, + actionData, +}: Route.ComponentProps) { + const { website, shareLink, snippet } = loaderData; + const navigation = useNavigation(); + const isDeleting = navigation.formData?.get("intent") === "delete"; + + useEffect(() => { + if (actionData && "ok" in actionData) { + toast.success("Changes saved"); + } + }, [actionData]); + + return ( + + + + {website.name} + {website.url} + + + + + + + + + {/* Card's default ring is swapped for a destructive border, not stacked with it. */} + + + Danger zone + + Deleting {website.name} removes every pageview recorded for it. This + cannot be undone. + + + + + }> + Delete website + + + + Delete website + + {website.name} and every pageview recorded for it will be + deleted permanently. + + + + Cancel +
+ + + {isDeleting && } + Delete + + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/routes/websites.new.tsx b/apps/web/app/routes/websites.new.tsx new file mode 100644 index 00000000..277bdcfd --- /dev/null +++ b/apps/web/app/routes/websites.new.tsx @@ -0,0 +1,35 @@ +import { redirect } from "react-router"; +import { websiteSchema } from "~/modules/websites/components/website-form"; +import { createWebsite } from "~/modules/websites/queries.server"; +import { requireUser } from "~/modules/auth/session.server"; +import type { Route } from "./+types/websites.new"; + +/** + * Action-only route: adding a site is three fields and now happens in the + * panel, which posts here with a fetcher. There is no page + * left to render, so a direct visit goes back to the list. + */ +export async function loader({ request }: Route.LoaderArgs) { + await requireUser(request); + + return redirect("/"); +} + +export async function action({ request }: Route.ActionArgs) { + const user = await requireUser(request); + const formData = await request.formData(); + + const parsed = websiteSchema.safeParse({ + name: formData.get("name"), + url: formData.get("url"), + is_public: formData.get("is_public") === "on", + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0].message }; + } + + await createWebsite({ ...parsed.data, user_id: user.id }); + + return redirect("/"); +} diff --git a/apps/web/app/routes/websites.tsx b/apps/web/app/routes/websites.tsx new file mode 100644 index 00000000..69c93091 --- /dev/null +++ b/apps/web/app/routes/websites.tsx @@ -0,0 +1,257 @@ +import { Globe, Lock, MoreHorizontal, Plus } from "lucide-react"; +import { Link } from "react-router"; +import { AddWebsiteSheet } from "~/modules/websites/components/add-website-sheet"; +import { + DAILY_VISITORS_HINT, + MetricHint, +} from "~/shared/components/metric-hint"; +import { + Page, + PageActions, + PageDescription, + PageHeader, + PageHeading, + PageTitle, +} from "~/shared/components/page-header"; +import { Sparkline } from "~/shared/components/sparkline"; +import { Badge } from "~/shared/ui/badge"; +import { Button } from "~/shared/ui/button"; +import { Card } from "~/shared/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/shared/ui/dropdown-menu"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/shared/ui/empty"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/shared/ui/table"; +import { + formatCompactNumber, + formatNumber, + initials, +} from "~/shared/lib/format"; +import { + getUserWebsitesOverview, + OVERVIEW_DAYS, +} from "~/modules/websites/queries.server"; +import { requireUser } from "~/modules/auth/session.server"; +import type { Route } from "./+types/websites"; + +export const meta = () => [{ title: "Websites — Aurora" }]; + +const RECEIVING_WINDOW = 24 * 60 * 60 * 1000; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await requireUser(request); + const sites = await getUserWebsitesOverview(user.id); + const now = Date.now(); + + return { + days: OVERVIEW_DAYS, + websites: sites.map((site) => ({ + id: site.id, + name: site.name, + url: site.url, + is_public: site.is_public, + views: site.views, + visitors: site.visitors, + spark: site.spark, + // Deciding this against the clock during render would make the server + // markup and the hydrated markup disagree, so it is resolved once here. + receiving: + site.lastEventAt !== null && + now - site.lastEventAt.getTime() < RECEIVING_WINDOW, + })), + }; +} + +export default function Websites({ loaderData }: Route.ComponentProps) { + const { days, websites } = loaderData; + + return ( + + + + Websites + + {/* "every site on this instance" was a claim about the database + and the loader makes one about the session: the overview is + `getUserWebsitesOverview(user.id)`, restricted to + `websites.user_id`. A stock install has one user, so the two + populations coincide today and the sentence would start + under-reporting silently the moment a second one exists. */} + Traffic across your sites, last {days} days. + + + + + + Add website + + } + /> + + + + {websites.length === 0 ? ( + + + + + + No websites yet + + Add a website to start collecting pageviews. Aurora sets no + cookies and needs no consent banner. + + + + + + Add website + + } + /> + + + ) : ( + +
` between them, which left a screen reader announcing "/pricing 500 300" + * with no way to tell which figure was which. + * + * `count` is the caller's word for what the middle column holds, because the + * panels no longer all hold the same thing: the acquisition dimensions count + * sessions, the rest count views, and the goals list counts events. + * + * The visitor column is named for what it counts. `unique` in the wire shape is + * a distinct count of an identifier that rotates at midnight, so over a week it + * is seven days of visitors added together — "Visitors" alone invited every + * reader to take it for an audience. + * + * The hint's accessible name is built from `label` rather than being the bare + * metric. This component is rendered once per table and there are fourteen of + * them on a populated dashboard, so a fixed `about="Daily visitors"` put + * fourteen identically-named buttons in a screen reader's rotor — the exact + * defect MetricHint's `about` exists to remove, reintroduced by the one hint + * that is drawn in a loop. `label` is the tab's own noun (Channel, Referrer, + * Source, Medium, Term, Goal…) and no two tables on the dashboard share one, so + * naming the trigger after it is enough to tell all fourteen apart. + */ +export function PanelColumns({ + label, + count, +}: { + label: string; + count: string; +}) { + return ( +
+ {label} + + {count} + + {/* Nowrap and a column wide enough to hold it: "Daily visitors" is + two words and was breaking across two lines against the one-word + header beside it, which read as a layout fault rather than a + label. */} + + Daily visitors + + {DAILY_VISITORS_HINT} + + +
`. + * + * Every panel on the dashboard is a `
` with a + * `
` inside it, so the title is not programmatically + * attached to anything: a screen reader's table list showed seven unnamed + * tables in reading order, and the multi-tab panels were only distinguishable + * because Base UI's tabpanel is `aria-labelledby` its trigger. A caption is the + * element that names a table, and it is visually hidden here because the title + * is already on screen — this adds a name for the tables, not a second heading + * for the sighted reader. + */ +export function PanelCaption({ children }: { children: React.ReactNode }) { + return
{children}
+ + + Site + {days} days + Pageviews + {/* Same figure and same caveat as the dashboard tile: the id + behind it rotates at midnight, so a week of it is seven + daily counts added up. */} + + + Daily visitors + + {DAILY_VISITORS_HINT} + + + + Status + + Actions + + + + + {websites.map((website) => ( + + +
+ {/* Stands in for a site logo — Aurora will not fetch a + favicon from a third party to get one. */} + + +
+ + {website.name} + + + {website.url} + +
+
+
+ + {/* A flat line and a row of zeros reads as "nothing yet" + only if you already know the site is live; say it. */} + {website.receiving || website.views > 0 ? ( + + ) : ( + + Waiting for data + + )} + + + {formatCompactNumber(website.views)} + + + {formatCompactNumber(website.visitors)} + + + {website.is_public ? ( + + + Public + + ) : ( + + + Private + + )} + + + + + } + > + + + + + } + > + View analytics + + } + > + Settings + + + + +
+ ))} +
+
+ + )} + + ); +} diff --git a/apps/web/app/shared/components/logo.tsx b/apps/web/app/shared/components/logo.tsx new file mode 100644 index 00000000..13b23697 --- /dev/null +++ b/apps/web/app/shared/components/logo.tsx @@ -0,0 +1,28 @@ +import { cn } from "~/shared/lib/utils"; + +type LogoProps = React.SVGProps; + +export function Logo({ className, ...props }: LogoProps) { + return ( + + {/* Equilateral: side 18, height 18 * sqrt(3) / 2 = 15.588, centred in the box. */} + + {/* Aurora streak, anchored on both slopes. */} + + + ); +} diff --git a/apps/web/app/shared/components/metric-hint.tsx b/apps/web/app/shared/components/metric-hint.tsx new file mode 100644 index 00000000..286e5875 --- /dev/null +++ b/apps/web/app/shared/components/metric-hint.tsx @@ -0,0 +1,77 @@ +import { InfoIcon } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "~/shared/ui/popover"; +import { cn } from "~/shared/lib/utils"; + +/** + * Why every visitor figure on the dashboard is a count of visitor-*days*. + * + * `visitor_id` is an HMAC whose message starts with the UTC date, so the same + * person is a different id tomorrow and no id survives midnight. The count + * therefore grows with the length of the window rather than with the audience, + * and "unique visitors" would be read as the opposite. Stated wherever the + * number appears rather than once in a footnote, because every place it appears + * is a place someone could take it for an audience. + * + * The word "UTC" is load-bearing and was missing. The dashboard has a timezone + * picker and groups every chart bucket by `created_at AT TIME ZONE tz`, so "a + * day" on screen is the viewer's day — while the identifier's day is always + * UTC's. For a reader charting Asia/Tokyo the two split at 09:00 local, and the + * worked example below is off by a count for anyone outside UTC unless it says + * which day it means. + * + * So is the sentence about the window, which was also missing and is the larger + * error of the two. "Counted once per UTC day" is true of the *identifier* and + * was being read as a statement about the *figure*. Every preset resolves to + * `now - days x 86_400_000 -> now` (`resolveWindow` in analytics.server.ts), a + * rolling window anchored on the request instant — so it never lines up with a + * UTC day and always crosses at least one UTC midnight. On the default "Last 24 + * hours" that means anyone who read a page on both sides of 00:00 UTC is two + * rows in the `count(DISTINCT visitor_id)`, and how far the tile overstates is a + * function of what time of day the dashboard was opened. Nothing on screen can + * attribute that drift, so the hint has to. + */ +export const DAILY_VISITORS_HINT = + "A total of daily uniques over the range, not a headcount and not a per-day rate. Counted once per UTC day: no identifier outlives the UTC day it was made, which is what lets Aurora measure this without cookies — so someone who visits every day for a week counts seven times. The range is a rolling window ending now rather than a run of whole UTC days, so it always crosses a midnight UTC and anyone who read a page on both sides of one is counted twice — on a 24-hour range that can be most of them. The chart's days are the zone you picked; this figure's are not."; + +/** + * The affordance those explanations hang off, so they all look and read alike. + * + * A Popover rather than a Tooltip. Base UI's tooltip trigger registers exactly + * two interactions — hover with `mouseOnly: true`, and focus that bails unless + * the target matches `:focus-visible` — so tapping one on a phone opens + * nothing. Seventeen of these are drawn on a populated dashboard, and every + * explanation the dashboard owes its reader was unreachable on touch. The + * popover keeps the hover behaviour on a pointer (`openOnHover`) and adds the + * press that makes it work everywhere else. + * + * `about` names the metric. All seventeen buttons previously answered to the + * same "How this is measured", so a screen reader's button rotor listed + * seventeen identical entries and the only way to tell them apart was reading + * order. + */ +export function MetricHint({ + about, + children, + className, +}: { + about: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + + + + {children} + + ); +} diff --git a/apps/web/app/shared/components/page-header.tsx b/apps/web/app/shared/components/page-header.tsx new file mode 100644 index 00000000..c943da56 --- /dev/null +++ b/apps/web/app/shared/components/page-header.tsx @@ -0,0 +1,45 @@ +import { cn } from "~/shared/lib/utils"; + +export function Page({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + // min-w-0 keeps wide children (tables, charts) inside their own scroll + // container instead of widening the page. + return ( +
+ {children} +
+ ); +} + +export function PageHeader({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** + * Groups the title with its description so `PageHeader` stays a two-column + * row: heading on one side, actions on the other. + */ +export function PageHeading({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function PageTitle({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +export function PageDescription({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +export function PageActions({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/web/app/shared/components/sparkline.tsx b/apps/web/app/shared/components/sparkline.tsx new file mode 100644 index 00000000..2d525ba0 --- /dev/null +++ b/apps/web/app/shared/components/sparkline.tsx @@ -0,0 +1,75 @@ +import { cn } from "~/shared/lib/utils"; + +const WIDTH = 72; +const HEIGHT = 24; + +// Half of the stroke sits outside the path, so the extremes need a little room +// or the peak and the baseline are both shaved off by the viewBox edge. +const TOP = 1; +const BASE = HEIGHT - 1; + +type Point = { x: number; y: number }; + +function plot(data: number[]): Point[] { + // A single reading has no shape to draw; fall back to the empty baseline. + if (data.length < 2) { + return [ + { x: 0, y: BASE }, + { x: WIDTH, y: BASE }, + ]; + } + + const max = Math.max(...data); + + return data.map((value, index) => ({ + x: (index / (data.length - 1)) * WIDTH, + // A week with no traffic has no scale to normalise against, and reads + // better as a line resting on the floor than as a divide-by-zero. + y: max > 0 ? BASE - (value / max) * (BASE - TOP) : BASE, + })); +} + +const at = (point: Point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`; + +/** + * Shape-at-a-glance trend line. Decorative by design: the figures next to it + * carry the actual values, so it is hidden from assistive tech. + */ +export function Sparkline({ + data, + className, +}: { + data: number[]; + className?: string; +}) { + const points = plot(data); + const line = points.map(at).join(" "); + + return ( + + ); +} diff --git a/apps/web/app/shared/hooks/use-mobile.ts b/apps/web/app/shared/hooks/use-mobile.ts new file mode 100644 index 00000000..48fab93c --- /dev/null +++ b/apps/web/app/shared/hooks/use-mobile.ts @@ -0,0 +1,21 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState( + undefined + ); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/apps/web/app/shared/hooks/use-theme.ts b/apps/web/app/shared/hooks/use-theme.ts new file mode 100644 index 00000000..852c560f --- /dev/null +++ b/apps/web/app/shared/hooks/use-theme.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from "react"; + +export const THEME_STORAGE_KEY = "aurora-theme"; + +export type Theme = "light" | "dark"; + +/** + * The `dark` class is applied by the inline script in root.tsx before first + * paint, so the class on is the source of truth and this hook only + * reflects and flips it. `mounted` stays false through the server render, + * which lets callers hold back anything that would otherwise hydrate with the + * wrong icon. + * + * The preference lives in localStorage rather than a cookie — Aurora ships + * with no cookies at all, and the UI shouldn't be the thing that breaks that. + */ +export function useTheme() { + const [theme, setThemeState] = useState("light"); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const root = document.documentElement; + const sync = () => + setThemeState(root.classList.contains("dark") ? "dark" : "light"); + + sync(); + setMounted(true); + + // Other tabs and the toggle both write the class; watching it keeps every + // consumer of this hook in step without a shared store. + const observer = new MutationObserver(sync); + + observer.observe(root, { attributes: true, attributeFilter: ["class"] }); + + return () => observer.disconnect(); + }, []); + + const setTheme = useCallback((next: Theme) => { + document.documentElement.classList.toggle("dark", next === "dark"); + + try { + localStorage.setItem(THEME_STORAGE_KEY, next); + } catch { + // Private-mode storage failures shouldn't stop the theme from changing. + } + + setThemeState(next); + }, []); + + const toggleTheme = useCallback( + () => setTheme(theme === "dark" ? "light" : "dark"), + [theme, setTheme] + ); + + return { theme, setTheme, toggleTheme, mounted }; +} diff --git a/apps/web/app/shared/lib/__tests__/format.test.ts b/apps/web/app/shared/lib/__tests__/format.test.ts new file mode 100644 index 00000000..aa3bbb18 --- /dev/null +++ b/apps/web/app/shared/lib/__tests__/format.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import { + countryFlag, + durationChange, + formatChannel, + formatCountry, + formatDateRange, + formatDuration, + formatMoney, + formatReferrer, + NO_DATA, + pointChange, + trend, +} from "../format"; +// Cross-module on purpose: formatDateRange takes a zone, so the expected +// boundaries have to come from the module that defines what a zoned day is. +import { + endOfZonedDayExclusive, + startOfZonedDay, +} from "~/modules/analytics/timezone"; + +describe("formatDuration", () => { + it("tells a window that measured nothing from one that measured zero", () => { + // The whole point of widening Statistics.avgDuration: an install whose + // duration beacons never arrive used to read a confident "0s". + expect(formatDuration(null)).toBe(NO_DATA); + expect(formatDuration(0)).toBe("0s"); + }); + + it.each([1, 400, 499])( + "keeps a measured %ims from reading as the zero it is not", + (ms) => { + // Rounding to whole seconds put a real sub-second average into the exact + // string this module reserves for "never measured is not zero" — a site + // of instant bounces was indistinguishable from one with no beacons. + expect(formatDuration(ms)).toBe("<1s"); + } + ); + + it.each([ + [500, "1s"], + [48_000, "48s"], + [187_000, "3m 07s"], + [3_840_000, "1h 04m"], + ])("renders %ims as %s", (ms, expected) => { + expect(formatDuration(ms)).toBe(expected); + }); +}); + +describe("trend", () => { + it("caps a percentage that has stopped meaning anything", () => { + // A new site with one pageview last week and 50k this one rendered + // "+4999900%" as an unbreakable monospace run inside an overflow-hidden + // card, so it was clipped mid-digit rather than ellipsised. + expect(trend(50_000, 1).label).toBe(">+999%"); + expect(trend(987_000_000, 1).label).toBe(">+999%"); + // The true ratio is still carried; only the label is capped. + expect(trend(50_000, 1).ratio).toBe(49_999); + }); + + it("leaves everything under the cap exact", () => { + expect(trend(1099, 1000).label).toBe("+9.9%"); + expect(trend(10_990, 1000).label).toBe("+999%"); + expect(trend(900, 1000).label).toBe("−10%"); + }); +}); + +describe("pointChange", () => { + it("states a rate's move in points rather than calling it New", () => { + // A bounce rate that went from 0% to 10% is not new; it is ten points + // worse. trend() reported "New" here, a word written for counts growing + // from nothing and meaningless about a share. + expect(pointChange(0.1, 0)).toEqual({ + ratio: 0.1, + direction: "up", + label: "+10.0 pts", + }); + expect(pointChange(1, 0).label).toBe("+100.0 pts"); + }); + + it("reads a fall as a fall and a standstill as no move", () => { + expect(pointChange(0.12, 0.155).label).toBe("−3.5 pts"); + expect(pointChange(0.12, 0.12).direction).toBe("flat"); + }); +}); + +describe("durationChange", () => { + it("states an average's move in its own unit", () => { + // Same reasoning as pointChange: an average visit whose previous window + // read 0 has not become "New". + expect(durationChange(12_000, 0).label).toBe("+12s"); + expect(durationChange(90_000, 30_000).label).toBe("+1m 00s"); + expect(durationChange(30_000, 90_000).direction).toBe("down"); + }); + + it("does not report a difference formatDuration would round away", () => { + expect(durationChange(48_400, 48_000)).toEqual({ + ratio: 0, + direction: "flat", + label: "0s", + }); + }); +}); + +describe("formatCountry", () => { + it("names the alpha-2 codes the edge headers speak", () => { + expect(formatCountry("IT")).toBe("Italy"); + expect(formatCountry("US")).toBe("United States"); + }); + + it("labels the bucket that carries no country at all", () => { + expect(formatCountry("")).toBe("Unknown"); + }); + + it("falls back to what was stored rather than to undefined", () => { + // The header is whatever the proxy in front of the app sent, so a value + // Intl won't even parse as a region has to survive as itself. + expect(formatCountry("not-a-code")).toBe("not-a-code"); + }); +}); + +describe("countryFlag", () => { + it("maps a code to its regional-indicator pair", () => { + expect(countryFlag("IT")).toBe("\u{1F1EE}\u{1F1F9}"); + }); + + it("has nothing to draw for the empty bucket", () => { + expect(countryFlag("")).toBe(""); + expect(countryFlag("ZZZ")).toBe(""); + }); +}); + +describe("formatMoney", () => { + it("keeps every currency in its own unit", () => { + // 49 EUR and 10 USD are two figures. Adding them answered "59" in no unit, + // which is the bug this column was corrected for. + expect(formatMoney(49, "EUR")).toBe("€49.00"); + expect(formatMoney(10, "USD")).toBe("$10.00"); + }); + + it("renders a well-formed code Intl has no symbol for as the code", () => { + // Intl's own separator here is a non-breaking space, which is the point of + // letting it do the joining rather than doing it by hand. + expect(formatMoney(12.5, "XBT")).toBe("XBT\u00a012.50"); + }); + + it("still renders an amount in a code Intl refuses outright", () => { + // ISO-4217 is a claim the tracker makes, not one anything verifies, and a + // goal's total is worth more than its symbol. + expect(formatMoney(12.5, "bitcoin")).toBe("12.50 bitcoin"); + }); +}); + +/** A window as the picker sends it: zoned midnights, the end exclusive. */ +const pickedWindow = ( + tz: string, + from: [number, number], + to: [number, number] +) => ({ + from: startOfZonedDay(new Date(2026, from[0] - 1, from[1]), tz), + to: endOfZonedDayExclusive(new Date(2026, to[0] - 1, to[1]), tz), +}); + +describe("formatDateRange", () => { + it("labels a single picked day as that day", () => { + // The end is the next day's midnight, so reading it directly labelled one + // day "Aug 1 – Aug 2" — a window one day wider than the one on screen. + const { from, to } = pickedWindow("Europe/Rome", [8, 1], [8, 1]); + + expect(formatDateRange(from, to, "Europe/Rome")).toBe("Aug 1"); + }); + + it("names the last day inside the window, not the boundary after it", () => { + const { from, to } = pickedWindow("Europe/Rome", [8, 1], [8, 4]); + + expect(formatDateRange(from, to, "Europe/Rome")).toBe("Aug 1 – Aug 4"); + }); + + it("reads both ends in the charted zone", () => { + // Tokyo's Aug 1 – Aug 4 is one pair of instants; relabelled sixteen hours + // west it starts on Jul 31, which is the point of passing the zone at all. + const { from, to } = pickedWindow("Asia/Tokyo", [8, 1], [8, 4]); + + expect(formatDateRange(from, to, "Asia/Tokyo")).toBe("Aug 1 – Aug 4"); + expect(formatDateRange(from, to, "America/Los_Angeles")).toBe( + "Jul 31 – Aug 4" + ); + }); + + it("qualifies the year when the window really spans two", () => { + const from = startOfZonedDay(new Date(2026, 11, 30), "UTC"); + const to = endOfZonedDayExclusive(new Date(2027, 0, 2), "UTC"); + + expect(formatDateRange(from, to, "UTC")).toBe("Dec 30, 2026 – Jan 2, 2027"); + }); + + it("does not qualify one whose exclusive end merely crosses new year", () => { + // New Year's Eve alone ends at Jan 1 00:00. Read as if it were in the + // window, that end put a year on both labels and stretched a one-day pick + // across two of them. + const from = startOfZonedDay(new Date(2026, 11, 31), "UTC"); + const to = endOfZonedDayExclusive(new Date(2026, 11, 31), "UTC"); + + expect(formatDateRange(from, to, "UTC")).toBe("Dec 31"); + }); + + it("still names a single day when the end has been clipped to now", () => { + // The loader clips a range ending today back to the current instant, so the + // end reaching this is often not a midnight at all. + const from = startOfZonedDay(new Date(2026, 7, 4), "Europe/Rome"); + + expect(formatDateRange(from, from + 13 * 3_600_000, "Europe/Rome")).toBe( + "Aug 4" + ); + }); +}); + +describe("formatReferrer", () => { + it("does not call the empty bucket Direct, which it is not", () => { + // The dimension is grouped over arrivals now, so the bucket is a visit that + // began with no external referrer rather than the internal navigation it + // used to be full of. Still not "Direct": one tab over, `channel` calls a + // visit `campaign` whenever the link carried utm parameters, referrer or + // not, so a newsletter click is in this bucket and under Campaign in that + // one. Two panels spending one word on two different sets of visits is how + // a reader ends up comparing figures that were never comparable. + expect(formatReferrer("")).toBe("No referrer"); + expect(formatReferrer("news.ycombinator.com")).toBe("news.ycombinator.com"); + }); +}); + +describe("formatChannel", () => { + it("names the five values the column is constrained to", () => { + // Stored lowercase, and the panel would otherwise print "search" under a + // capitalised header beside four capitalised siblings. + expect( + ["direct", "search", "social", "referral", "campaign"].map(formatChannel) + ).toEqual(["Direct", "Search", "Social", "Referral", "Campaign"]); + }); + + it("echoes anything the check constraint would have refused", () => { + // Unreachable while events_channel_valid holds. It renders the stored value + // rather than "undefined" if it ever stops holding, which is the same + // bargain formatCountry makes with an unknown region code. + expect(formatChannel("carrier-pigeon")).toBe("carrier-pigeon"); + expect(formatChannel("")).toBe("Unknown"); + }); +}); diff --git a/apps/web/app/shared/lib/db.server.ts b/apps/web/app/shared/lib/db.server.ts new file mode 100644 index 00000000..7d065929 --- /dev/null +++ b/apps/web/app/shared/lib/db.server.ts @@ -0,0 +1,43 @@ +import * as schema from "~/db/schema"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +/** + * Cached on globalThis so `react-router dev` HMR doesn't open a new pool on + * every reload. + */ +const globalForDb = globalThis as unknown as { + pool: Pool | undefined; +}; + +function createPool() { + const connectionString = process.env.DATABASE_URL; + + if (!connectionString) { + throw new Error("DATABASE_URL is not set"); + } + + const pool = new Pool({ connectionString }); + + // pg emits 'error' on the pool when an *idle* client fails — a database + // restart, a failover, an idle_session_timeout, a pooler dropping the TCP + // connection. Node throws on an 'error' event with no listener, which would + // take the whole server down rather than the one dead connection. The Prisma + // adapter this replaced installed such a handler; without it the port would + // have been a regression. + pool.on("error", (error) => { + console.error("[db] idle client error", error); + }); + + return pool; +} + +const pool = globalForDb.pool ?? createPool(); + +if (process.env.NODE_ENV !== "production") { + globalForDb.pool = pool; +} + +export const db = drizzle(pool, { schema, casing: "snake_case" }); + +export { schema }; diff --git a/apps/web/app/shared/lib/format.ts b/apps/web/app/shared/lib/format.ts new file mode 100644 index 00000000..78840dc4 --- /dev/null +++ b/apps/web/app/shared/lib/format.ts @@ -0,0 +1,409 @@ +/** + * Display formatting shared by every panel. + * + * Everything pins an explicit locale and time zone. The server renders these + * strings too, so anything that reads the host's locale or clock would produce + * markup that doesn't survive hydration. + */ + +// Type-only, so nothing in db/schema.ts is pulled into the client bundle: it is +// the declaration of the closed set `formatChannel` labels, and the point is +// that adding a channel there breaks the label map here. +import type { ChannelType } from "~/db/schema"; + +const LOCALE = "en-US"; + +const compact = new Intl.NumberFormat(LOCALE, { + notation: "compact", + maximumFractionDigits: 1, +}); + +const plain = new Intl.NumberFormat(LOCALE); + +/** Full precision, grouped. For tooltips and single figures. */ +export function formatNumber(value: number) { + return plain.format(Math.round(value)); +} + +/** Abbreviated. For anything that has to fit in a column or a tile. */ +export function formatCompactNumber(value: number) { + return value < 1000 ? plain.format(Math.round(value)) : compact.format(value); +} + +/** Stands in for a figure that was never measured, as opposed to one that is 0. */ +export const NO_DATA = "—"; + +/** + * Milliseconds to the shortest unambiguous form: 0s, <1s, 48s, 3m 07s, 1h 04m. + * + * Null is a window in which nothing reported a duration. It renders as a dash + * rather than "0s", which would claim every visit ended the instant it began. + * + * A *measured* fraction of a second is the same trap one step down. Rounding to + * whole seconds turned a real 400ms average into the same "0s" this function + * reserves for a real zero, so a site of instant bounces read as one whose + * beacons never fired. Anything that measured something but rounds away is + * "<1s": still short, and still not nothing. + */ +export function formatDuration(ms: number | null) { + if (ms === null) { + return NO_DATA; + } + + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + + if (totalSeconds < 60) { + return totalSeconds === 0 && ms > 0 ? "<1s" : `${totalSeconds}s`; + } + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes < 60) { + return `${minutes}m ${String(seconds).padStart(2, "0")}s`; + } + + return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`; +} + +/** A share of a whole, already expressed as a 0–1 ratio. */ +export function formatPercent(ratio: number, fractionDigits = 1) { + if (!Number.isFinite(ratio)) { + return "0%"; + } + + const percent = ratio * 100; + // Whole numbers don't need a decimal; 12.5% does. + const digits = Number.isInteger(percent) ? 0 : fractionDigits; + + return `${percent.toFixed(digits)}%`; +} + +export type Trend = { + /** Signed ratio, e.g. 0.14 for +14%. Null when there is no baseline. */ + ratio: number | null; + direction: "up" | "down" | "flat"; + label: string; +}; + +/** + * Past this the exact figure has stopped saying anything a reader can use, and + * it is wide enough to be a layout problem: a stat tile that grew from one + * pageview to fifty thousand rendered "+4999900%" as an unbreakable monospace + * run inside a card that clips its overflow, so the number was cut mid-digit. + * Everything below the cap fits in "+999.9%". + */ +const TREND_CAP = 10; + +/** + * Change against the previous window, for a metric that is a *count*. + * + * Growth from zero has no defined percentage, so it reports as "New" rather + * than the infinity a naive (current - previous) / previous would produce. + * That word is why this is only for counts: "New" describes a pageview count + * that had nothing before it, and describes nothing at all about a rate or an + * average that happened to sit at zero. Those have `pointChange` and + * `durationChange`, which state their change in their own units and never need + * a baseline to divide by. + */ +export function trend(current: number, previous: number): Trend { + if (previous === 0) { + return current === 0 + ? { ratio: 0, direction: "flat", label: "No change" } + : { ratio: null, direction: "up", label: "New" }; + } + + const ratio = (current - previous) / previous; + + if (Math.abs(ratio) < 0.0005) { + return { ratio: 0, direction: "flat", label: "0%" }; + } + + const sign = ratio > 0 ? "+" : "−"; + const direction = ratio > 0 ? ("up" as const) : ("down" as const); + + if (Math.abs(ratio) >= TREND_CAP) { + return { ratio, direction, label: `${ratio > 0 ? ">+" : "<−"}999%` }; + } + + return { + ratio, + direction, + label: `${sign}${formatPercent(Math.abs(ratio), 1)}`, + }; +} + +/** + * Change between two *rates*, in percentage points. + * + * A rate divided by a rate is a number nobody wants: a bounce rate that went + * from 0% to 10% is not "New", and one that went from 1% to 2% did not double + * in any sense a reader would act on. The difference of two shares is defined + * everywhere, including at a zero baseline, and "pts" is what keeps it from + * being read as the rate itself. + */ +export function pointChange(current: number, previous: number): Trend { + const points = (current - previous) * 100; + + // Below this the tile would render a signed "0.0 pts", which reads as a + // change that did not happen. + if (Math.abs(points) < 0.05) { + return { ratio: 0, direction: "flat", label: "0 pts" }; + } + + return { + ratio: current - previous, + direction: points > 0 ? "up" : "down", + label: `${points > 0 ? "+" : "−"}${Math.abs(points).toFixed(1)} pts`, + }; +} + +/** + * Change between two *durations*, as a duration. + * + * Same reasoning as `pointChange`: an average is not a count, so a window whose + * previous average was zero has not gained a duration "New". "+12s" is the + * answer in the unit the tile above it is already showing. + */ +export function durationChange(current: number, previous: number): Trend { + const delta = current - previous; + + // Rounds away at the resolution formatDuration prints, so there is nothing to + // report; stating "+<1s" would make a rounding artifact look like a move. + if (Math.round(Math.abs(delta) / 1000) === 0) { + return { ratio: 0, direction: "flat", label: "0s" }; + } + + return { + ratio: previous === 0 ? null : delta / previous, + direction: delta > 0 ? "up" : "down", + label: `${delta > 0 ? "+" : "−"}${formatDuration(Math.abs(delta))}`, + }; +} + +/** + * Chart bucket labels. + * + * Bucket timestamps are wall-clock time in the viewer's zone, labelled as UTC + * by the query layer (see metrics.server.ts). Reading them back in UTC is what + * recovers the wall clock — using the host zone would shift every label. + */ +export function formatBucket(iso: string, unit: "hour" | "day") { + const date = new Date(iso); + + return new Intl.DateTimeFormat(LOCALE, { + timeZone: "UTC", + ...(unit === "hour" + ? { hour: "numeric", hour12: true } + : { month: "short", day: "numeric" }), + }).format(date); +} + +/** + * The same label with the day attached, for an hourly axis that spans one. + * + * A rolling window does not start on a bucket boundary, so the 24 hour preset + * pads to 25 hourly buckets and the first and last are the same hour of the + * clock — the axis drew "2 PM" at both ends, an hour apart in name and a day + * apart in fact. The chart qualifies only the labels that would otherwise + * repeat, so a window that reads unambiguously stays as short as it was. + */ +export function formatBucketWithDay(iso: string) { + return new Intl.DateTimeFormat(LOCALE, { + timeZone: "UTC", + month: "short", + day: "numeric", + hour: "numeric", + hour12: true, + }).format(new Date(iso)); +} + +/** The long form used inside chart tooltips, where there is room to be exact. */ +export function formatBucketLong(iso: string, unit: "hour" | "day") { + const date = new Date(iso); + + return new Intl.DateTimeFormat(LOCALE, { + timeZone: "UTC", + weekday: "short", + month: "short", + day: "numeric", + ...(unit === "hour" ? { hour: "numeric", hour12: true } : {}), + }).format(date); +} + +/** + * A custom window, as the range picker labels it: "Aug 1 – Aug 4". + * + * The endpoints are instants, so they're read in the zone the dashboard is + * being viewed in — the same one the buckets are grouped by. The year only + * appears when the window spans two of them, where the day alone is ambiguous. + * + * `to` is exclusive, as it is everywhere else (the picker sends the next day's + * first instant, and every range predicate is `< end`), so the last day *in* + * the window is the one the instant before it falls on. Formatting the boundary + * itself labelled a single picked day "Aug 1 – Aug 2". + */ +export function formatDateRange(from: number, to: number, tz: string) { + // Not `to - 1` guarded on `to > from`: the loader clips the end to now, which + // is never below the start — it rejects `from >= to` with a 400 first. + const last = to - 1; + + const year = new Intl.DateTimeFormat(LOCALE, { + timeZone: tz, + year: "numeric", + }); + const sameYear = year.format(from) === year.format(last); + + const day = new Intl.DateTimeFormat(LOCALE, { + timeZone: tz, + month: "short", + day: "numeric", + ...(sameYear ? {} : { year: "numeric" }), + }); + + const start = day.format(from); + const end = day.format(last); + + return start === end ? start : `${start} – ${end}`; +} + +/** + * The referrer column's label. + * + * The dimension is now grouped over arrivals — `is_new_session`, the pageview + * that opened the visit — so the empty bucket is a visit that arrived with no + * external referrer rather than the pile of internal navigation it used to be. + * It read "None or internal" for that reason, and before that "Direct", which + * claimed the opposite of the truth: a site whose visitors all arrive from one + * search engine and then read five pages showed that engine at 100 and "Direct" + * at 400. + * + * Still not "Direct", even now that the scope is right, because that word means + * something narrower one panel over: `channel` calls a visit `campaign` whenever + * the link carried utm parameters, referrer or not, so a newsletter click sits + * in this bucket and under Campaign in the other. Two panels using one word for + * two different sets of visits is how a reader ends up comparing figures that + * were never comparable. "No referrer" is what the bucket actually holds. + */ +export function formatReferrer(value: string) { + return value || "No referrer"; +} + +/** + * A `channel` value as a label. + * + * The column is a closed set (ChannelType, and a CHECK constraint means it), so + * this is a capitalisation and not a lookup — spelled out rather than + * `charAt(0).toUpperCase()` so that a channel added to the schema shows up here + * as a compile error instead of rendering lowercase next to four capitalised + * siblings. + */ +const CHANNEL_LABELS: Record = { + direct: "Direct", + search: "Search", + social: "Social", + referral: "Referral", + campaign: "Campaign", +}; + +export function formatChannel(value: string) { + return CHANNEL_LABELS[value as ChannelType] ?? (value || "Unknown"); +} + +const regions = new Intl.DisplayNames([LOCALE], { type: "region" }); + +/** + * An ISO-3166-1 alpha-2 code as a country name. + * + * The edge headers are the only geography Aurora has and they speak codes; the + * query layer keeps them raw on purpose, so this is where "IT" becomes "Italy". + * `Intl.DisplayNames` throws on anything that isn't a well-formed region code + * and echoes back one it simply doesn't know, so both degrade to the stored + * value rather than to "undefined". + */ +export function formatCountry(code: string) { + if (!code) { + return "Unknown"; + } + + try { + return regions.of(code.toUpperCase()) ?? code; + } catch { + return code; + } +} + +/** + * The regional-indicator pair for an alpha-2 code, which every platform that + * has flags draws as one — no image, no request to a third party for it. The + * ones that don't render letters, which is why it never carries the meaning on + * its own. + */ +export function countryFlag(code: string) { + if (!/^[a-z]{2}$/i.test(code)) { + return ""; + } + + return String.fromCodePoint( + ...[...code.toUpperCase()].map( + (letter) => 0x1f1e6 + letter.charCodeAt(0) - 65 + ) + ); +} + +/** Null marks a code Intl refused, so the refusal is paid for once per code. */ +const currencies = new Map(); + +/** + * One currency's total, in that currency. + * + * Takes a single amount and never a list: 49 EUR and 10 USD are two figures, + * and adding them answered "59" in no unit at all — the bug the revenue column + * was just corrected for. The code is whatever the site reported and nothing + * verifies it against ISO-4217, so one Intl won't parse at all still renders + * its amount rather than throwing the goal's row away. + */ +export function formatMoney(total: number, currency: string) { + if (!currencies.has(currency)) { + try { + currencies.set( + currency, + new Intl.NumberFormat(LOCALE, { style: "currency", currency }) + ); + } catch { + currencies.set(currency, null); + } + } + + const format = currencies.get(currency); + + return format ? format.format(total) : `${total.toFixed(2)} ${currency}`; +} + +/** + * Leading letters, for the tiles that stand in for logos and avatars. + * + * Sites take one letter: their names are often domains, and "Demo: docs.x.dev" + * would otherwise render as "DD". People take two. + */ +export function initials(value: string, max = 2) { + return value + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, max) + .map((word) => word.charAt(0)) + .join("") + .toUpperCase(); +} + +/** Deterministic tint per label, drawn from the aurora ramp. */ +export function tintIndex(value: string, buckets = 5) { + let hash = 0; + + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + + return Math.abs(hash) % buckets; +} diff --git a/apps/web/app/shared/lib/pg-errors.server.ts b/apps/web/app/shared/lib/pg-errors.server.ts new file mode 100644 index 00000000..19abb133 --- /dev/null +++ b/apps/web/app/shared/lib/pg-errors.server.ts @@ -0,0 +1,32 @@ +/** + * Postgres error shapes, kept apart from db.server so that reading one costs + * nothing: importing db.server constructs the connection pool, and a suite that + * stubs the pool would otherwise have to stub this too — mocking away the logic + * it came to test. + */ + +/** + * pg reports a unique violation as 23505, and drizzle wraps it in a + * DrizzleQueryError, so the code is one `cause` down — two once a transaction + * has rethrown it. + * + * Every caller is a place where a check-then-insert has a race in it: two + * requests can both read "no such row" before either writes, and the constraint + * is the only thing that sees the second one. The catch is the answer, the + * check is only there for the better message. + */ +export function isUniqueViolation(error: unknown): boolean { + for (let cause = error, depth = 0; depth < 4; depth += 1) { + if (typeof cause !== "object" || cause === null) { + return false; + } + + if ((cause as { code?: unknown }).code === "23505") { + return true; + } + + cause = (cause as { cause?: unknown }).cause; + } + + return false; +} diff --git a/apps/web/app/shared/lib/utils.ts b/apps/web/app/shared/lib/utils.ts new file mode 100644 index 00000000..3b8071f9 --- /dev/null +++ b/apps/web/app/shared/lib/utils.ts @@ -0,0 +1,19 @@ +import { clsx, type ClassValue } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * `text-eyebrow` is a custom utility, and tailwind-merge reads any unknown + * `text-*` class as a colour — so `cn("text-eyebrow text-muted-foreground")` + * would silently drop one of them. Its own class group keeps both. + */ +const twMerge = extendTailwindMerge<"eyebrow">({ + extend: { + classGroups: { + eyebrow: ["text-eyebrow"], + }, + }, +}); + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/app/shared/ui/alert-dialog.tsx b/apps/web/app/shared/ui/alert-dialog.tsx new file mode 100644 index 00000000..00eff48a --- /dev/null +++ b/apps/web/app/shared/ui/alert-dialog.tsx @@ -0,0 +1,185 @@ +import * as React from "react"; +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; + +import { cn } from "~/shared/lib/utils"; +import { Button } from "~/shared/ui/button"; + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return ; +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ); +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + - - ); -} diff --git a/packages/frontend/src/components/WebsitesForm/index.ts b/packages/frontend/src/components/WebsitesForm/index.ts deleted file mode 100644 index ed7d40e3..00000000 --- a/packages/frontend/src/components/WebsitesForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./WebsitesForm"; diff --git a/packages/frontend/src/components/Wrapper/Wrapper.tsx b/packages/frontend/src/components/Wrapper/Wrapper.tsx deleted file mode 100644 index 379e4943..00000000 --- a/packages/frontend/src/components/Wrapper/Wrapper.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Flex } from "@chakra-ui/react"; -import * as React from "react"; - -interface WrapperProps { - children: React.ReactNode; -} - -const Wrapper = ({ children }: WrapperProps) => { - return ( - - {children} - - ); -}; - -export { Wrapper }; diff --git a/packages/frontend/src/components/Wrapper/WrapperActions.tsx b/packages/frontend/src/components/Wrapper/WrapperActions.tsx deleted file mode 100644 index 51717b6a..00000000 --- a/packages/frontend/src/components/Wrapper/WrapperActions.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { HStack } from "@chakra-ui/react"; -import * as React from "react"; - -interface WrapperActionsProps { - children: React.ReactNode; -} - -const WrapperActions = ({ children }: WrapperActionsProps) => { - return {children}; -}; - -export { WrapperActions }; diff --git a/packages/frontend/src/components/Wrapper/WrapperContent.tsx b/packages/frontend/src/components/Wrapper/WrapperContent.tsx deleted file mode 100644 index 7692d57e..00000000 --- a/packages/frontend/src/components/Wrapper/WrapperContent.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Box } from "@chakra-ui/react"; -import * as React from "react"; - -interface WrapperContentProps { - children: React.ReactNode; -} - -const WrapperContent = ({ children }: WrapperContentProps) => { - return {children}; -}; - -export { WrapperContent }; diff --git a/packages/frontend/src/components/Wrapper/WrapperHeader.tsx b/packages/frontend/src/components/Wrapper/WrapperHeader.tsx deleted file mode 100644 index 31cce436..00000000 --- a/packages/frontend/src/components/Wrapper/WrapperHeader.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Flex, useMediaQuery } from "@chakra-ui/react"; -import * as React from "react"; - -interface WrapperHeaderProps { - children: React.ReactNode; -} - -const WrapperHeader = ({ children }: WrapperHeaderProps) => { - const [isNotMobile] = useMediaQuery("(min-width: 768px)"); - - return ( - - {children} - - ); -}; - -export { WrapperHeader }; diff --git a/packages/frontend/src/components/Wrapper/WrapperTitle.tsx b/packages/frontend/src/components/Wrapper/WrapperTitle.tsx deleted file mode 100644 index 7d96cb79..00000000 --- a/packages/frontend/src/components/Wrapper/WrapperTitle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Heading } from "@chakra-ui/react"; - -interface WrapperTitleProps { - children: string; -} - -const WrapperTitle = ({ children }: WrapperTitleProps) => { - return {children}; -}; - -export { WrapperTitle }; diff --git a/packages/frontend/src/components/Wrapper/index.ts b/packages/frontend/src/components/Wrapper/index.ts deleted file mode 100644 index df4ab61e..00000000 --- a/packages/frontend/src/components/Wrapper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./Wrapper"; -export * from './WrapperActions'; -export * from './WrapperContent'; -export * from './WrapperHeader'; -export * from './WrapperTitle'; - diff --git a/packages/frontend/src/index.js b/packages/frontend/src/index.js deleted file mode 100644 index 10dabfe3..00000000 --- a/packages/frontend/src/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import { ChakraProvider, ColorModeScript } from "@chakra-ui/react"; -import React from "react"; -import ReactDOM from "react-dom"; -import { IconContext } from "react-icons"; -import { BrowserRouter } from "react-router-dom"; -import { App } from "./App"; -import theme from "./lib/theme"; - -ReactDOM.render( - - - - - - - - - - , - document.getElementById("root") -); diff --git a/packages/frontend/src/lib/client.js b/packages/frontend/src/lib/client.js deleted file mode 100644 index 08155e5b..00000000 --- a/packages/frontend/src/lib/client.js +++ /dev/null @@ -1,5 +0,0 @@ -import axios from "axios"; - -export const client = axios.create({ - baseURL: process.env.REACT_APP_BACKEND_URL || undefined, -}); diff --git a/packages/frontend/src/lib/context/auth-context.js b/packages/frontend/src/lib/context/auth-context.js deleted file mode 100644 index 0c9ddb08..00000000 --- a/packages/frontend/src/lib/context/auth-context.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from "react"; -import { client } from "../client"; - -const AuthContext = React.createContext(); - -export const AuthProvider = ({ children }) => { - const [user, setUser] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(true); - - React.useEffect(() => { - const token = localStorage.getItem("aurora_access_token"); - - if (token) { - client.defaults.headers.common.Authorization = `Bearer ${token}`; - - client - .get("/me") - .then((res) => setUser(res.data)) - .finally(() => setIsLoading(false)); - } else { - setIsLoading(false); - } - }, []); - - const signIn = async (email, password) => { - const res = await client.post("/signin", { email, password }); - const { user, accessToken } = res.data; - client.defaults.headers.common.Authorization = `Bearer ${accessToken}`; - localStorage.setItem("aurora_access_token", accessToken); - setUser(user); - }; - - const signOut = async () => { - client.defaults.headers.common.Authorization = null; - localStorage.removeItem("aurora_access_token"); - setUser(null); - }; - - const value = { user, isLoading, signIn, signOut }; - - return {children}; -}; - -export const useAuth = () => React.useContext(AuthContext); diff --git a/packages/frontend/src/lib/hooks/use-account.ts b/packages/frontend/src/lib/hooks/use-account.ts deleted file mode 100644 index fa3de4a8..00000000 --- a/packages/frontend/src/lib/hooks/use-account.ts +++ /dev/null @@ -1,15 +0,0 @@ -import useSWR from "swr"; -import { User } from "../../types"; -import { client } from "../client"; - -const fetcher = (url: string) => client.get(url).then((res) => res.data); - -export function useAccount() { - const { data, error } = useSWR(`/me`, fetcher); - - return { - data: data, - isLoading: !error && !data, - isError: error, - }; -} diff --git a/packages/frontend/src/lib/hooks/use-aurora-links.js b/packages/frontend/src/lib/hooks/use-aurora-links.js deleted file mode 100644 index 484f6efb..00000000 --- a/packages/frontend/src/lib/hooks/use-aurora-links.js +++ /dev/null @@ -1,17 +0,0 @@ -import * as React from "react"; - -export function useAuroraLinks(wid) { - const [sharedLink, setSharedLink] = React.useState(null); - const [generatedLink, setGeneratedLink] = React.useState(null); - - React.useEffect(() => { - const currentUrl = process.env.REACT_APP_BACKEND_URL; - const sharedLink = `${window.location.protocol}//${window.location.host}/websites/${wid}/s/analytics`; - const generatedLink = ``; - - setSharedLink(sharedLink); - setGeneratedLink(generatedLink); - }, [wid]); - - return { sharedLink, generatedLink }; -} diff --git a/packages/frontend/src/lib/hooks/use-form.js b/packages/frontend/src/lib/hooks/use-form.js deleted file mode 100644 index fe355f69..00000000 --- a/packages/frontend/src/lib/hooks/use-form.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from "react"; - -export function useForm(initialValues = {}) { - const formRef = React.useRef(); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - React.useEffect(() => { - console.log("useForm.useEffect called!"); - - for (const key of formRef.current.elements) { - if (key.name in initialValues) { - key.value = initialValues[key.name]; - } - } - }, [initialValues]); - - const onSubmit = (cb) => { - return async (event) => { - event.preventDefault(); - - setIsSubmitting(true); - - const submitted = {}; - const formData = new FormData(formRef.current); - - for (const [key, value] of formData.entries()) { - submitted[key] = value; - } - - await cb(submitted); - setIsSubmitting(false); - }; - }; - - const getFormProps = (...props) => { - return { ...props, ref: formRef }; - }; - - return { - isSubmitting, - getFormProps, - onSubmit: onSubmit, - }; -} diff --git a/packages/frontend/src/lib/hooks/use-metadata.js b/packages/frontend/src/lib/hooks/use-metadata.js deleted file mode 100644 index 0ea5b52a..00000000 --- a/packages/frontend/src/lib/hooks/use-metadata.js +++ /dev/null @@ -1,20 +0,0 @@ -import useSWR from "swr"; -import { client } from "../client"; - -const fetcher = (url) => client.get(url).then((res) => res.data); - -export function useMetadata(metadata, filters) { - const qs = new URLSearchParams(filters).toString(); - const { data, error } = useSWR( - qs - ? `/websites/${filters.wid}/metrics/metadata?meta=${metadata}&${qs}` - : null, - fetcher - ); - - return { - data: data, - isLoading: !error && !data, - isError: error, - }; -} diff --git a/packages/frontend/src/lib/hooks/use-pages.js b/packages/frontend/src/lib/hooks/use-pages.js deleted file mode 100644 index 3166b1f6..00000000 --- a/packages/frontend/src/lib/hooks/use-pages.js +++ /dev/null @@ -1,18 +0,0 @@ -import useSWR from "swr"; -import { client } from "../client"; - -const fetcher = (url) => client.get(url).then((res) => res.data); - -export function usePages(filters) { - const qs = new URLSearchParams(filters).toString(); - const { data, error } = useSWR( - qs ? `/websites/${filters.wid}/metrics/pages?${qs}` : null, - fetcher - ); - - return { - data: data, - isLoading: !error && !data, - isError: error, - }; -} diff --git a/packages/frontend/src/lib/hooks/use-statistics.js b/packages/frontend/src/lib/hooks/use-statistics.js deleted file mode 100644 index a6879b40..00000000 --- a/packages/frontend/src/lib/hooks/use-statistics.js +++ /dev/null @@ -1,18 +0,0 @@ -import useSWR from "swr"; -import { client } from "../client"; - -const fetcher = (url) => client.get(url).then((res) => res.data); - -export function useStatistics(filters) { - const qs = new URLSearchParams(filters).toString(); - const { data, error } = useSWR( - qs ? `/websites/${filters.wid}/metrics/statistics?${qs}` : null, - fetcher - ); - - return { - data: data, - isLoading: !error && !data, - isError: error, - }; -} diff --git a/packages/frontend/src/lib/hooks/use-timeseries.js b/packages/frontend/src/lib/hooks/use-timeseries.js deleted file mode 100644 index 64954bdc..00000000 --- a/packages/frontend/src/lib/hooks/use-timeseries.js +++ /dev/null @@ -1,18 +0,0 @@ -import useSWR from "swr"; -import { client } from "../client"; - -const fetcher = (url) => client.get(url).then((res) => res.data); - -export function useTimeseries(filters) { - const qs = new URLSearchParams(filters).toString(); - const { data, error } = useSWR( - qs ? `/websites/${filters.wid}/metrics/timeseries?${qs}` : null, - fetcher - ); - - return { - data: data, - isLoading: !error && !data, - isError: error, - }; -} diff --git a/packages/frontend/src/lib/hooks/use-website.js b/packages/frontend/src/lib/hooks/use-website.js deleted file mode 100644 index 46831eb0..00000000 --- a/packages/frontend/src/lib/hooks/use-website.js +++ /dev/null @@ -1,14 +0,0 @@ -import useSWR from "swr"; -import { client } from "../client"; - -const fetcher = (url) => client.get(url).then((res) => res.data); - -export function useWebsite(id) { - const { data, error } = useSWR(`/websites/${id}`, fetcher); - - return { - data: data, - isLoading: !error && !data, - isError: error, - }; -} diff --git a/packages/frontend/src/lib/hooks/use-websites.ts b/packages/frontend/src/lib/hooks/use-websites.ts deleted file mode 100644 index 98975d8b..00000000 --- a/packages/frontend/src/lib/hooks/use-websites.ts +++ /dev/null @@ -1,15 +0,0 @@ -import useSWR from "swr"; -import { Website } from "../../types"; -import { client } from "../client"; - -const fetcher = (url: string) => client.get(url).then((res) => res.data); - -export function useWebsites() { - const { data, error } = useSWR(`/websites`, fetcher); - - return { - data: data || [], - isLoading: !error && !data, - isError: error, - }; -} diff --git a/packages/frontend/src/lib/reducers/filters-reducer.js b/packages/frontend/src/lib/reducers/filters-reducer.js deleted file mode 100644 index a63fbbe9..00000000 --- a/packages/frontend/src/lib/reducers/filters-reducer.js +++ /dev/null @@ -1,36 +0,0 @@ -import { subDays } from "date-fns"; - -export function filtersReducer(state, action) { - switch (action.type) { - case "LAST_24_HOURS": - return { - ...state, - start: subDays(new Date(), 1).getTime(), - end: new Date().getTime(), - unit: "hour", - }; - - case "LAST_7_DAYS": - return { - ...state, - // start: subDays(new Date(), 5).getTime(), - // end: addDays(new Date(), 1).getTime(), - start: subDays(new Date(), 6).getTime(), - end: new Date().getTime(), - unit: "day", - }; - - case "LAST_30_DAYS": - return { - ...state, - // start: subDays(new Date(), 28).getTime(), - // end: addDays(new Date(), 1).getTime(), - start: subDays(new Date(), 29).getTime(), - end: new Date().getTime(), - unit: "day", - }; - - default: - return state; - } -} diff --git a/packages/frontend/src/lib/theme.js b/packages/frontend/src/lib/theme.js deleted file mode 100644 index 18521ed2..00000000 --- a/packages/frontend/src/lib/theme.js +++ /dev/null @@ -1,10 +0,0 @@ -import { extendTheme } from "@chakra-ui/react"; - -const config = { - initialColorMode: "light", - useSystemColorMode: false, -}; - -const theme = extendTheme({ config }); - -export default theme; diff --git a/packages/frontend/src/pages/Account/Account.tsx b/packages/frontend/src/pages/Account/Account.tsx deleted file mode 100644 index 0c28ce53..00000000 --- a/packages/frontend/src/pages/Account/Account.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { - Wrapper, - WrapperContent, - WrapperHeader, - WrapperTitle, -} from "../../components/Wrapper"; -import { AccountForm } from "./AccountForm"; - -export function Account() { - return ( - - - Account - - - - - - - ); -} diff --git a/packages/frontend/src/pages/Account/AccountForm.tsx b/packages/frontend/src/pages/Account/AccountForm.tsx deleted file mode 100644 index 298a4eca..00000000 --- a/packages/frontend/src/pages/Account/AccountForm.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { - Button, - FormControl, - FormHelperText, - FormLabel, - Input, - useToast, -} from "@chakra-ui/react"; -import * as React from "react"; -import { useForm } from "react-hook-form"; -import { Loader } from "../../components/Loader"; -import { Panel } from "../../components/Panel"; -import { client } from "../../lib/client"; -import { useAccount } from "../../lib/hooks/use-account"; - -type AccountFormFields = { - firstname: string; - lastname: string; - email: string; - password: string; - confirmPassword: string; -}; - -export function AccountForm() { - const toast = useToast(); - const { data, isLoading, isError } = useAccount(); - - const { - reset, - register, - handleSubmit, - formState: { isSubmitting }, - } = useForm(); - - React.useEffect(() => { - if (data) { - reset(data); - } - }, [isLoading, data, isError, reset]); - - const onSuccess = () => { - toast({ status: "success", title: "Account updated!" }); - }; - - const onError = () => { - toast({ status: "error", title: "An error has occurred.." }); - }; - - const onSubmit = async (data: any) => { - // Removing password fields if empty - const payload = Object.fromEntries( - Object.entries(data).filter(([_, v]) => v !== "") - ); - - await client.put(`/me`, payload).then(onSuccess).catch(onError); - }; - - if (isLoading) { - return ; - } - - return ( - - - Firstname - - - - - Lastname - - - - - Email - - - - - New Password - - Minimum 8 characters. - - - - Repeat New Password - - Minimum 8 characters. - - - - - ); -} diff --git a/packages/frontend/src/pages/Account/index.ts b/packages/frontend/src/pages/Account/index.ts deleted file mode 100644 index 091788ef..00000000 --- a/packages/frontend/src/pages/Account/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./Account"; diff --git a/packages/frontend/src/pages/Analytics/Analytics.tsx b/packages/frontend/src/pages/Analytics/Analytics.tsx deleted file mode 100644 index f09f5151..00000000 --- a/packages/frontend/src/pages/Analytics/Analytics.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Button } from "@chakra-ui/react"; -import { Link, useParams } from "react-router-dom"; -import { - Wrapper, - WrapperActions, - WrapperContent, - WrapperHeader, - WrapperTitle, -} from "../../components/Wrapper"; -import { AnalyticsDashboard } from "./AnalyticsDashboard"; - -// TODO: Maybe a better way to handle this? -export function Analytics({ isPublic = false }) { - const { id } = useParams(); - - return ( - - - Dashboard - - {!isPublic && ( - - - - )} - - - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/AnalyticsDashboard.js b/packages/frontend/src/pages/Analytics/AnalyticsDashboard.js deleted file mode 100644 index 5c03d4fc..00000000 --- a/packages/frontend/src/pages/Analytics/AnalyticsDashboard.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Flex, Select, SimpleGrid } from "@chakra-ui/react"; -import { subDays } from "date-fns"; -import * as React from "react"; -import { filtersReducer } from "../../lib/reducers/filters-reducer"; -import { BrowserTable } from "./Charts/BrowserTable"; -import { CountryTable } from "./Charts/CountryTable"; -import { DeviceTable } from "./Charts/DeviceTable"; -import { OsTable } from "./Charts/OsTable"; -import { PageTable } from "./Charts/PageTable"; -import { ReferrerTable } from "./Charts/ReferrerTable"; -import { Stats } from "./Charts/Stats"; -import { TimeseriesChart } from "./Charts/TimeseriesChart"; - -export function AnalyticsDashboard({ wid }) { - // Filters Logic - const initialState = { - wid: wid, - start: subDays(new Date(), 1).getTime(), - end: new Date().getTime(), - unit: "hour", - tz: Intl.DateTimeFormat().resolvedOptions().timeZone, - }; - - const [filters, dispatch] = React.useReducer(filtersReducer, initialState); - - const handleChange = (e) => { - dispatch({ type: e.target.value }); - }; - - return ( - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/Charts/BrowserTable.tsx b/packages/frontend/src/pages/Analytics/Charts/BrowserTable.tsx deleted file mode 100644 index e0693d66..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/BrowserTable.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Divider, Flex, Grid, GridItem, Spinner, Text } from "@chakra-ui/react"; -import React from "react"; -import { Panel, PanelBody, PanelTitle } from "../../../components/Panel"; -import { useMetadata } from "../../../lib/hooks/use-metadata"; - -// TODO: Fix this any -const BrowserTableContainer = ({ filters }: any) => { - const { data, isLoading, isError } = useMetadata("browser", filters); - - if (isLoading) { - return ; - } - - if (isError) { - return
Whoops.. Something bad happened!
; - } - - if (data.length === 0) { - return No Data Available; - } - - const heading = ( - <> - - Name - - - Views - - - Unique - - - ); - - const divider = ( - - - - ); - - // TODO: Fix this any - const rows = data.map((row: any, index: number) => ( - - - {row.element} - - {row.views} - {row.unique} - - )); - - return ( - - {heading} - {divider} - {rows} - - ); -}; - -// TODO: Fix this any -export function BrowserTable({ filters }: any) { - return ( - - Browser - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/Charts/CountryTable.js b/packages/frontend/src/pages/Analytics/Charts/CountryTable.js deleted file mode 100644 index fa5aa906..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/CountryTable.js +++ /dev/null @@ -1,69 +0,0 @@ -import { Divider, Flex, Grid, GridItem, Spinner, Text } from "@chakra-ui/react"; -import React from "react"; -import { Panel, PanelBody, PanelTitle } from "../../../components/Panel"; -import { useMetadata } from "../../../lib/hooks/use-metadata"; - -const CountryTableContainer = ({ filters }) => { - const { data, isLoading, isError } = useMetadata("locale", filters); - - if (isLoading) { - return ; - } - - if (isError) { - return
Whoops.. Something bad happened!
; - } - - if (data.length === 0) { - return No Data Available; - } - - const heading = ( - <> - - Name - - - Views - - - Unique - - - ); - - const divider = ( - - - - ); - - const rows = data.map((row, index) => ( - - - {row.element} - - {row.views} - {row.unique} - - )); - - return ( - - {heading} - {divider} - {rows} - - ); -}; - -export const CountryTable = ({ filters }) => { - return ( - - Country - - - - - ); -}; diff --git a/packages/frontend/src/pages/Analytics/Charts/DeviceTable.js b/packages/frontend/src/pages/Analytics/Charts/DeviceTable.js deleted file mode 100644 index c9e64b05..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/DeviceTable.js +++ /dev/null @@ -1,69 +0,0 @@ -import { Divider, Flex, Grid, GridItem, Spinner, Text } from "@chakra-ui/react"; -import React from "react"; -import { Panel, PanelBody, PanelTitle } from "../../../components/Panel"; -import { useMetadata } from "../../../lib/hooks/use-metadata"; - -const DeviceTableContainer = ({ filters }) => { - const { data, isLoading, isError } = useMetadata("device", filters); - - if (isLoading) { - return ; - } - - if (isError) { - return
Whoops.. Something bad happened!
; - } - - if (data.length === 0) { - return No Data Available; - } - - const heading = ( - <> - - Name - - - Views - - - Unique - - - ); - - const divider = ( - - - - ); - - const rows = data.map((row, index) => ( - - - {row.element} - - {row.views} - {row.unique} - - )); - - return ( - - {heading} - {divider} - {rows} - - ); -}; - -export function DeviceTable({ filters }) { - return ( - - Device - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/Charts/OsTable.js b/packages/frontend/src/pages/Analytics/Charts/OsTable.js deleted file mode 100644 index ccaab0b0..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/OsTable.js +++ /dev/null @@ -1,69 +0,0 @@ -import { Divider, Flex, Grid, GridItem, Spinner, Text } from "@chakra-ui/react"; -import React from "react"; -import { Panel, PanelBody, PanelTitle } from "../../../components/Panel"; -import { useMetadata } from "../../../lib/hooks/use-metadata"; - -const OsTableContainer = ({ filters }) => { - const { data, isLoading, isError } = useMetadata("os", filters); - - if (isLoading) { - return ; - } - - if (isError) { - return
Whoops.. Something bad happened!
; - } - - if (data.length === 0) { - return No Data Available; - } - - const heading = ( - <> - - Name - - - Views - - - Unique - - - ); - - const divider = ( - - - - ); - - const rows = data.map((row, index) => ( - - - {row.element} - - {row.views} - {row.unique} - - )); - - return ( - - {heading} - {divider} - {rows} - - ); -}; - -export function OsTable({ filters }) { - return ( - - Os - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/Charts/PageTable.js b/packages/frontend/src/pages/Analytics/Charts/PageTable.js deleted file mode 100644 index df28e40f..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/PageTable.js +++ /dev/null @@ -1,69 +0,0 @@ -import { Divider, Flex, Grid, GridItem, Spinner, Text } from "@chakra-ui/react"; -import React from "react"; -import { Panel, PanelBody, PanelTitle } from "../../../components/Panel"; -import { usePages } from "../../../lib/hooks/use-pages"; - -const PageTableContainer = ({ filters }) => { - const { data, isLoading, isError } = usePages(filters); - - if (isLoading) { - return ; - } - - if (isError) { - return
Whoops.. Something bad happened!
; - } - - if (data.length === 0) { - return No Data Available; - } - - const heading = ( - <> - - Name - - - Views - - - Unique - - - ); - - const divider = ( - - - - ); - - const rows = data.map((row, index) => ( - - - {row.element} - - {row.views} - {row.unique} - - )); - - return ( - - {heading} - {divider} - {rows} - - ); -}; - -export function PageTable({ filters }) { - return ( - - Page - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/Charts/ReferrerTable.js b/packages/frontend/src/pages/Analytics/Charts/ReferrerTable.js deleted file mode 100644 index c6d3509e..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/ReferrerTable.js +++ /dev/null @@ -1,86 +0,0 @@ -import { - Divider, - Flex, - Grid, - GridItem, - Link, - Spinner, - Text, -} from "@chakra-ui/react"; -import React from "react"; -import { Panel, PanelBody, PanelTitle } from "../../../components/Panel"; -import { useMetadata } from "../../../lib/hooks/use-metadata"; - -export const dropProtocol = (url) => { - return url - .replace(/(^\w+:|^)\/\//, "") - .replace(/\/$/, "") - .replace("www.", ""); -}; - -const ReferrerTableContainer = ({ filters }) => { - const { data, isLoading, isError } = useMetadata("referrer", filters); - - if (isLoading) { - return ; - } - - if (isError) { - return
Whoops.. Something bad happened!
; - } - - if (data.length === 0) { - return No Data Available; - } - - const heading = ( - <> - - Name - - - Views - - - Unique - - - ); - - const divider = ( - - - - ); - - const rows = data.map((row, index) => ( - - - - {row.element} - - - {row.views} - {row.unique} - - )); - - return ( - - {heading} - {divider} - {rows} - - ); -}; - -export function ReferrerTable({ filters }) { - return ( - - Referrer - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/Charts/Stats.js b/packages/frontend/src/pages/Analytics/Charts/Stats.js deleted file mode 100644 index f5d92659..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/Stats.js +++ /dev/null @@ -1,29 +0,0 @@ -import { SimpleGrid, Spinner } from "@chakra-ui/react"; -import { Stat } from "../../../components/Stat"; -import { useStatistics } from "../../../lib/hooks/use-statistics"; - -export function Stats({ filters }) { - const { data, isLoading, isError } = useStatistics(filters); - - if (isLoading || isError) { - return ( - - } /> - } /> - } /> - } /> - - ); - } - - const avg = Math.ceil(data.avgDuration / 1000); - - return ( - - - - - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/Charts/TimeseriesChart.js b/packages/frontend/src/pages/Analytics/Charts/TimeseriesChart.js deleted file mode 100644 index 420d1145..00000000 --- a/packages/frontend/src/pages/Analytics/Charts/TimeseriesChart.js +++ /dev/null @@ -1,131 +0,0 @@ -import { Spinner, useColorModeValue } from "@chakra-ui/react"; -import Chart from "react-apexcharts"; -import { Panel, PanelBody, PanelTitle } from "../../../components/Panel"; -import { useTimeseries } from "../../../lib/hooks/use-timeseries"; - -export function TimeseriesChart({ filters }) { - const foreColor = useColorModeValue("black", "white"); - const barColor = useColorModeValue("#555de3", "#bfe399"); - const { data, isLoading, isError } = useTimeseries(filters); - - if (isLoading || isError) { - return ( - - Number of Page Visits - - - - - ); - } - - return ( - - Number of Page Visits - - - ${data.name}: ${series[seriesIndex][dataPointIndex]} - - `; - }, - }, - yaxis: { - labels: { - padding: 4, - }, - }, - labels: data.map((item) => item.timeseries), - colors: [barColor], - legend: { - show: true, - fontSize: "16px", - itemMargin: { - horizontal: 10, - vertical: 0, - }, - markers: { - width: 14, - height: 14, - offsetX: -4, - offsetY: 0, - }, - }, - }} - series={[ - { - name: "Page Views", - data: data.map((item) => item.count), - }, - ]} - type="bar" - height={500} - //width={1400} - /> - - - ); -} diff --git a/packages/frontend/src/pages/Analytics/index.js b/packages/frontend/src/pages/Analytics/index.js deleted file mode 100644 index 5c4ac40f..00000000 --- a/packages/frontend/src/pages/Analytics/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from "./Analytics"; diff --git a/packages/frontend/src/pages/EditWebsite/EditWebsite.js b/packages/frontend/src/pages/EditWebsite/EditWebsite.js deleted file mode 100644 index ec30c2ca..00000000 --- a/packages/frontend/src/pages/EditWebsite/EditWebsite.js +++ /dev/null @@ -1,117 +0,0 @@ -import { - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Button, - useDisclosure, - useToast, -} from "@chakra-ui/react"; -import * as React from "react"; -import { Link, useNavigate, useParams } from "react-router-dom"; -import { Loader } from "../../components/Loader"; -import { WebsitesForm } from "../../components/WebsitesForm"; -import { - Wrapper, - WrapperActions, - WrapperContent, - WrapperHeader, - WrapperTitle, -} from "../../components/Wrapper"; -import { client } from "../../lib/client"; -import { useWebsite } from "../../lib/hooks/use-website"; - -export function EditWebsite() { - const toast = useToast(); - const navigate = useNavigate(); - const { id } = useParams(); - const { data, isLoading, isError } = useWebsite(id); - - const { isOpen, onOpen, onClose } = useDisclosure(); - const cancelRef = React.useRef(); - const [isDeleting, setIsDeleting] = React.useState(false); - - const handleSubmit = async (data) => { - await client - .put(`/websites/${id}`, data) - .then(() => { - toast({ status: "success", title: "Website updated." }); - }) - .catch(() => { - toast({ status: "error", title: "An error has occurred.." }); - }); - }; - - const handleDelete = () => { - setIsDeleting(true); - client - .delete(`/websites/${id}`) - .then(() => { - navigate("/"); - toast({ status: "success", title: "Website deleted." }); - }) - .catch(() => { - setIsDeleting(false); - toast({ status: "error", title: "An error has occurred.." }); - }); - }; - - return ( - - - Website Details - - - - - - - - - {isLoading && } - {isError &&
Something went wrong ...
} - {!isLoading && !isError && ( - - )} -
- - - - - - Delete Website - - - - Are you sure? You can't undo this action afterwards. - - - - - - - - - -
- ); -} diff --git a/packages/frontend/src/pages/EditWebsite/index.js b/packages/frontend/src/pages/EditWebsite/index.js deleted file mode 100644 index d711c43f..00000000 --- a/packages/frontend/src/pages/EditWebsite/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from "./EditWebsite"; diff --git a/packages/frontend/src/pages/NewWebsite/NewWebsite.tsx b/packages/frontend/src/pages/NewWebsite/NewWebsite.tsx deleted file mode 100644 index 7f50863f..00000000 --- a/packages/frontend/src/pages/NewWebsite/NewWebsite.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Button, useToast } from "@chakra-ui/react"; -import { Link, useNavigate } from "react-router-dom"; -import { WebsitesForm } from "../../components/WebsitesForm"; -import { - Wrapper, - WrapperActions, - WrapperContent, - WrapperHeader, - WrapperTitle, -} from "../../components/Wrapper"; -import { client } from "../../lib/client"; - -export function NewWebsite() { - const toast = useToast(); - const navigate = useNavigate(); - - const onSuccess = () => { - toast({ status: "success", title: "Website created." }); - navigate("/"); - }; - - const onError = () => { - toast({ status: "error", title: "An error has occurred.." }); - }; - - // TODO: Fix this any. - const handleSubmit = async (data: any) => { - await client.post("/websites", data).then(onSuccess).catch(onError); - }; - - return ( - - - Create Website - - - - - - - - - - ); -} diff --git a/packages/frontend/src/pages/NewWebsite/index.ts b/packages/frontend/src/pages/NewWebsite/index.ts deleted file mode 100644 index 296abdac..00000000 --- a/packages/frontend/src/pages/NewWebsite/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./NewWebsite"; diff --git a/packages/frontend/src/pages/NotFound.tsx b/packages/frontend/src/pages/NotFound.tsx deleted file mode 100644 index a1ba159f..00000000 --- a/packages/frontend/src/pages/NotFound.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Button, Center, Flex, Heading } from "@chakra-ui/react"; -import { Link } from "react-router-dom"; - -const NotFound = () => { - return ( -
- - 404 - Page not found! - - - -
- ); -}; - -export { NotFound }; diff --git a/packages/frontend/src/pages/Setup/Setup.tsx b/packages/frontend/src/pages/Setup/Setup.tsx deleted file mode 100644 index 106d63a3..00000000 --- a/packages/frontend/src/pages/Setup/Setup.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Flex, Heading, Image, Text } from "@chakra-ui/react"; -import { SetupForm } from "./SetupForm"; - -const Setup = () => { - return ( - - - Aurora Logo - - - Welcome - - You are about to setup your first Aurora account. Please fill the - form to continue. You will be able to change these informations - later, so don't worry. - - - - - - - - - ); -}; - -export { Setup }; diff --git a/packages/frontend/src/pages/Setup/SetupForm.tsx b/packages/frontend/src/pages/Setup/SetupForm.tsx deleted file mode 100644 index 54ba5b00..00000000 --- a/packages/frontend/src/pages/Setup/SetupForm.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { - Button, - FormControl, - FormHelperText, - FormLabel, - Input, - useToast, - VStack, -} from "@chakra-ui/react"; -import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; -import { client } from "../../lib/client"; - -type SetupFormFields = { - firstname: string; - lastname: string; - email: string; - password: string; - confirmPassword: string; -}; - -const SetupForm = () => { - const toast = useToast(); - const navigate = useNavigate(); - const { - register, - handleSubmit, - formState: { isSubmitting }, - } = useForm(); - - const onSuccess = () => { - toast({ status: "success", title: "Account Created." }); - navigate("/signin", { replace: true }); - }; - - const onError = () => { - toast({ status: "error", title: "An error has occurred.." }); - }; - - // TODO: Fix this any - const onSubmit = async (data: any) => { - await client.post("/setup", data).then(onSuccess).catch(onError); - }; - - return ( - - - First Name - - - - - Last Name - - - - - Email address - - It will be used as username. - - - - Password - - Minimum 8 Characters - - - - Repeat Password - - - - - - ); -}; - -export { SetupForm }; diff --git a/packages/frontend/src/pages/Setup/index.ts b/packages/frontend/src/pages/Setup/index.ts deleted file mode 100644 index b68d7bc2..00000000 --- a/packages/frontend/src/pages/Setup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./Setup"; diff --git a/packages/frontend/src/pages/SignIn/SignIn.tsx b/packages/frontend/src/pages/SignIn/SignIn.tsx deleted file mode 100644 index d5d5a493..00000000 --- a/packages/frontend/src/pages/SignIn/SignIn.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Center, Flex, Heading, Text } from "@chakra-ui/react"; -import { Link } from "react-router-dom"; -import { SignInForm } from "./SignInForm"; - -const SignIn = () => { - return ( -
- - - Sign In - - - - - - First time here? - - - Create the first user! - - - -
- ); -}; - -export { SignIn }; diff --git a/packages/frontend/src/pages/SignIn/SignInForm.tsx b/packages/frontend/src/pages/SignIn/SignInForm.tsx deleted file mode 100644 index 7d383110..00000000 --- a/packages/frontend/src/pages/SignIn/SignInForm.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - Button, - FormControl, - FormLabel, - Input, - useToast, -} from "@chakra-ui/react"; -import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; -import { Panel } from "../../components/Panel"; -import { useAuth } from "../../lib/context/auth-context"; - -type SignInFormFields = { - email: string; - password: string; -}; - -export function SignInForm() { - const toast = useToast(); - const navigate = useNavigate(); - const { signIn } = useAuth(); - const { - register, - handleSubmit, - formState: { isSubmitting }, - } = useForm(); - - const onSuccess = () => { - navigate("/", { replace: true }); - }; - - const onError = () => { - toast({ status: "error", title: "An error has occurred.." }); - }; - - // TODO: Fix this any - const onSubmit = async (data: any) => { - await signIn(data.email, data.password).then(onSuccess).catch(onError); - }; - - return ( - - - Email address - - - - - Password - - - - - - ); -} diff --git a/packages/frontend/src/pages/SignIn/index.ts b/packages/frontend/src/pages/SignIn/index.ts deleted file mode 100644 index 61b95f9a..00000000 --- a/packages/frontend/src/pages/SignIn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./SignIn"; diff --git a/packages/frontend/src/pages/Websites/Websites.tsx b/packages/frontend/src/pages/Websites/Websites.tsx deleted file mode 100644 index 159454e9..00000000 --- a/packages/frontend/src/pages/Websites/Websites.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Button } from "@chakra-ui/react"; -import { Link } from "react-router-dom"; -import { Loader } from "../../components/Loader"; -import { - Wrapper, - WrapperActions, - WrapperContent, - WrapperHeader, - WrapperTitle, -} from "../../components/Wrapper"; -import { useWebsites } from "../../lib/hooks/use-websites"; -import { WebsitesList } from "./WebsitesList"; - -export function Websites() { - const { data, isLoading, isError } = useWebsites(); - - return ( - - - Websites - - - - - - - {isLoading && } - {isError &&

There was an error processing your request.

} - {!isLoading && !isError && } -
-
- ); -} diff --git a/packages/frontend/src/pages/Websites/WebsitesList.tsx b/packages/frontend/src/pages/Websites/WebsitesList.tsx deleted file mode 100644 index bac9f02d..00000000 --- a/packages/frontend/src/pages/Websites/WebsitesList.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { - Badge, - Button, - Flex, - Table, - TableContainer, - Tbody, - Td, - Text, - Th, - Thead, - Tr, -} from "@chakra-ui/react"; -import { Link } from "react-router-dom"; -import { Panel } from "../../components/Panel"; -import { Website } from "../../types"; - -interface WebsitesListProps { - data: Website[]; -} - -const WebsitesList = ({ data }: WebsitesListProps) => { - if (data.length === 0) { - return ( - - Here is absolute emptiness.. - - ); - } - - const items = data.map((website) => { - return ( - - {website.name} - {website.url} - - {website.is_public ? "Public" : "Private"} - - - - - - - - - ); - }); - - return ( - - - - - - - - - - - - {items} -
NameUrlStatusActions
-
-
- ); -}; - -export { WebsitesList }; diff --git a/packages/frontend/src/pages/Websites/index.ts b/packages/frontend/src/pages/Websites/index.ts deleted file mode 100644 index f007f36b..00000000 --- a/packages/frontend/src/pages/Websites/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./Websites"; diff --git a/packages/frontend/src/setupTests.js b/packages/frontend/src/setupTests.js deleted file mode 100644 index d0de870d..00000000 --- a/packages/frontend/src/setupTests.js +++ /dev/null @@ -1 +0,0 @@ -import "@testing-library/jest-dom"; diff --git a/packages/frontend/src/types.ts b/packages/frontend/src/types.ts deleted file mode 100644 index f2b969d9..00000000 --- a/packages/frontend/src/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface User { - id: string; - firstname: string; - lastname: string; - email: string; - password: string; - created_at: string; - updated_at: string; -} - -export interface Website { - id: string; - name: string; - url: string; - is_public: boolean; - user_id: string; - created_at: string; - updated_at: string; -} - -export interface Event { - id: string; - type: string; - element: string; - duration: number; - is_new_visitor: boolean; - is_new_session: boolean; - is_a_bounce: boolean; - website_id: string; - created_at: string; - updated_at: string; -} diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json deleted file mode 100644 index 9d379a3c..00000000 --- a/packages/frontend/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx" - }, - "include": ["src"] -} diff --git a/packages/tracker/package.json b/packages/tracker/package.json new file mode 100644 index 00000000..f2d2a30a --- /dev/null +++ b/packages/tracker/package.json @@ -0,0 +1,19 @@ +{ + "name": "tracker", + "private": true, + "version": "4.0.0", + "description": "Aurora's browser tracking script", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --minify --format=iife --target=es2018 --outfile=../../apps/web/public/tracker.js", + "dev": "esbuild src/index.ts --bundle --format=iife --target=es2018 --outfile=../../apps/web/public/tracker.js --watch", + "typecheck": "tsc", + "test": "vitest run" + }, + "devDependencies": { + "esbuild": "^0.27.2", + "jsdom": "^30.0.1", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/tracker/src/__tests__/index.test.ts b/packages/tracker/src/__tests__/index.test.ts new file mode 100644 index 00000000..d9ec19bb --- /dev/null +++ b/packages/tracker/src/__tests__/index.test.ts @@ -0,0 +1,2281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The tracker runs as it is imported, so every test builds the page it wants + * first and imports second. What it leaves behind is a patched History and a + * handful of listeners on globals the whole file shares, so both are recorded + * on the way in and undone afterwards — otherwise the previous test's module is + * still listening and every beacon arrives twice. + */ + +type Beacon = { url: string; body: Record }; + +const PUSH = history.pushState; +const REPLACE = history.replaceState; +const SRC = "https://cdn.example/aurora/tracker.js"; +const COLLECT = "https://cdn.example/aurora/collect"; +const DURATION = "https://cdn.example/aurora/collect/duration"; + +let beacons: Beacon[]; +let fetched: unknown[][]; +let clock: number; +let listeners: Array<[EventTarget, string, any, any]>; + +const record = (target: EventTarget) => { + const original = target.addEventListener.bind(target) as any; + + vi.spyOn(target as any, "addEventListener").mockImplementation( + (...args: any[]) => { + listeners.push([target, args[0], args[1], args[2]]); + original(...args); + } + ); +}; + +const tick = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +/** Moves the url without going through the patch the tracker installed. */ +const at = (url: string) => REPLACE.call(history, null, "", url); + +const visibility = (state: "visible" | "hidden") => { + Object.defineProperty(document, "visibilityState", { + value: state, + configurable: true, + }); +}; + +const prerendering = (value: boolean) => { + Object.defineProperty(document, "prerendering", { + value, + configurable: true, + }); +}; + +/** + * The settle window the correction rule measures against opens at the load + * event, so all three states have to be drivable: `loading` is a document whose + * app bundle has not arrived yet, `complete` plus a `load` event is one that + * has, and `interactive` is one that never will — a finished, readable page + * held off "complete" by a single stalled subresource. + */ +const readyState = (state: "loading" | "interactive" | "complete") => { + Object.defineProperty(document, "readyState", { + value: state, + configurable: true, + }); +}; + +const loaded = () => { + readyState("complete"); + window.dispatchEvent(new Event("load")); +}; + +/** A gesture, seen the way the tracker sees one: capture phase, on the way in. */ +const tap = () => { + window.dispatchEvent(new Event("pointerdown")); +}; + +const types = () => { + document.body.dispatchEvent(new Event("keydown", { bubbles: true })); +}; + +/** A router that assigns `location.hash`, or an ``. */ +const hashed = () => { + window.dispatchEvent(new HashChangeEvent("hashchange")); +}; + +/** + * A genuine `navigator.userAgentData`, GREASE and all. Chromium pads both brand + * lists with a randomised "Not A Brand" entry specifically so servers cannot + * match them literally, and `model`, `architecture` and the rest are what a + * caller that asked for everything would be handed. + */ +const HIGH_ENTROPY = { + brands: [ + { brand: "Not)A;Brand", version: "8" }, + { brand: "Chromium", version: "139" }, + { brand: "Google Chrome", version: "139" }, + ], + fullVersionList: [ + { brand: "Not)A;Brand", version: "8.0.0.0" }, + { brand: "Chromium", version: "139.0.7258.67" }, + { brand: "Google Chrome", version: "139.0.7258.67" }, + ], + mobile: false, + platform: "Windows", + platformVersion: "15.0.0", + architecture: "x86", + bitness: "64", + model: "", + uaFullVersion: "139.0.7258.67", + wow64: false, +}; + +const hinting = ( + getHighEntropyValues: (hints: string[]) => unknown, + values: Record = HIGH_ENTROPY +) => { + vi.stubGlobal("navigator", { + ...navigator, + userAgentData: { + brands: values.brands, + mobile: values.mobile, + platform: values.platform, + getHighEntropyValues, + }, + }); +}; + +const answering = (values: Record = HIGH_ENTROPY) => { + hinting(() => Promise.resolve(values), values); +}; + +/** jsdom has no referrer and no way to arrive at a page carrying one. */ +const referrer = (value: string) => { + Object.defineProperty(document, "referrer", { + value, + configurable: true, + }); +}; + +const load = async ( + attributes: { id?: string | null; src?: string | null } = {} +) => { + const { id = "wid_test", src = SRC } = attributes; + const script = document.createElement("script"); + + if (id !== null) { + script.setAttribute("aurora-id", id); + } + + if (src !== null) { + script.setAttribute("src", src); + } + + document.head.append(script); + + await import("../index"); + + return script; +}; + +const pageviews = () => beacons.filter((beacon) => beacon.url === COLLECT); +const durations = () => beacons.filter((beacon) => beacon.url === DURATION); + +/** A pageview beacon that repairs a row rather than opening one. */ +const corrections = () => + pageviews().filter((beacon) => beacon.body.corrects === true); + +/** + * The rows the events table would be holding, replayed the way /collect writes + * them: a pageview inserts under its token, a correction UPDATEs the row that + * token already names and inserts nothing. One entry here is one row in the + * pages breakdown and one contribution to the visit count. + */ +const rows = () => { + const written = new Map(); + + for (const beacon of pageviews()) { + if (beacon.body.type !== "pageview") { + continue; + } + + const vid = beacon.body.vid as string; + + if (beacon.body.corrects) { + if (written.has(vid)) { + written.set(vid, beacon.body.path as string); + } + + continue; + } + + written.set(vid, beacon.body.path as string); + } + + return [...written.values()]; +}; + +beforeEach(() => { + beacons = []; + fetched = []; + listeners = []; + clock = 0; + + vi.stubGlobal("navigator", { + language: "en-US", + doNotTrack: null, + sendBeacon: (url: string, body: string) => { + beacons.push({ url, body: JSON.parse(body) }); + return true; + }, + }); + + vi.stubGlobal("fetch", (...args: unknown[]) => { + fetched.push(args); + return Promise.resolve(); + }); + + vi.stubGlobal("screen", { width: 1920 }); + vi.spyOn(performance, "now").mockImplementation(() => clock); + + record(window); + record(document); + + document.head.innerHTML = ""; + at("/"); + visibility("visible"); + // Pinned rather than left to jsdom: it is one half of the anchor the + // correction rule measures its window from. + readyState("complete"); + vi.resetModules(); +}); + +afterEach(() => { + for (const [target, type, listener, options] of listeners) { + target.removeEventListener(type, listener, options); + } + + history.pushState = PUSH; + history.replaceState = REPLACE; + + delete (window as any).aurora; + delete (document as any).visibilityState; + delete (document as any).prerendering; + delete (document as any).readyState; + delete (document as any).currentScript; + delete (document as any).referrer; + + document.head.innerHTML = ""; + + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("bootstrap", () => { + it("sends one pageview for the current path", async () => { + at("/pricing"); + + await load(); + + expect(pageviews()).toHaveLength(1); + expect(pageviews()[0]?.body).toMatchObject({ + wid: "wid_test", + type: "pageview", + path: "/pricing", + language: "en-US", + screen: 1920, + }); + expect(pageviews()[0]?.body.vid).toEqual(expect.any(String)); + }); + + it("keeps the endpoints under the src's own sub-path", async () => { + await load({ src: "/assets/aurora.min.js" }); + + expect(beacons[0]?.url).toBe(`${location.origin}/assets/collect`); + }); + + it("omits the fields the page cannot supply and the one with no column", async () => { + await load(); + + const body = pageviews()[0]?.body ?? {}; + + // jsdom has no referrer, and every optional field is `.optional()` rather + // than `.nullish()` server-side: an omitted key, never a null. + expect(body).not.toHaveProperty("referrer"); + expect(body).not.toHaveProperty("viewport"); + expect(body).not.toHaveProperty("utm"); + }); + + it("carries the campaign off the landing url", async () => { + at("/?utm_source=hn&utm_medium=social&ref=x"); + + await load(); + + expect(pageviews()[0]?.body.utm).toEqual({ + source: "hn", + medium: "social", + }); + }); + + it("writes nothing to any client-side storage", async () => { + await load(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); + expect(document.cookie).toBe(""); + }); + + it("prefers currentScript over the query when a page has two tags", async () => { + const other = document.createElement("script"); + + other.setAttribute("aurora-id", "wid_first"); + other.setAttribute("src", SRC); + document.head.append(other); + + const script = document.createElement("script"); + + script.setAttribute("aurora-id", "wid_current"); + script.setAttribute("src", SRC); + document.head.append(script); + + Object.defineProperty(document, "currentScript", { + value: script, + configurable: true, + }); + + await import("../index"); + + expect(pageviews()[0]?.body.wid).toBe("wid_current"); + }); +}); + +describe("refusals", () => { + /** + * What a refusal looks like from the host page: no request, no History + * patch, and a global that is still callable — a page must not take a + * TypeError for a decision the visitor or the deployment made. + */ + const INERT = { sent: 0, patched: false, api: "function" }; + + const settle = async () => { + window.aurora?.("signup"); + history.pushState(null, "", "/moved"); + + await tick(); + + return { + sent: beacons.length, + patched: history.pushState !== PUSH || history.replaceState !== REPLACE, + api: typeof window.aurora, + }; + }; + + it("does nothing when the page carries no aurora script", async () => { + await import("../index"); + + expect(await settle()).toEqual(INERT); + expect(listeners).toHaveLength(0); + }); + + it("does nothing when the tag has no id", async () => { + await load({ id: null }); + + expect(await settle()).toEqual(INERT); + }); + + it("does nothing when the tag has no src to resolve an endpoint from", async () => { + await load({ src: null }); + + expect(await settle()).toEqual(INERT); + }); + + it("respects doNotTrack", async () => { + vi.stubGlobal("navigator", { ...navigator, doNotTrack: "1" }); + + await load(); + + expect(await settle()).toEqual(INERT); + }); + + it("respects globalPrivacyControl", async () => { + vi.stubGlobal("navigator", { ...navigator, globalPrivacyControl: true }); + + await load(); + + expect(await settle()).toEqual(INERT); + }); + + it("drains the stub queue even when it refuses to send", async () => { + (window as any).aurora = Object.assign(() => {}, { q: [["signup"]] }); + vi.stubGlobal("navigator", { ...navigator, doNotTrack: "1" }); + + await load(); + + expect(beacons).toHaveLength(0); + expect(window.aurora?.q).toBeUndefined(); + }); +}); + +describe("prerendering", () => { + it("sends nothing until the prerender is activated", async () => { + prerendering(true); + + await load(); + + expect(beacons).toHaveLength(0); + + prerendering(false); + document.dispatchEvent(new Event("prerenderingchange")); + + expect(pageviews()).toHaveLength(1); + }); + + it("holds an aurora() call made during the prerender", async () => { + prerendering(true); + + await load(); + + window.aurora?.("signup"); + + expect(beacons).toHaveLength(0); + + prerendering(false); + document.dispatchEvent(new Event("prerenderingchange")); + + expect(pageviews()).toHaveLength(2); + expect(pageviews()[1]?.body).toMatchObject({ + type: "event", + name: "signup", + }); + }); + + /** + * The one case the two mechanisms share. A prerender can be activated into a + * tab that is not on screen — the visitor middle-clicked the link Chrome had + * prerendered — and the visibility check that used to sit in `boot` turned + * that into a document that had refused the prerender's activation as well. + */ + it("activates a prerender announced into a hidden tab, and still defers", async () => { + prerendering(true); + visibility("hidden"); + + await load(); + + prerendering(false); + document.dispatchEvent(new Event("prerenderingchange")); + + expect(beacons).toHaveLength(0); + // Activated for real: the hooks are in place, waiting for the tab. + expect(history.pushState).not.toBe(PUSH); + + at("/deferred"); + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(rows()).toEqual(["/deferred"]); + }); + + /** + * `{ once: true }` on the listener and the cleared `pending` queue each hide + * the other: with only one of them in place a second announcement is still + * silent, and with neither, every held call is sent twice and every listener + * is registered twice. + */ + it("activates once, however many times the prerender is announced", async () => { + prerendering(true); + + await load(); + + window.aurora?.("signup"); + + prerendering(false); + document.dispatchEvent(new Event("prerenderingchange")); + document.dispatchEvent(new Event("prerenderingchange")); + + expect(pageviews()).toHaveLength(2); + expect( + pageviews().filter((beacon) => beacon.body.name === "signup") + ).toHaveLength(1); + }); +}); + +describe("navigation", () => { + it("reads location after pushState rather than the url argument", async () => { + await load(); + + history.pushState({ n: 1 }, "", "/docs/install?from=nav#top"); + await tick(); + + expect(pageviews()).toHaveLength(2); + expect(pageviews()[1]?.body.path).toBe("/docs/install"); + expect(location.pathname).toBe("/docs/install"); + }); + + it("ignores a pushState that did not move the page", async () => { + await load(); + + // The call the old code tracked verbatim, url argument and all. + history.pushState({ n: 1 }, ""); + await tick(); + + expect(pageviews()).toHaveLength(1); + }); + + it("tracks a replaceState the visitor asked for", async () => { + await load(); + + // A router that replaces rather than pushes — a tab strip, a wizard step, + // a filter that owns the URL. The gesture is what separates it from a + // redirect the app issued on its own; see the mount redirects below. + tap(); + history.replaceState(null, "", "/b"); + await tick(); + + expect(rows()).toEqual(["/", "/b"]); + expect(pageviews()[1]?.body.vid).not.toBe(pageviews()[0]?.body.vid); + expect(corrections()).toHaveLength(0); + }); + + it("collapses a burst of replaceState into one view", async () => { + await load(); + + types(); + history.replaceState(null, "", "/list?q=a"); + history.replaceState(null, "", "/list?q=ab"); + history.replaceState(null, "", "/list?q=abc"); + await tick(); + + expect(pageviews()).toHaveLength(2); + expect(pageviews()[1]?.body.path).toBe("/list"); + }); + + it("tracks a back navigation", async () => { + await load(); + + history.pushState(null, "", "/b"); + await tick(); + + at("/"); + window.dispatchEvent(new PopStateEvent("popstate")); + await tick(); + + expect(pageviews().map((beacon) => beacon.body.path)).toEqual([ + "/", + "/b", + "/", + ]); + }); + + it("does not count a hash change as a new page", async () => { + await load(); + + at("/#section"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + await tick(); + + expect(pageviews()).toHaveLength(1); + }); + + it("moves the settled hash, not the url the router was handed", async () => { + await load(); + + // What `createHashRouter` does: the hash is moved through pushState, so + // this is the patch's path and not the listener's. + history.pushState(null, "", "/#/orders?page=2"); + await tick(); + + expect(pageviews()[1]?.body.path).toBe("/#/orders"); + }); + + it("collapses a trailing slash so one page is one row", async () => { + await load(); + + history.pushState(null, "", "/docs/"); + await tick(); + history.pushState(null, "", "/docs"); + await tick(); + + expect(pageviews()).toHaveLength(2); + expect(pageviews()[1]?.body.path).toBe("/docs"); + }); + + it("mints a fresh token per view, since a repeat is silently dropped", async () => { + await load(); + + history.pushState(null, "", "/b"); + await tick(); + + const [first, second] = pageviews(); + + expect(second?.body.vid).not.toBe(first?.body.vid); + }); +}); + +/** + * A hash-routed app — Vue Router's hash mode, Angular's HashLocationStrategy, + * `createHashRouter`, or anything on a static host that cannot serve a rewrite + * — used to report one row per site, always `/`, one pageview per document + * however deep the visit went, and a bounce on every visit, because the second + * pageview that clears it never existed. The listener below was removed once as + * provably dead, and it was: `view()` compares paths, and every hash was the + * same path until /collect started keeping a route-shaped fragment. + */ +describe("hash routing", () => { + it("counts a hash route as the page it is", async () => { + await load(); + + at("/#/settings"); + hashed(); + await tick(); + + expect(rows()).toEqual(["/", "/#/settings"]); + // A real navigation, so a real token: without one the duration beacon and + // the bounce clear both address the wrong row. + expect(pageviews()[1]?.body.vid).not.toBe(pageviews()[0]?.body.vid); + }); + + it("behaves exactly like a pushState, correction window included", async () => { + await load(); + + // Inside the settle window and with no gesture, which is what makes a + // `replaceState` a mount correction. A hash moved because something asked + // it to, so this is a navigation whatever the clock says. + clock = 20; + at("/#/dashboard"); + hashed(); + await tick(); + + expect(rows()).toEqual(["/", "/#/dashboard"]); + expect(corrections()).toHaveLength(0); + }); + + it("collapses the boot rewrite from / to /#/", async () => { + await load(); + + at("/#/"); + hashed(); + await tick(); + + // The router's root is the page `/` already names. Nothing moved. + expect(pageviews()).toHaveLength(1); + }); + + it("spends one view on a back navigation that fires both events", async () => { + await load(); + + at("/#/a"); + hashed(); + await tick(); + + // Back across two hash entries fires popstate and hashchange both, and only + // the coalescing in `schedule` makes that pair one view. + at("/"); + window.dispatchEvent(new PopStateEvent("popstate")); + hashed(); + await tick(); + + expect(pageviews().map((beacon) => beacon.body.path)).toEqual([ + "/", + "/#/a", + "/", + ]); + }); + + it("leaves the route's own query alone", async () => { + at("/#/search"); + + await load(); + + types(); + at("/#/search?q=a"); + hashed(); + at("/#/search?q=ab"); + hashed(); + await tick(); + + // A search box owning the URL is not three pages, exactly as it is not on + // the pathname side. + expect(beacons).toHaveLength(1); + }); + + it("bills each hash route the time it was on screen", async () => { + await load(); + + const first = pageviews()[0]?.body.vid; + + clock = 12_000; + at("/#/a"); + hashed(); + await tick(); + + clock = 20_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(durations().map((beacon) => beacon.body)).toEqual([ + { wid: "wid_test", vid: first, duration: 12_000 }, + { wid: "wid_test", vid: pageviews()[1]?.body.vid, duration: 8_000 }, + ]); + }); + + /** + * The reason the rule is `#/` and nothing looser. OAuth implicit-flow and + * magic-link callbacks put bearer tokens in the fragment, and `path` is + * unbounded text rendered in a dashboard panel. + */ + it("never puts a fragment carrying a secret on the wire", async () => { + await load(); + + at("/callback#access_token=ya29.a0AeXRPp&refresh_token=1%2F%2F0e"); + hashed(); + await tick(); + + expect(rows()).toEqual(["/", "/callback"]); + expect(JSON.stringify(beacons)).not.toContain("access_token"); + }); + + it("holds a hash route the tab was not looking at", async () => { + await load(); + + clock = 5_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + at("/#/a"); + hashed(); + await tick(); + at("/#/b"); + hashed(); + await tick(); + + expect(pageviews()).toHaveLength(1); + + clock = 60_000; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(rows()).toEqual(["/", "/#/b"]); + }); +}); + +describe("duration", () => { + it("attributes each page's time to that page's own token", async () => { + await load(); + + const first = pageviews()[0]?.body.vid; + + clock = 60_000; + history.pushState(null, "", "/b"); + await tick(); + + const second = pageviews()[1]?.body.vid; + + clock = 90_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(durations().map((beacon) => beacon.body)).toEqual([ + { wid: "wid_test", vid: first, duration: 60_000 }, + { wid: "wid_test", vid: second, duration: 30_000 }, + ]); + }); + + it("flushes the leaving page before the arriving one is announced", async () => { + await load(); + + clock = 5_000; + history.pushState(null, "", "/b"); + await tick(); + + expect(beacons.map((beacon) => beacon.url)).toEqual([ + COLLECT, + DURATION, + COLLECT, + ]); + }); + + it("does not count time spent in a background tab", async () => { + await load(); + + clock = 10_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + clock = 400_000; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + clock = 405_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(durations().map((beacon) => beacon.body.duration)).toEqual([ + 10_000, 15_000, + ]); + }); + + it("flushes on pagehide as well, and spends one beacon doing it", async () => { + await load(); + + clock = 7_000; + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + expect(durations()).toHaveLength(1); + expect(durations()[0]?.body.duration).toBe(7_000); + + // Browsers that fire both must not cost two requests against a limiter + // that is per IP and shared with /collect. + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(durations()).toHaveLength(1); + }); + + it("never reports a view that lasted no measurable time", async () => { + await load(); + + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + expect(durations()).toHaveLength(0); + }); + + it("starts a fresh view and a fresh timer on a bfcache restore", async () => { + await load(); + + clock = 5_000; + window.dispatchEvent(new PageTransitionEvent("pagehide")); + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }) + ); + + expect(pageviews()).toHaveLength(2); + + const revisit = pageviews()[1]?.body.vid; + + expect(revisit).not.toBe(pageviews()[0]?.body.vid); + + clock = 8_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + // 3s on the restored view, not the 8s the document has been alive. + expect(durations()[1]?.body).toMatchObject({ + vid: revisit, + duration: 3_000, + }); + }); + + it("ignores a pageshow that is not a restore", async () => { + await load(); + + window.dispatchEvent(new PageTransitionEvent("pageshow")); + + expect(pageviews()).toHaveLength(1); + }); +}); + +describe("window.aurora", () => { + it("sends a custom event against the current view", async () => { + at("/checkout"); + + await load(); + + window.aurora?.("purchase", { + props: { plan: "pro" }, + revenue: { amount: 49, currency: "eur" }, + }); + + expect(pageviews()[1]?.body).toEqual({ + wid: "wid_test", + type: "event", + name: "purchase", + vid: pageviews()[0]?.body.vid, + path: "/checkout", + props: { plan: "pro" }, + revenue: { amount: 49, currency: "eur" }, + }); + }); + + it("drains calls the async stub queued before the bundle landed", async () => { + (window as any).aurora = Object.assign(() => {}, { + q: [["signup", { props: { plan: "free" } }], ["viewed_pricing"]], + }); + + await load(); + + expect(pageviews().map((beacon) => beacon.body.name)).toEqual([ + undefined, + "signup", + "viewed_pricing", + ]); + expect(window.aurora?.q).toBeUndefined(); + }); + + it("drops the properties the schema would reject, not the event", async () => { + await load(); + + window.aurora?.("signup", { + props: { plan: "pro", team: null, tags: [1] } as any, + revenue: { amount: 10 } as any, + }); + + expect(pageviews()[1]?.body).toMatchObject({ + name: "signup", + props: { plan: "pro" }, + }); + expect(pageviews()[1]?.body).not.toHaveProperty("revenue"); + }); + + it("sends nothing for a call the server would reject outright", async () => { + await load(); + + window.aurora?.(""); + (window.aurora as any)?.(); + (window.aurora as any)?.(42); + + expect(pageviews()).toHaveLength(1); + }); + + it("follows the view, so an event names the page it happened on", async () => { + await load(); + + history.pushState(null, "", "/thanks"); + await tick(); + + window.aurora?.("purchase"); + + expect(pageviews()[2]?.body).toMatchObject({ + path: "/thanks", + vid: pageviews()[1]?.body.vid, + }); + }); +}); + +describe("host page safety", () => { + it("keeps history working for the page it is patched into", async () => { + await load(); + + history.pushState({ step: 2 }, "", "/checkout/payment"); + + expect(location.pathname).toBe("/checkout/payment"); + expect(history.state).toEqual({ step: 2 }); + + // Drained inside the test that armed it. The patched history schedules a + // task, and afterEach can unregister a listener but not cancel a timer: a + // navigation left pending here fires against the next test's beacon array, + // from a module instance that test never loaded. + await tick(); + }); + + it("lets the page's own pushState error through untouched", async () => { + await load(); + + // A cross-origin url is a SecurityError the caller has to see. + expect(() => + history.pushState(null, "", "https://elsewhere.test/") + ).toThrow(/cannot update history/); + }); + + it("does not run twice when the page carries the bundle twice", async () => { + await load(); + + // A tag-manager injection landing on top of a hardcoded snippet. Both + // copies see the same tag, and the second used to wrap the first's patched + // history and mint its own token for every view. + vi.resetModules(); + await import("../index"); + + history.pushState(null, "", "/b"); + await tick(); + + expect(pageviews().map((beacon) => beacon.body.path)).toEqual(["/", "/b"]); + }); + + it("surfaces nothing when every transport is broken", async () => { + vi.stubGlobal("navigator", { + ...navigator, + sendBeacon: () => { + throw new Error("blocked"); + }, + }); + vi.stubGlobal("fetch", () => { + throw new Error("blocked"); + }); + + await expect(load()).resolves.toBeDefined(); + + expect(() => history.pushState(null, "", "/b")).not.toThrow(); + expect(() => window.aurora?.("signup")).not.toThrow(); + expect(() => + window.dispatchEvent(new PageTransitionEvent("pagehide")) + ).not.toThrow(); + + await tick(); + }); +}); + +describe("background tabs", () => { + /** + * A tab opened in the background is a visit, not a prerender: cmd-click, + * middle-click, "open link in new tab", a minimised window, or simply a load + * that finished after the visitor tabbed away — which on a slow connection is + * the ordinary case, since this file is a third-party script that lands late. + * The document used to be refused outright here, taking every `aurora()` call + * with it. + */ + it("records a background tab the visitor opens, against the route it settled on", async () => { + referrer("https://news.ycombinator.com/item?id=1"); + visibility("hidden"); + + await load(); + + expect(beacons).toHaveLength(0); + // Activated, unlike before: the hooks are the whole point of deferring + // rather than refusing. + expect(history.pushState).not.toBe(PUSH); + + // The tab settles while nobody is looking. None of it is a pageview. + clock = 30_000; + history.pushState(null, "", "/onboarding"); + await tick(); + + expect(beacons).toHaveLength(0); + + clock = 60_000; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(rows()).toEqual(["/onboarding"]); + // One arrival, credited to the place it came from: `landed` is still false + // when the held view is finally recorded. + expect(pageviews()[0]?.body.referrer).toBe("https://news.ycombinator.com"); + + clock = 70_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + // Ten seconds on screen, not the seventy the document has been alive. + expect(durations().map((beacon) => beacon.body.duration)).toEqual([10_000]); + }); + + it("sends nothing at all for a background tab that is never opened", async () => { + visibility("hidden"); + + await load(); + + history.pushState(null, "", "/b"); + await tick(); + history.replaceState(null, "", "/c"); + await tick(); + + clock = 1_800_000; + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + // Not one beacon, not even a duration: the view was never opened, so there + // is no token to time and nothing to report against it. + expect(beacons).toHaveLength(0); + }); + + /** + * The defect the old refusal hid. `install(api)` drains the stub's queue into + * `pending` before `boot` runs, and the drain used to happen at the end of + * `activate()` — with no view, so `event()` rejected every held call and the + * queue was emptied behind it. A `revenue` conversion fired from the page head + * of a backgrounded tab was destroyed rather than delayed. + */ + it("holds a queued call until the tab has a view to name it", async () => { + (window as any).aurora = Object.assign(() => {}, { + q: [["purchase", { revenue: { amount: 49, currency: "eur" } }]], + }); + + visibility("hidden"); + at("/checkout"); + + await load(); + + expect(beacons).toHaveLength(0); + + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(pageviews()).toHaveLength(2); + expect(pageviews()[1]?.body).toMatchObject({ + type: "event", + name: "purchase", + path: "/checkout", + vid: pageviews()[0]?.body.vid, + revenue: { amount: 49, currency: "eur" }, + }); + }); + + /** + * The symmetric half of the test above, which was still broken. That one + * covers a call made *before* the bundle landed; this is one made *after* the + * document has fully activated, in a tab that is still hidden. `ready` is + * true by then, so it walked past `api`'s queue and into `event()`'s + * `!vid || !path` return and was destroyed — the conversion and its revenue + * gone permanently, with the deferred pageview arriving later as if nothing + * had happened. + */ + it("holds a call made after activation in a still-hidden tab", async () => { + visibility("hidden"); + at("/checkout"); + + await load(); + + window.aurora?.("newsletter_signup", { + revenue: { amount: 5, currency: "usd" }, + }); + + expect(beacons).toHaveLength(0); + + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(pageviews()).toHaveLength(2); + expect(pageviews()[1]?.body).toMatchObject({ + type: "event", + name: "newsletter_signup", + path: "/checkout", + vid: pageviews()[0]?.body.vid, + revenue: { amount: 5, currency: "usd" }, + }); + }); + + /** + * The queue `view()` drains is only emptied by a view happening, so a tab that + * never comes forward fills it for the life of the document. Bounded at 32 so + * a heartbeat event on a timer cannot grow it without limit on somebody + * else's page. + */ + it("bounds the held queue rather than growing it in a tab nobody opens", async () => { + visibility("hidden"); + at("/dashboard"); + + await load(); + + for (let i = 0; i < 100; i += 1) { + window.aurora?.(`heartbeat_${i}`); + } + + expect(beacons).toHaveLength(0); + + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + // One pageview plus the 32 that were held; the rest were dropped as they + // were made rather than retained. + expect(pageviews()).toHaveLength(33); + expect(pageviews()[1]?.body.name).toBe("heartbeat_0"); + expect(pageviews()[32]?.body.name).toBe("heartbeat_31"); + }); + + it("records a bfcache restore into a hidden tab when the tab comes back", async () => { + await load(); + + clock = 5_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + // Restored underneath the visitor — a back navigation in another window + // brought this document back while its tab was still in the background. + at("/restored"); + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }) + ); + + expect(rows()).toEqual(["/"]); + + clock = 20_000; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(rows()).toEqual(["/", "/restored"]); + + clock = 25_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + // 5s on the first view and 5s on the restored one: none of the fifteen + // seconds the document spent in the background is billed to either. + expect(durations().map((beacon) => beacon.body.duration)).toEqual([ + 5_000, 5_000, + ]); + }); + + /** + * The tab is hidden, the router keeps going, and nobody is looking. Every + * one of those routes used to be a pageview — a row in the pages breakdown, + * a cleared bounce for the session, and a slot of a rate limit shared with + * the beacons that pay for it. + */ + it("holds a background navigation until the tab is looked at", async () => { + await load(); + + clock = 10_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + history.pushState(null, "", "/b"); + await tick(); + history.pushState(null, "", "/c"); + await tick(); + + expect(pageviews()).toHaveLength(1); + + clock = 600_000; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + // One view, for the route the tab actually settled on. + expect(pageviews().map((beacon) => beacon.body.path)).toEqual(["/", "/c"]); + + clock = 610_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(durations().map((beacon) => beacon.body.duration)).toEqual([ + 10_000, 10_000, + ]); + }); + + it("books no duration for a view that was never on screen", async () => { + await load(); + + clock = 10_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + clock = 60_000; + history.pushState(null, "", "/b"); + await tick(); + + // Twenty minutes in the background and then discarded. The timer used to + // start with the view, so /b was billed all of it. + clock = 1_260_000; + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + expect(pageviews()).toHaveLength(1); + expect(durations().map((beacon) => beacon.body.duration)).toEqual([10_000]); + }); + + /** + * The half of the deferral this listener cannot decide on its own. Every + * other test in this file drains the pending navigation before flipping the + * tab, which is the order a foreground tab always gets; a hidden one never + * does. Browsers clamp `setTimeout` in a background tab to one a second, and + * Chrome to one a *minute* past five minutes hidden, so the task `schedule()` + * deferred cannot run until the tab is foregrounded and `visibilitychange` + * arrives first every time. + */ + it("lets the pending navigation decide, rather than racing it to a row", async () => { + await load(); + + clock = 200; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + // The auth guard fires while nobody is looking. Deliberately not drained: + // the timer is still throttled at this point. + clock = 400; + history.replaceState(null, "", "/login"); + + clock = 700; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + await tick(); + + // One arrival, one row, and it names the page the visitor is looking at. + // Deciding here instead booked a second view for `/login` and left the + // correction with nothing to move — two rows, the second under `direct`. + expect(rows()).toEqual(["/login"]); + expect(corrections()).toHaveLength(1); + expect(pageviews().filter((beacon) => !beacon.body.corrects)).toHaveLength( + 1 + ); + }); + + it("sends no event for a view it is still holding back", async () => { + await load(); + + clock = 5_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + // A bfcache restore into a background tab: the view is reset and held, so + // there is no path to name and `path` is `min(1)` server-side. + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }) + ); + + const before = beacons.length; + + window.aurora?.("signup"); + + expect(beacons).toHaveLength(before); + }); +}); + +/** + * A router that rewrites the URL while its first route settles — an auth guard, + * a locale prefix, a boot redirect — used to book a second pageview a few dozen + * milliseconds after the first. That is two rows for one arrival, a phantom row + * for a path nobody read, and, because the second view retroactively clears the + * session's bounce, a structural bounce rate of zero for every site that does + * it. The second beacon also carried no referrer, so the row holding the path + * the visitor actually landed on was filed under `direct`. + * + * The rule that separates the two is in `correcting()`: a `replaceState`, with + * no gesture and no custom event against the view it is replacing, inside a + * window that opens when the document finishes loading. + */ +describe("mount redirects", () => { + it("books one pageview for an auth guard's redirect", async () => { + await load(); + + const opening = pageviews()[0]?.body.vid; + + // Where a React mount redirect lands: past any "no measurable time" floor, + // and long before the visitor could have asked for anything. + clock = 50; + history.replaceState(null, "", "/login"); + await tick(); + + expect(rows()).toEqual(["/login"]); + expect(pageviews()[1]?.body).toEqual({ + wid: "wid_test", + type: "pageview", + vid: opening, + path: "/login", + corrects: true, + }); + }); + + it("leaves a single-page visit a bounce, and one visit", async () => { + await load(); + + clock = 50; + history.replaceState(null, "", "/login"); + await tick(); + + clock = 40_000; + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + // One row is one visit: nothing here clears a bounce server-side, because + // nothing here is a second pageview. + expect(rows()).toHaveLength(1); + expect(pageviews().filter((beacon) => !beacon.body.corrects)).toHaveLength( + 1 + ); + // And the visitor's time is still the view's own, undivided: the redirect + // neither flushed the clock nor started a second one. + expect(durations().map((beacon) => beacon.body)).toEqual([ + { wid: "wid_test", vid: pageviews()[0]?.body.vid, duration: 40_000 }, + ]); + }); + + it("keeps a locale prefix rewrite one page rather than two", async () => { + at("/pricing"); + + await load(); + + clock = 20; + history.replaceState(null, "", "/en/pricing"); + await tick(); + + expect(rows()).toEqual(["/en/pricing"]); + }); + + it("follows a redirect chain without spending a row on each hop", async () => { + await load(); + + clock = 40; + history.replaceState(null, "", "/login"); + await tick(); + + clock = 90; + history.replaceState(null, "", "/login/sso"); + await tick(); + + expect(rows()).toEqual(["/login/sso"]); + expect(corrections()).toHaveLength(2); + }); + + it("corrects the view a click opened rather than the click's own page", async () => { + await load(); + + // The visitor clicks through to a guarded route: the gesture belongs to the + // pushState, and the redirect that follows belongs to the view it opened. + clock = 8_000; + tap(); + history.pushState(null, "", "/admin"); + await tick(); + + clock = 8_008; + history.replaceState(null, "", "/login"); + await tick(); + + expect(rows()).toEqual(["/", "/login"]); + expect(corrections()[0]?.body.vid).toBe(pageviews()[1]?.body.vid); + }); + + it("never reads a pushState as a correction, however fast it lands", async () => { + await load(); + + clock = 3; + history.pushState(null, "", "/dashboard"); + await tick(); + + expect(rows()).toEqual(["/", "/dashboard"]); + expect(corrections()).toHaveLength(0); + }); + + it("never reads a burst containing a push as a correction", async () => { + await load(); + + // A router that pushes the route and then replaces the URL to normalise it + // has navigated, and the coalescing must not lose that. + clock = 5; + history.pushState(null, "", "/docs"); + history.replaceState(null, "", "/docs/install"); + await tick(); + + expect(rows()).toEqual(["/", "/docs/install"]); + expect(corrections()).toHaveLength(0); + }); + + it("books a genuine replaceState navigation once the window has closed", async () => { + loaded(); + + await load(); + + // Seconds later, with nothing else to say it was the visitor's doing: a + // slideshow advancing, a wizard stepping itself, a poll routing on data. + clock = 4_000; + history.replaceState(null, "", "/step-2"); + await tick(); + + expect(rows()).toEqual(["/", "/step-2"]); + expect(corrections()).toHaveLength(0); + // A real second view, which means the leaving one is billed on its way out. + expect(durations()[0]?.body).toMatchObject({ + vid: pageviews()[0]?.body.vid, + duration: 4_000, + }); + }); + + it("books a replaceState the visitor asked for inside the window", async () => { + await load(); + + clock = 40; + tap(); + history.replaceState(null, "", "/b"); + await tick(); + + expect(rows()).toEqual(["/", "/b"]); + }); + + it("leaves a search box alone, and stops correcting once it is typed in", async () => { + at("/list"); + + await load(); + + // The query moves and the path does not: nothing to send either way. + clock = 30; + types(); + history.replaceState(null, "", "/list?q=a"); + history.replaceState(null, "", "/list?q=ab"); + history.replaceState(null, "", "/list?q=abc"); + await tick(); + + expect(beacons).toHaveLength(1); + + // The same box, now routing to a result. The gesture is what makes it a + // navigation even though the window is still open. + clock = 60; + history.replaceState(null, "", "/list/42"); + await tick(); + + expect(rows()).toEqual(["/list", "/list/42"]); + expect(corrections()).toHaveLength(0); + }); + + it("stops correcting a view the page has reported an event against", async () => { + await load(); + + clock = 20; + window.aurora?.("signup"); + + clock = 40; + history.replaceState(null, "", "/thanks"); + await tick(); + + // The page said something happened on that view, so the view was real. + expect(rows()).toEqual(["/", "/thanks"]); + expect(corrections()).toHaveLength(0); + }); + + /** + * The measurement that set the window: the same `` mount + * redirect lands 22ms after this script's first view unthrottled and 1703ms + * after it on a throttled connection, because the difference is the app + * bundle arriving. Anchored at the load event instead, both are within a few + * dozen milliseconds of it. + */ + it("still corrects a redirect from a bundle that took seconds to arrive", async () => { + readyState("loading"); + + await load(); + + clock = 1_703; + history.replaceState(null, "", "/login"); + await tick(); + + expect(rows()).toEqual(["/login"]); + }); + + it("measures the window from the load event, not from the view", async () => { + readyState("loading"); + + await load(); + + clock = 2_800; + loaded(); + + // 577ms past the load event is the slowest true positive measured: a guard + // awaiting a 400ms /session call on a throttled connection. + clock = 3_377; + history.replaceState(null, "", "/login"); + await tick(); + + expect(rows()).toEqual(["/login"]); + }); + + it("closes the window a second after the document has loaded", async () => { + readyState("loading"); + + await load(); + + clock = 2_800; + loaded(); + + clock = 3_900; + history.replaceState(null, "", "/login"); + await tick(); + + expect(rows()).toEqual(["/", "/login"]); + expect(corrections()).toHaveLength(0); + }); + + /** + * The window a document that has not loaded yet gets is open by design — + * "an app that has not run yet cannot have redirected yet" — and it used to + * be open with no end at all. `readyState` only reaches "complete" once every + * subresource has resolved, so a dead image host, a hanging ad iframe or a + * font that never arrives leaves a perfectly readable page at "interactive" + * for as long as it is open, and `load` never fires to close anything. + */ + it("stops treating a document that never loads as one that is still loading", async () => { + readyState("interactive"); + + await load(); + + clock = 30_000; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + // A background token refresh, ten minutes in. A hidden tab can produce no + // gesture and this page has fired no custom event, so those two halves of + // the rule can never falsify it and only the clock is left. + clock = 630_000; + history.replaceState(null, "", "/session/refresh"); + await tick(); + + // The row still names the page the visitor spent thirty seconds reading. + expect(rows()).toEqual(["/"]); + expect(corrections()).toHaveLength(0); + + // And the beacon spend is bounded with it: every further rewrite from a tab + // nobody is looking at is a POST against a per-IP limit shared with the + // pageviews that pay for it. + const spent = beacons.length; + + for (let step = 0; step < 5; step += 1) { + clock += 60_000; + history.replaceState(null, "", `/session/refresh/${step}`); + await tick(); + } + + expect(beacons).toHaveLength(spent); + }); + + /** + * A boot-time URL scrub — a consent or analytics script stripping `?fbclid` + * or `?gclid` on mount, a router normalising a default query parameter — is + * a no-gesture `replaceState` inside the window that does not move the page. + * It satisfies every clause of the correction rule and there is nothing to + * correct. + */ + it("spends nothing correcting a rewrite that did not move the page", async () => { + at("/?fbclid=IwAR0abc"); + + await load(); + + clock = 30; + history.replaceState(null, "", "/"); + await tick(); + + expect(beacons).toHaveLength(1); + expect(corrections()).toHaveLength(0); + }); + + /** + * The gesture listeners are the discriminator that survives an arbitrary + * delay, and a bubbling listener would never see the gestures that matter + * most: menu, dialog and dropdown primitives — Radix's, which this repo's own + * dashboard is built out of — call `stopPropagation()` on `pointerdown` as a + * matter of course. In the bubble phase the click never reaches `window`, and + * the navigation the visitor asked for is swallowed as a mount correction. + */ + it("sees a gesture the page stopped from propagating", async () => { + await load(); + + const item = document.createElement("button"); + + document.body.append(item); + item.addEventListener("pointerdown", (gesture) => { + gesture.stopPropagation(); + }); + + try { + clock = 40; + item.dispatchEvent(new Event("pointerdown", { bubbles: true })); + history.replaceState(null, "", "/menu/orders"); + await tick(); + + expect(rows()).toEqual(["/", "/menu/orders"]); + expect(corrections()).toHaveLength(0); + } finally { + item.remove(); + } + }); + + it("listens for those gestures passively, so it can never jank one", async () => { + await load(); + + const gestures = listeners.filter( + ([target, type]) => + target === window && + ["pointerdown", "keydown", "touchstart"].includes(type) + ); + + expect(gestures).toHaveLength(3); + + for (const [, , , options] of gestures) { + expect(options).toEqual({ capture: true, passive: true }); + } + }); + + /** + * The async stub queues a call into `q` before the bundle has landed, so that + * call was made before any view existed and says nothing about whether the + * view that eventually arrives is real. Counting it as evidence disarmed the + * whole rule for every site using the documented snippet. + */ + it("does not let a call the stub queued stand in for the view's own", async () => { + (window as any).aurora = Object.assign(() => {}, { q: [["identify"]] }); + + await load(); + + clock = 40; + history.replaceState(null, "", "/login"); + await tick(); + + expect(rows()).toEqual(["/login"]); + expect(corrections()).toHaveLength(1); + }); + + it("repairs a row whose tab was backgrounded before the redirect landed", async () => { + await load(); + + clock = 200; + visibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + clock = 400; + history.replaceState(null, "", "/login"); + await tick(); + + expect(rows()).toEqual(["/login"]); + + clock = 5_000; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + // Nothing left to record: the page the tab comes back to is the page the + // row already names. Holding the repair back is what would have made this + // a second view. + expect(rows()).toEqual(["/login"]); + expect(pageviews().filter((beacon) => !beacon.body.corrects)).toHaveLength( + 1 + ); + }); + + it("holds a redirect in a hidden tab back rather than correcting it", async () => { + visibility("hidden"); + + await load(); + + clock = 50; + history.replaceState(null, "", "/login"); + await tick(); + + expect(beacons).toHaveLength(0); + + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + // One view for the route the redirect settled on, and no correction: there + // was never a row naming `/` to repair. + expect(rows()).toEqual(["/login"]); + expect(corrections()).toHaveLength(0); + }); +}); + +describe("acquisition", () => { + it("sends the referrer's origin and not the path or query on it", async () => { + referrer( + "https://mail.example.com/inbox?email=jane.doe%40acme.com&token=sk_live_9f3a2b" + ); + + await load(); + + // Only the host is ever stored, so the rest is a search phrase, a private + // thread or a magic-link token crossing the network for nothing. + expect(pageviews()[0]?.body.referrer).toBe("https://mail.example.com"); + }); + + it("drops a referrer the server could not have stored anyway", async () => { + referrer("android-app://com.google.android.gm/"); + + await load(); + + expect(pageviews()[0]?.body).not.toHaveProperty("referrer"); + }); + + it("credits the arrival once and not once per page of the visit", async () => { + referrer("https://news.ycombinator.com/item?id=1"); + + await load(); + + history.pushState(null, "", "/pricing"); + await tick(); + history.pushState(null, "", "/docs"); + await tick(); + + // `document.referrer` never moves within a document, so re-reading it made + // a four-page SPA visit four referrals where an MPA visit is one. + expect(pageviews()[0]?.body.referrer).toBe("https://news.ycombinator.com"); + expect(pageviews()[1]?.body).not.toHaveProperty("referrer"); + expect(pageviews()[2]?.body).not.toHaveProperty("referrer"); + }); + + /** + * The referrer is snapshotted against a deferred view by `landed` and the + * campaign was not, so the two disagreed about exactly one document: a + * campaign link cmd-clicked, middle-clicked or opened into a background tab. + * The view is held until the visitor looks at the tab, and by then the + * router's mount rewrite has taken `?utm_source` off `location` — so the same + * click reported `campaign` in the foreground and `direct` in the background. + */ + it("keeps the campaign a deferred first view arrived on", async () => { + at("/?utm_source=newsletter&utm_campaign=launch"); + visibility("hidden"); + + await load(); + + // The router normalising its URL on mount, with nobody looking yet. + clock = 2_000; + history.replaceState(null, "", "/"); + await tick(); + + clock = 60_000; + visibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(pageviews()).toHaveLength(1); + expect(pageviews()[0]?.body.utm).toEqual({ + source: "newsletter", + campaign: "launch", + }); + }); + + it("reads the campaign per view, since an SPA can route into one", async () => { + await load(); + + history.pushState(null, "", "/promo?utm_source=newsletter"); + await tick(); + + expect(pageviews()[1]?.body.utm).toEqual({ source: "newsletter" }); + }); +}); + +describe("payload bounds", () => { + it("clamps a custom event name to what the schema accepts", async () => { + await load(); + + window.aurora?.("x".repeat(500)); + + expect(pageviews()[1]?.body.name).toHaveLength(200); + }); + + it("clamps an over-long referrer rather than losing the pageview", async () => { + referrer(`https://${"a".repeat(1200)}.example/`); + + await load(); + + expect(pageviews()[0]?.body.referrer).toHaveLength(1024); + }); + + it("clamps an over-long language tag", async () => { + vi.stubGlobal("navigator", { ...navigator, language: "en-GB-".repeat(40) }); + + await load(); + + expect(pageviews()[0]?.body.language).toHaveLength(64); + }); + + it("omits a screen width the column would store as null", async () => { + vi.stubGlobal("screen", { width: 0 }); + + await load(); + + expect(pageviews()[0]?.body).not.toHaveProperty("screen"); + }); + + it("clamps a duration to the range the check constraint allows", async () => { + await load(); + + // A tab left open for more than a day, or a clock that jumped. + clock = 90_000_000; + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + expect(durations()[0]?.body.duration).toBe(86_400_000); + }); +}); + +/** + * `Accept-CH` cannot work on this origin — a browser stores the ask only from a + * top-level navigation response, and the collector serves nothing but + * third-party beacons — so `Sec-CH-UA-Platform-Version` never arrives and the + * server falls back to a UA string that UA reduction has frozen: every Chromium + * Mac permanently "10", Windows 11 indistinguishable from 10, every Android + * "10". `getHighEntropyValues` reads the same value out of the browser + * in-process, with no header negotiation to fail. + */ +describe("client hints", () => { + it("sends nothing at all on a browser that has no userAgentData", async () => { + // Safari and Firefox, and every insecure context. This is the whole + // population that must be unchanged by any of it. + await load(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(pageviews()).toHaveLength(2); + + for (const beacon of pageviews()) { + expect(beacon.body).not.toHaveProperty("platformVersion"); + } + }); + + it("carries the platform version once the browser has answered", async () => { + answering(); + + await load(); + await tick(); + + history.pushState(null, "", "/b"); + await tick(); + + // Raw, not reduced: on Windows the platform version is an index into a + // table Microsoft publishes — 15 is the release 11 — and only the server + // holds that table. + expect(pageviews()[1]?.body.platformVersion).toBe("15.0.0"); + }); + + it("names the view it belongs to, events included", async () => { + answering(); + + await load(); + await tick(); + + window.aurora?.("signup"); + + expect(pageviews()[1]?.body).toMatchObject({ + type: "event", + name: "signup", + platformVersion: "15.0.0", + }); + }); + + it("sends the first view without waiting for the promise", async () => { + let answer: ((values: unknown) => void) | undefined; + + hinting( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + + await load(); + await tick(); + + // The view a fast bounce depends on went out on its own task, with the + // frozen answer the UA string gives and no hint at all. + expect(pageviews()).toHaveLength(1); + expect(pageviews()[0]?.body).not.toHaveProperty("platformVersion"); + + answer?.({ ...HIGH_ENTROPY, platformVersion: "14.6.1" }); + await tick(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(pageviews()[1]?.body.platformVersion).toBe("14.6.1"); + }); + + /** + * The ask itself, and not only what survives it. Everything else + * `getHighEntropyValues` can return is entropy this file has no column for — + * `model` settles a question `?0` plus the platform already settles, + * `fullVersionList` is reduced to a major server-side, and `architecture`, + * `bitness` and `wow64` have nowhere at all to go — so a widened ask is a + * widened fingerprinting surface that the beacon assertions below cannot see, + * because the extra values would be fetched and then dropped. + */ + it("asks for the one hint it has a column for, once per document", async () => { + const asked: string[][] = []; + + hinting((hints) => { + asked.push(hints); + + return Promise.resolve(HIGH_ENTROPY); + }); + + await load(); + await tick(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(asked).toEqual([["platformVersion"]]); + }); + + it("discards the GREASE brands and everything else it did not ask for", async () => { + answering({ ...HIGH_ENTROPY, model: "SM-X200" }); + + await load(); + await tick(); + + history.pushState(null, "", "/b"); + await tick(); + + // One key, not the dictionary: a brand list is reduced to a major + // server-side anyway, `model` settles a question `?0` plus the platform + // already settles, and the rest have no column at all. + expect(pageviews()[1]?.body).toEqual({ + wid: "wid_test", + type: "pageview", + vid: expect.any(String), + path: "/b", + language: "en-US", + screen: 1920, + platformVersion: "15.0.0", + }); + expect(JSON.stringify(beacons)).not.toMatch(/brand/i); + }); + + it("clamps an over-long answer to what the schema takes", async () => { + answering({ ...HIGH_ENTROPY, platformVersion: "9".repeat(200) }); + + await load(); + await tick(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(pageviews()[1]?.body.platformVersion).toHaveLength(32); + }); + + it("ignores an answer that is not a non-empty string", async () => { + for (const platformVersion of ["", 15, null, { major: 15 }]) { + beacons = []; + answering({ ...HIGH_ENTROPY, platformVersion }); + + await load(); + await tick(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(pageviews()[1]?.body).not.toHaveProperty("platformVersion"); + + vi.resetModules(); + delete (window as any).aurora; + history.pushState = PUSH; + history.replaceState = REPLACE; + at("/"); + } + }); + + it("keeps the whole tracker when userAgentData throws", async () => { + // This runs inside `activate()`, ahead of the history patch and every + // listener, so a throw escaping it costs the document its tracker rather + // than one field. + hinting(() => { + throw new Error("blocked"); + }); + + await load(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(rows()).toEqual(["/", "/b"]); + expect(pageviews()[1]?.body).not.toHaveProperty("platformVersion"); + }); + + it("swallows a rejection rather than logging in the host's console", async () => { + const unhandled = vi.fn(); + + process.on("unhandledRejection", unhandled); + + try { + hinting(() => Promise.reject(new Error("NotAllowedError"))); + + await load(); + await tick(); + await tick(); + + history.pushState(null, "", "/b"); + await tick(); + + expect(rows()).toEqual(["/", "/b"]); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + } + }); + + it("survives a userAgentData that is not the shape it claims", async () => { + for (const broken of [ + { userAgentData: {} }, + { userAgentData: { getHighEntropyValues: "yes" } }, + { userAgentData: null }, + ]) { + beacons = []; + vi.stubGlobal("navigator", { ...navigator, ...broken }); + + await load(); + await tick(); + + expect(pageviews()).toHaveLength(1); + expect(pageviews()[0]?.body).not.toHaveProperty("platformVersion"); + + vi.resetModules(); + delete (window as any).aurora; + history.pushState = PUSH; + history.replaceState = REPLACE; + } + }); +}); + +describe("hostile pages", () => { + it("stays inert, and callable, behind a blob: src", async () => { + // How a tag manager or a CSP-nonce setup injects a bundle. Nothing can be + // resolved against an opaque path, and the throw used to happen before + // `window.aurora` existed. + await load({ src: "blob:https://host.example/2f8a-1" }); + + expect(beacons).toHaveLength(0); + expect(typeof window.aurora).toBe("function"); + expect(() => window.aurora?.("signup")).not.toThrow(); + }); + + it("resolves the endpoints against the base the browser used", async () => { + at("/blog/post"); + + const base = document.createElement("base"); + + base.setAttribute("href", `${location.origin}/assets/`); + document.head.append(base); + + await load({ src: "tracker.js" }); + + expect(beacons[0]?.url).toBe(`${location.origin}/assets/collect`); + }); + + it("leaves a stubbed-out pushState detectable and unarmed", async () => { + // Consent tools and anti-tracking scriptlets do this. A wrapper over it + // reports `typeof history.pushState === "function"` to a router that + // feature-detects, then throws from inside the router's own stack. + (history as any).pushState = null; + + await load(); + + expect(history.pushState).toBeNull(); + expect(pageviews()).toHaveLength(1); + + // The half that is still there is still tracked. + tap(); + history.replaceState(null, "", "/b"); + await tick(); + + expect(pageviews()[1]?.body.path).toBe("/b"); + }); + + it("keeps tracking navigation after a setTimeout that threw", async () => { + await load(); + + const real = globalThis.setTimeout; + let broken = true; + + vi.stubGlobal("setTimeout", (fn: any, ms?: any) => { + if (broken) { + broken = false; + throw new Error("no timers here"); + } + + return real(fn, ms); + }); + + history.pushState(null, "", "/a"); + await tick(); + history.pushState(null, "", "/b"); + await tick(); + + // One navigation lost, not every navigation after it: the coalescing latch + // used to be set before the timer and cleared only inside it. + expect(pageviews().map((beacon) => beacon.body.path)).toEqual(["/", "/b"]); + }); + + it("still boots when window.aurora cannot be assigned", async () => { + const stub = vi.fn(); + + Object.defineProperty(window, "aurora", { + value: stub, + writable: false, + configurable: true, + }); + + await load(); + + expect(window.aurora).toBe(stub); + expect(pageviews()).toHaveLength(1); + }); + + it("survives a junk entry in the stub queue", async () => { + (window as any).aurora = Object.assign(() => {}, { + q: [null, ["signup"]], + }); + + await load(); + + expect(pageviews().map((beacon) => beacon.body.name)).toEqual([ + undefined, + "signup", + ]); + }); + + it("sends the pageview even when the clock and the screen are gone", async () => { + const now = vi.spyOn(performance, "now").mockImplementation(() => { + throw new Error("blocked"); + }); + + vi.stubGlobal("screen", undefined); + + await load(); + + now.mockImplementation(() => clock); + + expect(pageviews()).toHaveLength(1); + expect(pageviews()[0]?.body.path).toBe("/"); + expect(pageviews()[0]?.body).not.toHaveProperty("screen"); + }); + + it("keeps timing on a page that took performance.now away", async () => { + const now = vi.spyOn(performance, "now").mockImplementation(() => { + throw new Error("blocked"); + }); + + await load(); + + // Real elapsed time: the fallback clock is Date.now(), which is why this + // is the one test in the file that cannot use the fake one. + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + window.dispatchEvent(new PageTransitionEvent("pagehide")); + + now.mockImplementation(() => clock); + + expect(durations()).toHaveLength(1); + expect(durations()[0]?.body.duration).toBeGreaterThan(0); + }); + + it("mints distinct tokens without crypto.randomUUID", async () => { + // Secure-context only, and a self-hosted install over plain http is a + // supported deployment. A repeated token collides on the unique index and + // the pageview is dropped server-side. + vi.stubGlobal("crypto", {}); + + await load(); + + history.pushState(null, "", "/b"); + await tick(); + + const [first, second] = pageviews(); + + expect(first?.body.vid).toEqual(expect.any(String)); + expect(second?.body.vid).not.toBe(first?.body.vid); + }); + + it("mints distinct tokens when crypto.randomUUID throws", async () => { + vi.stubGlobal("crypto", { + randomUUID: () => { + throw new Error("insecure context"); + }, + }); + + await load(); + + history.pushState(null, "", "/b"); + await tick(); + + const [first, second] = pageviews(); + + // A throw between `path` and `vid` used to leave the new page addressed by + // the previous page's token. + expect(pageviews()).toHaveLength(2); + expect(second?.body.vid).not.toBe(first?.body.vid); + }); + + it("sends nothing at all when it could not finish starting up", async () => { + // A hardened page that made history non-writable: `activate()` throws + // before the first view, so there is no token and no path, and every + // beacon from here is a guaranteed 422 against a shared rate limit. + Object.defineProperty(history, "pushState", { + value: PUSH, + writable: false, + configurable: true, + }); + + try { + await load(); + + expect(pageviews()).toHaveLength(0); + + window.aurora?.("signup"); + + expect(beacons).toHaveLength(0); + } finally { + Object.defineProperty(history, "pushState", { + value: PUSH, + writable: true, + configurable: true, + }); + } + }); +}); diff --git a/packages/tracker/src/__tests__/payload.test.ts b/packages/tracker/src/__tests__/payload.test.ts new file mode 100644 index 00000000..b824b621 --- /dev/null +++ b/packages/tracker/src/__tests__/payload.test.ts @@ -0,0 +1,254 @@ +import { + boundProps, + boundRevenue, + byteLength, + clamp, + normalizePath, + readUtm, +} from "../payload"; +import { describe, expect, it } from "vitest"; + +describe("byteLength", () => { + it("counts utf-8 bytes and not utf-16 code units", () => { + expect(byteLength("abc")).toBe(3); + expect(byteLength("é")).toBe(2); + expect(byteLength("日本")).toBe(6); + // One code point, two code units, four bytes. + expect(byteLength("🎉")).toBe(4); + }); +}); + +describe("clamp", () => { + it("leaves a value inside the budget untouched", () => { + expect(clamp("/pricing", 1024)).toBe("/pricing"); + }); + + it("cuts by bytes, so a multibyte string loses more characters", () => { + const cut = clamp("日".repeat(20), 12); + + expect(cut).toBe("日日日日"); + expect(byteLength(cut)).toBeLessThanOrEqual(12); + }); + + it("never returns more bytes than the budget however wide the script", () => { + for (const sample of ["a".repeat(300), "é".repeat(300), "🎉".repeat(300)]) { + expect(byteLength(clamp(sample, 64))).toBeLessThanOrEqual(64); + } + }); +}); + +describe("normalizePath", () => { + it("drops the query and the hash", () => { + expect(normalizePath("/docs?utm_source=x")).toBe("/docs"); + expect(normalizePath("/docs#install")).toBe("/docs"); + expect(normalizePath("/docs?a=1#b")).toBe("/docs"); + }); + + it("keeps a route-shaped fragment, which is a whole page", () => { + expect(normalizePath("/", "#/settings")).toBe("/#/settings"); + expect(normalizePath("/app", "#/orders/42")).toBe("/app#/orders/42"); + }); + + it("drops an anchor, which is a position inside one page", () => { + expect(normalizePath("/pricing", "#plans")).toBe("/pricing"); + expect(normalizePath("/post", "#comment-1234")).toBe("/post"); + expect(normalizePath("/", "#")).toBe("/"); + }); + + /** + * The reason the rule is `#/` and not "any fragment": an OAuth implicit-flow + * or magic-link callback puts a bearer token in the fragment, and `path` is + * unbounded text rendered in a dashboard panel. + */ + it("drops a fragment carrying a secret", () => { + expect( + normalizePath("/callback", "#access_token=ya29.a0Ae&token_type=Bearer") + ).toBe("/callback"); + expect(normalizePath("/auth", "#id_token=eyJhbGciOi")).toBe("/auth"); + }); + + it("strips the route's own query, so one page is one row", () => { + expect(normalizePath("/", "#/orders?page=2")).toBe("/#/orders"); + expect(normalizePath("/", "#/search?q=a&utm_source=hn")).toBe("/#/search"); + }); + + /** + * The two ways a secret gets past the `#/` test. A redirect URI that already + * carries a fragment is undefined territory in RFC 6749, and providers + * resolve it by appending to the fragment that is already there. + */ + it("ends the route before a token appended to it", () => { + expect(normalizePath("/", "#/callback&access_token=ya29.a0AeXRPp")).toBe( + "/#/callback" + ); + expect(normalizePath("/", "#/callback#access_token=ya29.a0AeXRPp")).toBe( + "/#/callback" + ); + expect(normalizePath("/", "#/callback?code=4%2F0AX")).toBe("/#/callback"); + }); + + it("collapses the router's root into the page it already names", () => { + // `/` and `/#/` are the same page of a hash-routed app, and the boot-time + // rewrite between them must not be a second row. + expect(normalizePath("/", "#/")).toBe("/"); + expect(normalizePath("/", "#//")).toBe("/"); + expect(normalizePath("/app/", "#/")).toBe("/app"); + }); + + it("collapses a trailing slash inside the route too", () => { + expect(normalizePath("/", "#/docs/")).toBe("/#/docs"); + expect(normalizePath("/", "#/docs/?q=1")).toBe("/#/docs"); + }); + + it("stays inside the byte bound once the route is added", () => { + expect( + byteLength(normalizePath(`/${"a".repeat(900)}`, `#/${"é".repeat(400)}`)) + ).toBeLessThanOrEqual(1024); + }); + + it("collapses a trailing slash the server would keep", () => { + expect(normalizePath("/a/b/")).toBe("/a/b"); + expect(normalizePath("/a/b//")).toBe("/a/b"); + }); + + it("keeps the bare root", () => { + expect(normalizePath("/")).toBe("/"); + expect(normalizePath("")).toBe("/"); + expect(normalizePath("?q=1")).toBe("/"); + expect(normalizePath("#top")).toBe("/"); + }); + + it("roots a relative pathname", () => { + expect(normalizePath("docs/install")).toBe("/docs/install"); + }); + + it("stays inside the 1024-byte bound", () => { + expect( + byteLength(normalizePath(`/${"é".repeat(2000)}`)) + ).toBeLessThanOrEqual(1024); + }); +}); + +describe("readUtm", () => { + it("is undefined when the url carries no campaign", () => { + expect(readUtm("")).toBeUndefined(); + expect(readUtm("?ref=hn&gclid=abc")).toBeUndefined(); + }); + + // A single non-empty utm value forces channel = "campaign" server-side, so + // an object of blanks would rewrite the acquisition of a visit that had none. + it("is undefined when every parameter is blank", () => { + expect(readUtm("?utm_source=&utm_medium=%20")).toBeUndefined(); + }); + + it("keeps only the keys that are present", () => { + expect(readUtm("?utm_source=hn&utm_campaign=launch&x=1")).toEqual({ + source: "hn", + campaign: "launch", + }); + }); + + it("reads all five and trims them", () => { + expect( + readUtm( + "?utm_source=a&utm_medium=b&utm_campaign=+c+&utm_term=d&utm_content=e" + ) + ).toEqual({ + source: "a", + medium: "b", + campaign: "c", + term: "d", + content: "e", + }); + }); + + it("clamps a value to the 255 bytes the column takes", () => { + const utm = readUtm(`?utm_campaign=${"x".repeat(400)}`); + + expect(utm?.campaign).toHaveLength(255); + }); +}); + +describe("boundProps", () => { + it("passes the three types the server accepts", () => { + expect(boundProps({ plan: "pro", seats: 4, trial: false })).toEqual({ + plan: "pro", + seats: 4, + trial: false, + }); + }); + + it("drops the values that would 422 the whole event", () => { + expect( + boundProps({ + keep: "yes", + nothing: null, + nested: { a: 1 }, + list: [1, 2], + broken: Number.NaN, + missing: undefined, + }) + ).toEqual({ keep: "yes" }); + }); + + it("keeps the first 24 keys and drops the rest", () => { + const input: Record = {}; + + for (let index = 0; index < 40; index += 1) { + input[`k${index}`] = index; + } + + const props = boundProps(input); + + expect(Object.keys(props ?? {})).toHaveLength(24); + expect(props?.k0).toBe(0); + expect(props?.k24).toBeUndefined(); + }); + + it("clamps keys and string values to the column bounds", () => { + const props = boundProps({ + [`k${"e".repeat(200)}`]: "v".repeat(900), + }); + const [key] = Object.keys(props ?? {}); + + expect(key).toHaveLength(64); + expect(props?.[key ?? ""]).toHaveLength(512); + }); + + it("is undefined for anything that is not a plain object of scalars", () => { + expect(boundProps(undefined)).toBeUndefined(); + expect(boundProps(null)).toBeUndefined(); + expect(boundProps("props")).toBeUndefined(); + expect(boundProps([1, 2])).toBeUndefined(); + expect(boundProps({})).toBeUndefined(); + expect(boundProps({ nested: {} })).toBeUndefined(); + }); +}); + +describe("boundRevenue", () => { + it("keeps a well-formed pair whatever the currency case", () => { + expect(boundRevenue({ amount: 49, currency: "eur" })).toEqual({ + amount: 49, + currency: "eur", + }); + }); + + it("allows a refund", () => { + expect(boundRevenue({ amount: -12.5, currency: "USD" })).toEqual({ + amount: -12.5, + currency: "USD", + }); + }); + + it("drops a pair the schema would reject rather than lose the event", () => { + expect(boundRevenue({ amount: 49 })).toBeUndefined(); + expect(boundRevenue({ amount: 49, currency: "euro" })).toBeUndefined(); + expect(boundRevenue({ amount: "49", currency: "EUR" })).toBeUndefined(); + expect( + boundRevenue({ amount: Number.NaN, currency: "EUR" }) + ).toBeUndefined(); + expect(boundRevenue({ amount: 1e15, currency: "EUR" })).toBeUndefined(); + expect(boundRevenue(null)).toBeUndefined(); + expect(boundRevenue(42)).toBeUndefined(); + }); +}); diff --git a/packages/tracker/src/__tests__/protocol.test.ts b/packages/tracker/src/__tests__/protocol.test.ts new file mode 100644 index 00000000..23d8678d --- /dev/null +++ b/packages/tracker/src/__tests__/protocol.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "blob:https://x.test/abc-123" } + * + * `location.protocol` is the test and `location.host === ""` was the old one: + * they are different questions, and only a whole document with the wrong + * scheme can tell them apart, hence a file of its own. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("non-http documents", () => { + beforeEach(() => { + vi.stubGlobal("navigator", { + language: "en-US", + doNotTrack: null, + sendBeacon: () => true, + }); + vi.stubGlobal("fetch", () => Promise.resolve()); + vi.resetModules(); + }); + + it("sends nothing, and does not try to resolve an endpoint first", async () => { + const sendBeacon = vi.spyOn(navigator, "sendBeacon"); + const pushState = history.pushState; + const script = document.createElement("script"); + + // Relative, so resolving it against a blob: url would throw. Reaching + // `new URL` at all is the failure this asserts against. + script.setAttribute("aurora-id", "wid_test"); + script.setAttribute("src", "/tracker.js"); + document.head.append(script); + + await expect(import("../index")).resolves.toBeDefined(); + + expect(location.protocol).toBe("blob:"); + expect(sendBeacon).not.toHaveBeenCalled(); + expect(history.pushState).toBe(pushState); + expect(typeof window.aurora).toBe("function"); + }); +}); diff --git a/packages/tracker/src/__tests__/transport.test.ts b/packages/tracker/src/__tests__/transport.test.ts new file mode 100644 index 00000000..b499bc96 --- /dev/null +++ b/packages/tracker/src/__tests__/transport.test.ts @@ -0,0 +1,91 @@ +import { send } from "../transport"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const payload = { wid: "w", vid: "v", duration: 10 }; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("send", () => { + it("posts the json body through sendBeacon and reads nothing back", () => { + const sendBeacon = vi.fn(() => true); + const fetch = vi.fn(); + + vi.stubGlobal("navigator", { sendBeacon }); + vi.stubGlobal("fetch", fetch); + + send("https://a.test/collect/duration", payload); + + expect(sendBeacon).toHaveBeenCalledWith( + "https://a.test/collect/duration", + JSON.stringify(payload) + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("falls back to a keepalive fetch when the beacon queue refuses", () => { + const fetch = vi.fn(() => Promise.resolve()); + + vi.stubGlobal("navigator", { sendBeacon: () => false }); + vi.stubGlobal("fetch", fetch); + + send("https://a.test/collect", payload); + + const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]; + + expect(url).toBe("https://a.test/collect"); + expect(init.method).toBe("POST"); + expect(init.keepalive).toBe(true); + expect(init.body).toBe(JSON.stringify(payload)); + // A declared content type would make every beacon a preflight plus a POST. + expect(init.headers).toBeUndefined(); + /** + * fetch defaults to `credentials: "same-origin"`, and a self-hosted install + * serves the bundle off the site's own origin — so the default would attach + * the host site's cookies to every beacon, from a script whose entire claim + * is that it reads and writes no terminal equipment. sendBeacon has the same + * default, but it carries no init to state it in; this path does. + */ + expect(init.credentials).toBe("omit"); + expect(init.mode).toBe("cors"); + }); + + it("falls back when sendBeacon is absent or throws", () => { + const fetch = vi.fn(() => Promise.resolve()); + + vi.stubGlobal("navigator", {}); + vi.stubGlobal("fetch", fetch); + send("https://a.test/collect", payload); + + vi.stubGlobal("navigator", { + sendBeacon: () => { + throw new Error("blocked by CSP"); + }, + }); + send("https://a.test/collect", payload); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + // An unhandled rejection is a message in the host page's console, which is + // the one thing this script must never produce. + it("attaches a handler to the fetch so a failure stays quiet", () => { + const promise = { catch: vi.fn() }; + + vi.stubGlobal("navigator", { sendBeacon: () => false }); + vi.stubGlobal("fetch", () => promise); + + send("https://a.test/collect", payload); + + expect(promise.catch).toHaveBeenCalled(); + }); + + it("does not throw when there is no transport at all", () => { + vi.stubGlobal("navigator", {}); + vi.stubGlobal("fetch", undefined); + + expect(() => send("https://a.test/collect", payload)).not.toThrow(); + }); +}); diff --git a/packages/tracker/src/index.ts b/packages/tracker/src/index.ts new file mode 100644 index 00000000..281c56d2 --- /dev/null +++ b/packages/tracker/src/index.ts @@ -0,0 +1,1098 @@ +import { + boundProps, + boundRevenue, + clamp, + normalizePath, + readUtm, +} from "./payload"; +import { send } from "./transport"; +import type { AuroraApi, EventOptions, Utm } from "./types"; + +declare global { + interface Window { + aurora?: AuroraApi; + } +} + +/** + * This script writes nothing to localStorage, sessionStorage, cookies or + * IndexedDB, and that is the point rather than an omission: ePrivacy Art. + * 5(3) covers "storage of information in terminal equipment", which is a + * localStorage key just as much as a cookie. Identity and sessions are the + * server's job now, derived from headers it already has. + */ + +/** The events_duration_range check; anything above it comes back a 422. */ +const MAX_DURATION = 86_400_000; + +/** + * How long after a view has settled a `replaceState` still reads as a + * correction of that view rather than a navigation away from it. + * + * Measured against React 19 + React Router 8 in Chrome rather than picked. A + * `` mount redirect landed 22ms after this script's first + * view unthrottled and 1703ms after it on a 4x-throttled CPU over Slow 4G — + * the spread is the app bundle downloading, not the router deciding, so a + * budget measured from the view alone is either far too loose or misses every + * slow connection. Anchored at the load event instead, the same two runs are + * 30ms *before* DCL and 30ms after it, and the slowest true positive of the + * set — a guard awaiting a 400ms /session call on that throttled profile — is + * 577ms past `loadEventEnd`. An in-app guard redirect, which happens long + * after the load event, lands 8.1ms after the pushState it corrects. + * + * Hence the anchor below is the later of the two and this is 1.7x the worst + * case observed. A redirect behind an endpoint slower than this still + * double-counts, exactly as it does today. + */ +const SETTLE = 1_000; + +/** + * The longest a document that has not fired `load` is still treated as one that + * is going to. + * + * `readyState` goes "loading" → "interactive" → "complete", and only the last + * step waits on subresources: one image on a dead host, one hanging ad iframe or + * one font that never arrives leaves a page that is finished, interactive and + * being read sitting at "interactive" for as long as the visitor keeps it open. + * `load` never fires there, so the window `anchor()` holds open for a document + * that has not loaded yet had nothing left to close it — see the comment there + * for what that cost. + * + * Thirty seconds against a mount redirect measured at 1703ms on a 4x-throttled + * CPU over Slow 4G: an order of magnitude past the worst true positive of that + * set, and finite, which is the whole property that was missing. + */ +const LOADING = 30_000; + +/** `document.prerendering` and `navigator.globalPrivacyControl` are both real + * and neither is in lib.dom. */ +type Prerenderable = Document & { prerendering?: boolean }; +type Private = Navigator & { globalPrivacyControl?: boolean }; + +/** + * `navigator.userAgentData`, which is not in lib.dom either. Every member is + * optional because this is read on pages that predate it and on pages that have + * stubbed it: the shape is whatever the host browser happens to have. + */ +type Hinted = Navigator & { + userAgentData?: { + getHighEntropyValues?: ( + hints: string[] + ) => Promise<{ platformVersion?: unknown } | null>; + }; +}; + +/** + * The rule the whole file is built around: nothing in here may surface on the + * host page. Every entry point a browser or a site can reach — the patched + * history methods, the listeners, `window.aurora` — goes through this, so the + * worst a bug in this script can cost anybody is a beacon. + */ +const guard = + (fn: (...args: A) => void) => + (...args: A): void => { + try { + fn(...args); + } catch { + // A tracker is never worth an exception in someone else's console. + } + }; + +/** + * `crypto.randomUUID` is secure-context only, and a self-hosted install served + * over plain http is a supported deployment, so it cannot be the only source. + * The token has to be unique per (site, pageview) to satisfy the partial + * unique index on `view_token` and nothing more: it is never secret, never + * stored, and meaningless the moment the page is gone. + */ +const random = () => Math.random().toString(36).slice(2); + +const token = (): string => { + try { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + } catch { + // Present and throwing rather than absent, which is how some non-secure + // context shims spell the same refusal. A throw here would land between + // `path` and `vid` in `view()` below and leave the new page addressed by + // the previous page's token — the exact mis-attribution this file exists + // to fix. + } + + return `${Date.now().toString(36)}-${random()}-${random()}`; +}; + +/** + * The timer's clock. `performance.now()` is monotonic where `Date.now()` + * follows an NTP correction, so it is the right one — but it is read on the + * same path as the pageview beacon, and a page that has shimmed it away must + * cost this script its durations and not its pageviews. + */ +const clock = (): number => { + try { + return performance.now(); + } catch { + return Date.now(); + } +}; + +/** + * Where the visit came from, and no more than that. + * + * `acquisition()` keeps the hostname and throws the rest away, so the path and + * the query of a referrer buy nothing and carry everything: a search phrase, a + * private thread, a document title, an address or a magic-link token in a + * webmail URL. Sending them would put that across the network and through + * every proxy and access log in front of the collector, to be discarded on + * arrival. The origin resolves to the same `referrer_host` for every referrer + * the server would have stored. + * + * Anything that is not http(s) is dropped rather than truncated: `urlHost` + * refuses those schemes too, so it was never going to become a row. + */ +const source = (referrer: string): string | undefined => { + try { + const url = new URL(referrer); + + return url.protocol === "http:" || url.protocol === "https:" + ? url.origin + : undefined; + } catch { + return undefined; + } +}; + +/** + * The platform version, asked of the browser rather than of the request. + * + * `Accept-CH` is stored by a browser only from a top-level navigation response, + * and this origin serves nothing but third-party beacons, so the ask the + * collector puts on its 204s can never be honoured and + * `Sec-CH-UA-Platform-Version` never arrives. The low-entropy trio does arrive + * unasked, which is the part that makes the gap expensive: the server learns + * the platform and not its version, and falls back to a UA string that UA + * reduction has frozen — every Chromium Mac permanently "10", Windows 11 + * indistinguishable from Windows 10, every Android "10". Silent corruption + * rather than a null, and rendered as fact. + * + * `getHighEntropyValues` reads the browser's own values in-process: no + * `Accept-CH`, no `Permissions-Policy` delegation, no navigation. It is + * Chromium-only and secure-context only, which is precisely the population + * whose UA string is frozen, so the fit is exact and a browser without it must + * — and does — behave as it did before. + * + * Only `platformVersion` is asked for. `model` feeds a branch that is already + * settled by `?0` plus the platform, `fullVersionList` is reduced to a major + * server-side anyway, and `architecture`, `bitness` and `wow64` have no column: + * every one of them would be bytes on a beacon and a value in a breakdown + * nobody can group on. + * + * Everything here is defended twice. The call is wrapped because a page may + * have replaced `userAgentData` with something that throws — and this runs + * inside `activate()`, ahead of the history patch and the listeners, so a throw + * escaping it would cost the document its whole tracker rather than one field. + * The promise carries its own `catch` because an unhandled rejection is a + * message in the host page's console, which is the one thing this file may + * never produce. + */ +const highEntropy = (keep: (value: string) => void): void => { + try { + const data = (navigator as Hinted).userAgentData; + + if (typeof data?.getHighEntropyValues !== "function") { + return; + } + + data + .getHighEntropyValues(["platformVersion"]) + .then((values) => { + const value = values?.platformVersion; + + if (typeof value === "string" && value) { + // Clamped to the schema's bound rather than to the format's: + // "10.0.19045.2846" is the widest answer a real platform gives, and + // one byte over the line is a 422 for the whole beacon. + keep(clamp(value, 32)); + } + }) + .catch(() => {}); + } catch { + // A hint is worth strictly less than the pageview it rides on. + } +}; + +/** + * The async stub a site installs ahead of the bundle pushes one `arguments` + * object per call into `q`; draining it is what keeps an `aurora()` fired from + * the page head from being lost. Read by index rather than destructured, + * because the queue is whatever the host page wrote there. + * + * Every step is guarded on its own, because every one of them touches + * something the host page owns: a page that froze `window` or defined `aurora` + * read-only makes the assignment throw under the `"use strict"` the bundle is + * built with, and one `null` left in the queue used to take the rest of the + * queue — and the boot that follows this call — down with it. + */ +const install = (api: AuroraApi): void => { + let queued: unknown[] | undefined; + + guard(() => { + const previous = window.aurora?.q; + + queued = Array.isArray(previous) ? previous : undefined; + window.aurora = api; + })(); + + for (const call of queued ?? []) { + guard(() => { + const args = call as ArrayLike; + + api(args[0] as string, args[1] as EventOptions | undefined); + })(); + } +}; + +// Guarded like every other entry point: module evaluation is the first one, +// and a `src` that will not resolve must cost the tracker and not the parser +// state of whatever runs after it on the page. +guard(() => { + const doc = document as Prerenderable; + const nav = navigator as Private; + + /** + * One page, one tracker. A hardcoded snippet plus a tag-manager injection is + * the ordinary way a site ends up serving this file twice, and the second + * copy would wrap the already-patched history, register a second set of + * unremovable listeners and mint its own token for every view: two pageviews + * per navigation, two rows the unique index cannot collapse, and a doubled + * session count that no later query can repair. + * + * The cost is a page deliberately reporting to two websites, which keeps the + * first. `window.aurora` is a single global and could only ever have + * addressed one of them anyway. + */ + if (window.aurora?.loaded) { + return; + } + + /** + * `currentScript` is only meaningful while the script is evaluating — by the + * time any listener runs it is null — so it is read here or never. It is + * also the only way to find the right tag when a page carries two of them; + * the query is the fallback for a bundle loaded async or as a module, where + * the browser hands back null. + */ + const current = doc.currentScript; + const script = ( + current?.hasAttribute("aurora-id") + ? current + : doc.querySelector("script[aurora-id]") + ) as HTMLScriptElement | null; + + const wid = script?.getAttribute("aurora-id"); + + /** + * The IDL property and not `getAttribute("src")`. The attribute is whatever + * the page wrote, and the browser resolved it against `` — the + * default shape of an Angular build deployed under a sub-path, and common in + * CMS templates — where this file used to resolve it against + * `location.href`. The two disagree exactly when a `` is present, and + * the beacons went to a path that had never existed. `script.src` is the URL + * the file was actually fetched from, already absolute. + */ + const src = script?.src; + + /** + * `window.aurora` is published API. A page that calls it must not take a + * TypeError because the visitor turned on GPC or because someone opened the + * build off a file:// path, so every refusal below still leaves the global + * in place and inert — and drains the stub's queue so it cannot grow. + */ + const inert: AuroraApi = guard(() => {}); + + if (!wid || !src) { + install(inert); + return; + } + + // file:, data:, blob: and extension pages have no site to attribute a view + // to. `location.host === ""` was the old test and it is a different + // question: about:blank has no host, a data: URL sometimes does. + if (location.protocol !== "http:" && location.protocol !== "https:") { + install(inert); + return; + } + + /** + * Both headers are a request not to be measured, and there is nothing to + * offer a visitor who sent one: no identifier to degrade, no storage to + * skip. So nothing is sent at all, rather than something anonymised. + */ + if (nav.doNotTrack === "1" || nav.globalPrivacyControl === true) { + install(inert); + return; + } + + let collectUrl = ""; + let durationUrl = ""; + + /** + * Both endpoints resolved off the script's own src, which is the only URL + * that is certainly reachable from this page. The old `src.replace( + * "/tracker.js", "/collect")` broke the moment the file was renamed, served + * from a CDN path, or fingerprinted — and failed silently, posting to a URL + * that had never existed. + * + * `new URL("collect/duration", base)` and not "/collect/duration": a leading + * slash would jump to the origin root and break an install under a sub-path. + * + * Wrapped, because resolving anything against an opaque path throws, and + * `blob:` is how a tag manager or a CSP-nonce setup injects a bundle. The + * throw used to land here, before `install` had run at all, so the refusal + * this file promises for every other unusable deployment arrived instead as + * an undefined `window.aurora` and a TypeError out of the host page's own + * `aurora("signup")`. + */ + try { + const base = new URL(src, location.href); + + collectUrl = new URL("collect", base).href; + durationUrl = new URL("collect/duration", base).href; + } catch { + install(inert); + return; + } + + /** The current view. All of it dies with the page; none of it is persisted. */ + let vid = ""; + let path = ""; + let elapsed = 0; + let since = 0; + let counting = false; + let reported = 0; + let landed = false; + let ready = false; + let scheduled = false; + /** Whether the burst being coalesced held anything but a `replaceState`. */ + let pushed = false; + /** When the current view was created, and what has happened to it since. */ + let born = 0; + let acted = false; + let fired = false; + /** When this document activated, and when it finished loading; `settled` + * stays 0 for as long as it has not. */ + let started = 0; + let settled = 0; + /** What `navigator.userAgentData` answered, once it has answered. */ + let platformVersion = ""; + /** The campaign the document arrived on; see `view()`. */ + let campaign: Utm | undefined; + + const pending: Array<[string, EventOptions | undefined]> = []; + + /** + * Calls made before there is a view to name them, held until there is one. + * + * Bounded, which it was not. The queue is drained by `view()`, so anything + * that keeps `view()` from running keeps it filling: a prerender the visitor + * never activates, and now a tab that stays in the background. A page firing + * a heartbeat event on a timer in either state would otherwise grow this + * array for the life of the document, holding every `props` bag in it — up to + * 24 keys of ~576 bytes each — on somebody else's page. Thirty-two is far past + * any real page's boot-time conversions and costs at most a few tens of KB. + * + * The newest is dropped rather than the oldest: the calls that matter most + * here are the ones a page makes on arrival, and a queue this deep is already + * a page in a state where its later calls are not going to be attributable + * anyway. + */ + const hold = (name: string, options: EventOptions | undefined) => { + if (pending.length < 32) { + pending.push([name, options]); + } + }; + + const stop = () => { + if (counting) { + elapsed += clock() - since; + counting = false; + } + }; + + const resume = () => { + if (!counting) { + since = clock(); + counting = true; + } + }; + + /** + * The route SETs the column rather than adding to it, so this reports the + * running total for the view and is safe to repeat — last write wins. + * + * Only when the total actually moved, though. `visibilitychange` fires on + * every tab switch and `pagehide` fires again right after it, and the rate + * limiter is per IP and shared with /collect, so an unchanged repeat is a + * request spent on nothing and a step closer to a 429 that would drop a real + * pageview. + */ + const flush = () => { + if (!vid) { + return; + } + + const visible = elapsed + (counting ? clock() - since : 0); + const duration = Math.min(Math.round(visible), MAX_DURATION); + + if (duration <= 0 || duration === reported) { + return; + } + + reported = duration; + + send(durationUrl, { wid, vid, duration }); + }; + + const view = () => { + /** + * A page the visitor is not looking at is not a pageview, and that has to + * hold for the whole life of the document rather than only at boot. A + * router that keeps navigating in a backgrounded tab — a poll, a redirect + * chain, a restored tab set settling — used to spend a pageview, a + * session's cleared bounce and a slot of the rate limit that the real + * beacons share on every route nobody saw. Worse, the view it opened + * started a timer, so a tab left in the background booked its background + * time as visit duration, up to the 24h clamp. + * + * Held rather than dropped: `path` is left where it was, so the + * visibilitychange below re-reads `location` and records the route the tab + * actually settled on, the moment the visitor looks at it. That now covers + * a document's *first* view as well — `boot` no longer refuses a tab that + * opens in the background, it activates and leans on this. + */ + if (document.visibilityState === "hidden") { + return; + } + + const next = normalizePath(location.pathname, location.hash); + + // Two views of one page back to back are one view. Routers call + // `replaceState` to keep a query string in sync with a filter or a search + // box, which is a dozen calls for a page the visitor never left. + if (next === path) { + return; + } + + /** + * The leaving page's time, addressed by the leaving page's token, before + * the token is replaced. This ordering is the entire fix: the accumulator + * used to run for the life of the document and the total was attributed to + * whatever page happened to be last, so 60s on /a then 30s on /b was one + * 90s beacon for /b and nothing at all for /a. + */ + flush(); + + path = next; + vid = token(); + elapsed = 0; + reported = 0; + counting = false; + // Everything the correction rule below asks about the view is about *this* + // view, so all three are reset with the token rather than per document: a + // guard that bounces a route the visitor clicked into is as much a + // correction as one that bounces the page they arrived on. + born = clock(); + acted = false; + fired = false; + + /** + * Only on the document's first view. `document.referrer` does not change + * across same-document navigations, so re-reading it credits one arrival + * once per page of the visit: a four-page SPA session out of a newsletter + * was four rows under that referrer and four `channel = 'social'` rows, + * where the same visit on a server-rendered site sends its own host from + * page two onward and the server drops it as a self-referral. The session + * carries the channel from its first pageview either way. + */ + const referrer = landed ? undefined : source(doc.referrer); + + /** + * Snapshotted at activation for the document's first view, read live for + * every one after it — the same split `landed` makes above, and for the + * same reason. An SPA can route into a campaign URL, so the query has to be + * re-read per view; but the arrival's own query is as perishable as its + * referrer is stable, and only the referrer was protected against the view + * being deferred. + * + * A tab that opens hidden holds its first view until the visitor looks at + * it, and by then the router's mount rewrite has stripped `?utm_source` + * from `location`. That is a campaign link cmd-clicked or middle-clicked + * into a background tab reporting channel `direct` where the same click in + * the foreground reports `campaign` — the skew landing on exactly the + * new-tab, slow-connection and mobile population the deferral was added to + * recover. + */ + const utm = landed ? readUtm(location.search) : campaign; + const language = nav.language; + // Read defensively for the same reason as the clock: `screen` is universal + // and it is still not worth a pageview. + const width = (window.screen as Screen | undefined)?.width ?? 0; + + landed = true; + + send(collectUrl, { + wid, + type: "pageview", + vid, + path, + // Clamped rather than dropped: only the hostname is kept server-side, so + // a truncated origin still resolves to the right referrer, where an + // over-long one would 422 the pageview away. + referrer: referrer ? clamp(referrer, 1024) : undefined, + language: language ? clamp(language, 64) : undefined, + // The sole input to `screen_class`, and `<= 0` is stored as null anyway. + screen: width > 0 ? width : undefined, + // Whatever `userAgentData` has answered by now, and nothing if it has + // not: the first view of a document is the one a fast bounce depends on, + // so it goes out on the same task it was decided on rather than waiting + // for a promise. That first view keeps the frozen answer the UA string + // gives; every beacon after it carries the real one. + platformVersion: platformVersion || undefined, + utm, + }); + + // After the beacon rather than before it. Starting the clock is the only + // thing in here that reads one, and a view that cannot be timed is still a + // view that has to be recorded. + resume(); + + /** + * The stub's queue, drained the moment there is a view to name — which is + * here and not at the end of `activate()` any more. A document that starts + * hidden now activates and holds its first view back, and the old drain ran + * against an empty `vid`: `event()` rejected every held call and the queue + * was cleared behind it, so a `revenue` conversion fired from the page head + * of a backgrounded tab was destroyed rather than delayed. + * + * Emptied as it is read, so the ordinary view — where the queue is empty + * and always will be, since `api` only fills it before `ready` — costs one + * comparison. + */ + for (const [name, options] of pending.splice(0)) { + event(name, options, true); + } + }; + + /** + * `held` is whether this call spent time in `pending` — the async stub's + * queue, drained by `view()` — rather than arriving live from the page. It + * changes nothing about the beacon and one thing about the view; see `fired` + * below. + */ + const event = ( + name: string, + options: EventOptions | undefined, + held: boolean + ) => { + // A nameless event is a 422 for the whole beacon. A call the page got + // wrong should cost the page its event, not the pageview behind it. + if (typeof name !== "string" || !name) { + return; + } + + /** + * And an event with no view behind it is the same 422: `vid` and `path` + * are both `min(1)` server-side. They are still empty in two windows — a + * throw anywhere in `activate()` ahead of the first `view()`, and a tab + * holding its view back until it is looked at — and a beacon sent from + * either is rejected for certain, having spent a slot of the limit the + * pageviews depend on. + * + * Held rather than dropped, which is the other half of the fix recorded + * above `view()`'s drain. That comment fixed the call made *before* + * activation; a call made *after* it in a tab that is still hidden took the + * old outcome and was destroyed here — a document that boots in the + * background activates fully, `ready` goes true, and every `aurora()` call + * then walked straight past `api`'s queue into this return. A + * `newsletter_signup` with revenue on it was gone permanently, and nothing + * anywhere recorded that it had happened. `view()` empties this queue the + * moment the visitor looks at the tab, with `vid` and `path` both already + * assigned, so the drain can never land back here and loop. + */ + if (!vid || !path) { + if (!held) { + hold(name, options); + } + + return; + } + + const opts = options ?? {}; + + /** + * A page that reported something happening on this view has told this + * script the view was real, whatever the clock says: `route()` below stops + * reading a later `replaceState` as a correction of it. + * + * Unless the call was held, which means it came out of the async stub's + * queue. That one was made before the bundle landed and therefore before + * the view existed, so it is evidence about the page and none at all about + * the view — and counting it disarmed the correction rule for every site + * using the documented snippet, which is most of them. The mount redirect + * booked its second row again, with the phantom path it names and the + * cleared bounce that goes with it. + */ + fired = fired || !held; + + send(collectUrl, { + wid, + type: "event", + name: clamp(name, 200), + // Custom events store no `view_token`, so the current view's token names + // the page the event happened on and collides with nothing in the + // partial unique index. + vid, + path, + // Carried here too, and not only on pageviews: the row an event writes + // holds the same five client columns a pageview's does, so omitting it + // would file one visitor's events under a different OS version than their + // pageviews and split the breakdown between them. + platformVersion: platformVersion || undefined, + props: boundProps(opts.props), + revenue: boundRevenue(opts.revenue), + }); + }; + + const api: AuroraApi = guard((name: string, options?: EventOptions) => { + // Held rather than sent while a prerender is still a prerender. + if (!ready) { + hold(name, options); + return; + } + + event(name, options, false); + }); + + // What a second copy of this bundle looks for. Set before `boot`, because a + // prerender can defer activation for as long as the visitor takes to click. + api.loaded = true; + + /** + * When the settle window opens: the later of the view being created and the + * document finishing loading — and `now` for as long as it has not finished, + * because an app that has not run yet cannot have redirected yet. + * + * `settled` stays 0 for a bundle a tag manager injected after the load event, + * where `readyState` is already "complete" and the listener never fires; the + * view's own birth is the right anchor there and `Math.max` picks it. + * + * The "has not finished" half is bounded rather than open, which it used to + * not be: it tested for "complete" and returned `clock()` — an anchor that is + * always now, so a window that never closes — for both of the other two + * states. A document with one stalled subresource sits at "interactive" + * indefinitely, and there `correcting()` collapsed to nothing but "no gesture + * and no custom event", neither of which a background tab can ever produce. + * A router's own `replaceState` ten minutes later then silently repointed the + * row naming the page the visitor had actually been reading, and every one + * after it spent a beacon saying so, unbounded in wall-clock time. Past + * `LOADING` the loading branch expires and each view falls back to the same + * one-second window the loaded branch gives it. + */ + const anchor = () => + document.readyState === "complete" + ? Math.max(born, settled) + : Math.max(born, Math.min(clock(), started + LOADING)); + + /** + * Whether a `replaceState` arriving now is a mount-time correction of the + * current view rather than a navigation away from it. + * + * The method is the first half of the answer and the reason `schedule` had to + * start carrying one: a redirect a router issues while a route settles is a + * `replaceState` precisely because the pre-redirect URL must not stay in + * history, where a navigation the visitor asked for is a `pushState` or a + * `popstate`. It is not the whole answer, since routers replace for ordinary + * reasons too, so three more have to agree: + * + * - No gesture since this view was created. An auth guard, a locale prefix + * and a boot redirect are nobody's idea; a navigation is. This is the + * discriminator that survives an arbitrary delay, and it is kept per view + * rather than read from `navigator.userActivation.hasBeenActive`, which is + * sticky for the life of the document — one click anywhere would disarm + * this rule for every view after it, including the guard redirect on the + * route that click opened. + * - No custom event named this view, handled in `event()` above. + * - Inside the settle window, which is the only reason a router's own + * `replaceState` seconds later — a filter, a search box, a wizard step — + * is still a navigation even with no gesture behind it. + * + * Two redirects are deliberately outside this, and a reader comparing a + * dashboard against their own app should know which. A redirect issued by a + * route loader is a `pushState` in React Router 8 unless the app asks for a + * replace — measured, not assumed — so it is counted as the navigation it + * genuinely is, back button and all. And a guard that decides behind an + * endpoint slower than the window redirects too late to be corrected, and + * books the second pageview it books today. + */ + const correcting = () => + Boolean(vid) && !acted && !fired && clock() - anchor() <= SETTLE; + + /** + * The correction itself: one beacon that moves the row already written to the + * path the redirect settled on, under the same token. + * + * The alternative was to leave the row alone and just stop counting the + * second view, which needs no server at all — but the row would keep naming + * the pre-redirect path, and a visit that an ordinary server-rendered site + * reports once as `/login` would be reported once as `/`. Every headline + * figure is already right by then: this view keeps its token, so its bounce + * flag, its acquisition and its clock are the arriving visit's and not a + * second one's. + * + * No `flush()` and no new token, deliberately. The visitor never left, so the + * time keeps running on the view they are looking at. + * + * Sent from a hidden tab as well, where every other beacon in this file is + * held: the row it repairs already exists and is already wrong, and holding + * the repair back would leave `path` naming the pre-redirect page — so the + * moment the tab came forward, `view()` would read the difference as a second + * page and book the extra row this exists to prevent. + */ + const correct = () => { + const next = normalizePath(location.pathname, location.hash); + + if (next === path) { + return; + } + + path = next; + + send(collectUrl, { wid, type: "pageview", vid, path, corrects: true }); + }; + + /** + * Every navigation hook lands here, carrying whether the burst that produced + * it contained anything but a `replaceState`. + */ + const route = (push: boolean) => { + if (!push && correcting()) { + correct(); + return; + } + + view(); + }; + + /** + * `location` has not moved yet when a `pushState` patch runs, and the url + * argument is whatever the router felt like passing — undefined, a URL + * object, a relative string, a full href. One task later `location.pathname` + * is the only thing that is certainly the new page, so that is what is read. + * + * Coalesced to one task per tick because a router that calls `replaceState` + * three times while it settles a route has navigated once. + * + * The method survives the coalescing rather than the last call winning: a + * router that pushes a route and then replaces the URL to normalise it has + * navigated, so anything but a `replaceState` anywhere in the burst makes the + * whole burst one. + */ + const schedule = guard((push: boolean) => { + pushed = pushed || push; + + if (scheduled) { + return; + } + + scheduled = true; + + try { + setTimeout( + guard(() => { + const burst = pushed; + + scheduled = false; + pushed = false; + route(burst); + }), + 0 + ); + } catch { + // Dropped with the navigation it belonged to, so a push that never ran + // cannot arm the next `replaceState` against the correction rule. + pushed = false; + // A latch is only worth holding for a task that is going to run. A host + // that has replaced `setTimeout` with something that throws must cost + // this one navigation rather than every navigation after it, which is + // what an un-released latch bought: the guard around this function + // swallowed the throw and `schedule` returned early forever. + scheduled = false; + } + }); + + const patch = (name: "pushState" | "replaceState") => { + const original = history[name]; + + /** + * Nothing is wrapped around a method that is not there. Consent tools, + * anti-tracking scriptlets and hardened enterprise builds do stub these + * out, and a wrapper over a non-function is worse than the hole it fills: + * it turns the `typeof history.pushState` a router feature-detects from + * `"undefined"` into `"function"`, then throws `original.apply is not a + * function` from inside that router's own stack — the one place the guard + * around this file cannot reach. + */ + if (typeof original !== "function") { + return; + } + + history[name] = function ( + this: History, + ...args: Parameters + ) { + // Called through first and untouched: a SecurityError the host's own call + // was going to raise must still reach it, and nothing may read `location` + // before it has moved. + original.apply(this, args); + schedule(name === "pushState"); + }; + }; + + const activate = () => { + /** + * The activation latch, and now the only thing holding it: the queue drain + * moved into `view()`, where a held call finally has a view to name, so + * `{ once: true }` on `prerenderingchange` and this flag are no longer two + * halves of one guarantee. This is the half that matters — losing it + * re-registers every listener and re-patches the already-patched history. + */ + if (ready) { + return; + } + + ready = true; + // The fallback end of the loading window in `anchor()`, and the moment the + // document's own arrival is read below. + started = clock(); + + // First, so that the answer has the longest possible run at the first + // beacon — and asked exactly once per document, which is what the + // activation latch above already guarantees. + highEntropy((value) => { + platformVersion = value; + }); + + // Read here rather than in `view()`, because a view can be deferred for as + // long as the visitor leaves the tab in the background and a query cannot + // survive the router's mount rewrite. `view()` explains what that cost. + campaign = readUtm(location.search); + + // Hooks before the first beacon: if anything in the initial view were to + // throw, a tracker that had stopped listening to navigation would be worse + // than one that missed a pageview. + patch("pushState"); + patch("replaceState"); + + // Back and forward were invisible before this. A same-document back + // navigation fires popstate and touches no other hook. Always a navigation + // and never a correction: the visitor asked for this one by name. + window.addEventListener( + "popstate", + guard(() => { + schedule(true); + }) + ); + + /** + * The two inputs to the correction rule that only a listener can supply. + * + * `load` is the anchor of the settle window, and the reason it is anchored + * there rather than at the view: a redirect a router issues on mount waits + * for the app bundle, so measured from the view it lands anywhere between + * 20ms and two seconds depending on the connection, and measured from the + * load event it lands within a few dozen ms of it either way. + * + * The gesture listeners are capture-phase so a page that stops propagation + * cannot hide the click from them, and passive so this script can never be + * the reason a scroll or a tap janks. They are the only listeners in here + * that fire on ordinary interaction, which is why they do nothing but set + * a flag. + */ + window.addEventListener( + "load", + guard(() => { + settled = clock(); + }) + ); + + const act = guard(() => { + acted = true; + }); + + for (const gesture of ["pointerdown", "keydown", "touchstart"]) { + window.addEventListener(gesture, act, { capture: true, passive: true }); + } + + /** + * Hash routing. This listener was removed once as provably dead code, and + * it was dead for exactly one reason: /collect split the path on `[?#]`, so + * `/#/a` and `/#/b` were both the row `/` and a hash change could only ever + * mint a token for a path already recorded. The column keeps a route-shaped + * fragment now, `normalizePath` reads `location.hash`, and the listener + * stops being dead the moment both of those are true. + * + * Registered even though `createHashRouter` moves the hash through + * `pushState` and is already covered by the patch above, because a router + * that assigns `location.hash` — or an ordinary `` — + * fires nothing else at all. Back and forward across two hash entries fire + * popstate and this one both, and the coalescing in `schedule` is what makes + * that pair a single view. + * + * A navigation and never a correction, like popstate: a hash moves because + * something asked it to. The mount rewrite it could be confused with, a + * boot-time `/` to `/#/`, normalises to the path the view already holds and + * is dropped by the dedupe in `view()` without a rule of its own. + */ + window.addEventListener( + "hashchange", + guard(() => { + schedule(true); + }) + ); + + /** + * Two events for one job, deliberately. `visibilitychange` is the only one + * that fires when a tab is merely backgrounded; `pagehide` is the only one + * that fires reliably when iOS Safari tears the page down. `flush` is + * idempotent, so a browser that fires both still spends one beacon. + */ + document.addEventListener( + "visibilitychange", + guard(() => { + if (document.visibilityState === "hidden") { + stop(); + flush(); + return; + } + + /** + * Time in a background tab is not visit duration — and the route the + * tab is on now may not be the one it was on when it was backgrounded, + * so the view it was holding is recorded here, against the path the + * visitor is actually looking at. + * + * Unless a navigation is already pending, which is the one case this + * listener must not decide on its own. `schedule()` defers `route()` + * through a `setTimeout`, and a hidden tab is where browsers throttle + * those hardest — one a second in Chrome and Firefox, one a *minute* in + * Chrome once a tab has been hidden five minutes — so the deferred task + * cannot run until the tab is foregrounded, and `visibilitychange` is + * always delivered first. Calling `view()` here regardless minted a + * second row for the post-redirect path, and by the time `route()` + * finally ran, `correct()` found the path it was going to move to and + * no-opped: two rows for one arrival, the second carrying no referrer + * because `landed` was already true, for any mount redirect issued in + * the last second — or minute — of a background period. + * + * The pending task knows whether the route the tab settled on is a + * correction of the held view or a navigation away from it. This + * listener does not, and there is nothing it can see that would tell + * it. + */ + if (!scheduled) { + view(); + } + + resume(); + }) + ); + + window.addEventListener( + "pagehide", + guard(() => { + stop(); + flush(); + }) + ); + + /** + * A bfcache restore fires no popstate and no navigation hook — the + * document was never torn down — so without this the back button lands on + * a page that is live, being read, and recorded nowhere. + * + * It starts a fresh view rather than resuming the old one because the + * duration column is overwritten and not accumulated: resuming would send + * a smaller total for the same token and erase the time already reported. + */ + window.addEventListener( + "pageshow", + guard((restore: PageTransitionEvent) => { + if (restore.persisted) { + path = ""; + view(); + } + }) + ); + + view(); + }; + + /** + * A prerendered document is a page the visitor has not asked to look at, and + * Chrome runs the whole script in one. Recording it would count a navigation + * that may never happen, with a referrer and a timer belonging to a page + * nobody has seen. That is a real second document with its own activation + * signal, so it keeps its own branch, unchanged. + * + * A document that merely starts hidden used to be refused here, with an inert + * global and no listeners at all, and the objection that used to be written + * in this comment turned out to be right: it now activates and defers. + * + * - Hidden at boot is the ordinary shape of a cmd-click, a middle-click, an + * "open link in new tab", a minimised window, and of any load that simply + * finished after the visitor tabbed away. On a slow connection that last + * one is routine, which made the refusal a systematic deletion of exactly + * the mobile and slow-connection sessions a performance-minded owner is + * looking for. + * - It cost far more than the pageview it was aimed at. `install(api)` runs + * before this, so the stub's queue was already drained into `pending`, and + * `install(inert)` then overwrote the API that was the only thing that + * could ever have sent it: every `aurora()` call from that document, + * `revenue` conversions included, died with the tab. The inert function + * also carried no `loaded` flag, so a second copy of this bundle would + * boot on top of it. + * - It defeated the prerender branch above. A prerender activated into a + * briefly hidden tab re-entered `boot` and was refused here. + * + * Deferring is safe because holding a view back is `view()`'s own job and + * has been since it started gating on visibility: nothing under a hidden + * document sends. `patch()` and the listeners registered in `activate()` + * send nothing by themselves, the first `view()` returns before it mints a + * token, and `visibilitychange` then re-reads `location` and records the + * route the tab actually settled on, with the arrival credit intact because + * `landed` is still false. A background tab the visitor never opens still + * sends nothing at all, which is everything the refusal ever bought. + * + * This contradicts §7.7 of the contract as written. The clause conflated a + * prerender with a background-tab open; only the first of those is a page + * nobody asked for. + */ + const boot = guard(() => { + if (doc.prerendering) { + doc.addEventListener("prerenderingchange", boot, { once: true }); + return; + } + + activate(); + }); + + // Before boot, because a prerender defers activation for as long as the + // visitor takes to click and the page can call `aurora()` throughout. + install(api); + + boot(); +})(); diff --git a/packages/tracker/src/payload.ts b/packages/tracker/src/payload.ts new file mode 100644 index 00000000..92128385 --- /dev/null +++ b/packages/tracker/src/payload.ts @@ -0,0 +1,186 @@ +import type { Props, Revenue, Utm } from "./types"; + +/** + * The route bounds every string by UTF-8 bytes, not characters, and one + * over-long value fails the whole beacon rather than the field. `length` is + * UTF-16 code units and disagrees with that on every non-ASCII page, so the + * same units have to be counted here for a clamp to be worth anything. + * + * Three bytes per code unit is the worst UTF-8 can do (a surrogate pair costs + * four across two units), so a browser without TextEncoder clamps early rather + * than wrong. + */ +const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null; + +export const byteLength = (value: string): number => + encoder ? encoder.encode(value).length : value.length * 3; + +/** + * Converged rather than computed: scaling the cut by the byte ratio lands + * inside the budget in a pass or two whatever the script. Cutting through an + * emoji leaves a lone surrogate, which the route repairs to U+FFFD instead of + * rejecting — which is the only reason clamping is safer than dropping. + */ +export const clamp = (value: string, max: number): string => { + let cut = value.length > max ? value.slice(0, max) : value; + + for (let size = byteLength(cut); size > max; size = byteLength(cut)) { + cut = cut.slice(0, Math.floor((cut.length * max) / size)); + } + + return cut; +}; + +/** + * A fragment names a page only when it is a route. + * + * `#/pricing` is the entire address of a page under every hash router — Vue + * Router's hash mode, Angular's HashLocationStrategy, `createHashRouter`, and + * any static host that cannot serve a rewrite — so collapsing it left those + * apps reporting one row per document, always `/`, and a bounce on every single + * visit. `#pricing` is a position inside a page the visitor never left, and + * counting it as a page would be the same defect pointing the other way. + * + * `#/` is also what keeps a secret out of the column. An OAuth implicit-flow or + * magic-link callback arrives as `#access_token=…`, and a rule any looser than + * this one would write bearer tokens into `events.path` and render them in a + * dashboard panel; anything not route-shaped is dropped exactly as before. + * + * The route ends at the first `?`, `&` or `#`, and all three matter. `?` is + * where a hash router puts its search params, so `#/orders?page=2` is one page. + * The other two are how a token gets past the `#/` test: a redirect URI that + * already has a fragment is undefined territory in RFC 6749, and providers + * resolve it by appending — `#/callback&access_token=…` and + * `#/callback#access_token=…` are both shapes a hash-routed app's OAuth + * callback really lands on. A route segment holding a literal `&` is truncated + * as the price, which is a rare page named slightly short against a bearer + * token in a rendered column. + * + * The trailing slash is collapsed for the reason the pathname's is, and a bare + * `#/` is the router's root — the page `/` already names — so it is dropped + * rather than made a second row for the same page. + */ +const route = (hash: string): string => { + if (!hash.startsWith("#/")) { + return ""; + } + + // Sliced past the `#` before splitting, or the leading one is the first + // separator and the whole route goes with it. + const [head = ""] = hash.slice(1).split(/[?&#]/); + const trimmed = head.replace(/\/+$/, ""); + + return trimmed ? `#${trimmed}` : ""; +}; + +/** + * The server strips a query and an anchor too, and keeps a route-shaped + * fragment on the same rule as `route` above — but it will not collapse a + * trailing slash: `/pricing` and `/pricing/` are two rows in every breakdown + * unless this collapses them, and only the client knows they are one page. + * + * The hash is a second argument rather than part of the first because the + * caller reads `location.pathname` and `location.hash` separately, and because + * every existing caller passing one string must keep meaning "no fragment". + */ +export const normalizePath = (pathname: string, hash = ""): string => { + const [head = ""] = pathname.split(/[?#]/); + const rooted = head.startsWith("/") ? head : `/${head}`; + + return clamp((rooted.replace(/\/+$/, "") || "/") + route(hash), 1024); +}; + +const UTM = ["source", "medium", "campaign", "term", "content"] as const; + +/** + * Only the five keys there are columns for, and only when one carries a value: + * any non-empty utm forces `channel = "campaign"` server-side, so an object of + * blanks would rewrite the acquisition channel of a visit that had none. + */ +export const readUtm = (search: string): Utm | undefined => { + const params = new URLSearchParams(search); + let utm: Utm | undefined; + + for (const key of UTM) { + const value = params.get(`utm_${key}`)?.trim(); + + if (value) { + utm = utm ?? {}; + utm[key] = clamp(value, 255); + } + } + + return utm; +}; + +const MAX_PROPS = 24; + +/** + * One bad property fails the entire beacon server-side — a null, an array, a + * nested object, a 25th key — and the event is worth more than the property + * that broke it, so the offenders are dropped here instead. A caller that + * forwards a user record verbatim gets a thinner event, not a lost one. + */ +export const boundProps = (input: unknown): Props | undefined => { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return undefined; + } + + const props: Props = {}; + let count = 0; + + for (const [key, value] of Object.entries(input)) { + if (count === MAX_PROPS) { + break; + } + + const bounded = + typeof value === "string" + ? clamp(value, 512) + : typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ? value + : undefined; + + if (bounded === undefined) { + continue; + } + + props[clamp(key, 64)] = bounded; + count += 1; + } + + // `{}` parses fine, but an empty object is a key on an unload beacon. + return count > 0 ? props : undefined; +}; + +/** `numeric(14, 2)` overflows past this, and the route rejects rather than + * truncates. Negatives are allowed: a refund is revenue too. */ +const AMOUNT = 999_999_999_999.99; + +/** + * Both halves or neither. `revenue` present without a currency is a 422, and + * so is a currency that is not three ASCII letters, so a half-filled object + * from a caller costs the whole event unless it is dropped here. + */ +export const boundRevenue = (input: unknown): Revenue | undefined => { + if (typeof input !== "object" || input === null) { + return undefined; + } + + const { amount, currency } = input as Partial; + + if ( + typeof amount !== "number" || + !Number.isFinite(amount) || + Math.abs(amount) > AMOUNT + ) { + return undefined; + } + + if (typeof currency !== "string" || !/^[a-z]{3}$/i.test(currency)) { + return undefined; + } + + return { amount, currency }; +}; diff --git a/packages/tracker/src/transport.ts b/packages/tracker/src/transport.ts new file mode 100644 index 00000000..e7bbb393 --- /dev/null +++ b/packages/tracker/src/transport.ts @@ -0,0 +1,47 @@ +import type { CollectPayload, DurationPayload } from "./types"; + +/** + * One-way, always. The routes answer 204 with a null body — no row, no event + * id, no token — which is exactly what makes a beacon possible: there is + * nothing to read back and therefore nothing that has to outlive the page. + * + * No `Content-Type` header on either path. A string body is labelled + * `text/plain;charset=UTF-8`, which is CORS-safelisted, so the request goes out + * on its own; declaring JSON would add a preflight to every beacon, including + * the ones fired during unload where there is no time for two round trips. + * `readPayload` parses the body rather than negotiating it for this reason. + */ +export const send = ( + url: string, + payload: CollectPayload | DurationPayload +): void => { + const body = JSON.stringify(payload); + + try { + // sendBeacon is queued against the browser rather than the document, so it + // survives the unload a flush is usually racing. `false` is a real refusal + // — the queue is full — and not an error, hence the fall-through. + if (navigator.sendBeacon(url, body)) { + return; + } + } catch { + // Absent (or refused by CSP): fetch still has to be tried. + } + + try { + // `keepalive` buys fetch the same survival. The rejection is swallowed on + // purpose: an unhandled rejection is a message in the host page's console, + // which is the one thing this script must never produce — and the throw is + // caught for the same reason, so that a transport that is unavailable + // costs a beacon and not the caller's next statement. + void fetch(url, { + method: "POST", + body, + keepalive: true, + mode: "cors", + credentials: "omit", + }).catch(() => {}); + } catch { + // Nowhere left to send it, and nowhere to report that either. + } +}; diff --git a/packages/tracker/src/types.ts b/packages/tracker/src/types.ts new file mode 100644 index 00000000..1bf1292b --- /dev/null +++ b/packages/tracker/src/types.ts @@ -0,0 +1,96 @@ +/** + * The wire shapes the shipped routes accept — `collectSchema` in + * apps/web/app/routes/collect.ts and `durationSchema` in + * apps/web/app/routes/collect.duration.ts. Neither side can move alone. + * + * Every optional field there is `.optional()` and not `.nullish()`, so an + * explicit `null` is a 422 where an omitted key is fine. They carry a literal + * `| undefined` here so a payload can be built as one object literal and let + * `JSON.stringify` drop whatever the page did not have. + */ + +export type PropValue = string | number | boolean; + +/** Scalars only: the route rejects null, arrays and nested objects outright. */ +export type Props = Record; + +export type Revenue = { + amount: number; + /** ISO-4217. Uppercased server-side, so the case sent here does not matter. */ + currency: string; +}; + +export type Utm = { + source?: string | undefined; + medium?: string | undefined; + campaign?: string | undefined; + term?: string | undefined; + content?: string | undefined; +}; + +/** + * POST /collect. `viewport` is deliberately absent: the schema accepts it and + * the insert never reads it, so it is bytes on every beacon for nothing. + */ +export type CollectPayload = { + wid: string; + type: "pageview" | "event"; + vid: string; + path: string; + /** + * "This pageview's path was wrong, here is where the redirect settled." The + * route moves the row `vid` already names instead of inserting a second one, + * so it carries nothing but the three fields that address that row and the + * path replacing it — everything else on it was right the first time. + */ + corrects?: boolean | undefined; + name?: string | undefined; + referrer?: string | undefined; + language?: string | undefined; + /** + * `navigator.userAgentData.getHighEntropyValues(["platformVersion"])`, when + * the browser has one and has answered in time. + * + * It is on the wire because the header carrying the same value cannot be: a + * browser stores an `Accept-CH` ask only from a top-level navigation + * response, and this origin serves nothing but beacons, so + * `Sec-CH-UA-Platform-Version` never arrives however politely it is asked + * for. Raw rather than reduced — "15.0.0", not "15" — because Windows + * reports a platform version from a table Microsoft publishes, where 15 means + * Windows 11, and only the server holds that table. + */ + platformVersion?: string | undefined; + screen?: number | undefined; + utm?: Utm | undefined; + props?: Props | undefined; + revenue?: Revenue | undefined; +}; + +/** + * POST /collect/duration. The event id never leaves the server, so `vid` is + * how a beacon names the row it is timing. + */ +export type DurationPayload = { + wid: string; + vid: string; + duration: number; +}; + +export type EventOptions = { + props?: Props | undefined; + revenue?: Revenue | undefined; +}; + +/** + * `q` is the async stub's queue: a site that installs the usual one-liner + * ahead of the bundle collects calls into it, and this script drains it. + * + * `loaded` is how a second copy of the bundle recognises a first one that has + * already taken the page. Only the live API carries it — a refusal installs an + * inert function without it, so a deployment that declined for one reason does + * not silence a copy that would have declined for none. + */ +export type AuroraApi = ((name: string, options?: EventOptions) => void) & { + q?: unknown[]; + loaded?: boolean; +}; diff --git a/packages/tracker/tsconfig.json b/packages/tracker/tsconfig.json new file mode 100644 index 00000000..7ddff5fe --- /dev/null +++ b/packages/tracker/tsconfig.json @@ -0,0 +1,26 @@ +{ + "include": ["src/**/*", "vitest.config.ts"], + "compilerOptions": { + "lib": ["DOM", "ES2018"], + "target": "ES2018", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "verbatimModuleSyntax": true, + // This bundle is served to third-party pages, so the Node ambient globals + // hoisted at the repo root must not be in scope: a stray `process` or + // `Buffer` has to be a compile error here, not a ReferenceError on someone + // else's site. + "types": [], + "noEmit": true, + "skipLibCheck": true, + "strict": true, + // esbuild is the only transpiler and compiles each file on its own, so the + // source has to stay safe under single-file transpilation. + "isolatedModules": true, + // The record is full of optional fields whose `undefined` is meaningful; + // without this, writing `undefined` into a `string | null` slips through. + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true + } +} diff --git a/packages/tracker/vitest.config.ts b/packages/tracker/vitest.config.ts new file mode 100644 index 00000000..64075f06 --- /dev/null +++ b/packages/tracker/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +/** + * The tracker reads the DOM as it is imported, so there has to be a document + * before the module is even loaded. + */ +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..f991bd3f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,7624 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: ^21.2.1 + version: 21.2.1(@types/node@22.20.1)(conventional-commits-parser@7.1.2)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: ^21.2.0 + version: 21.2.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + oxfmt: + specifier: ^0.62.0 + version: 0.62.0 + oxlint: + specifier: ^1.77.0 + version: 1.77.0 + + apps/web: + dependencies: + '@base-ui/react': + specifier: ^1.6.0 + version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@fontsource-variable/geist': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/geist-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@hookform/resolvers': + specifier: ^5.7.1 + version: 5.7.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(joi@17.13.4)(react-hook-form@7.84.0(react@19.2.8))(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3) + '@paralleldrive/cuid2': + specifier: ^3.3.0 + version: 3.3.0 + '@react-router/node': + specifier: ^8.3.0 + version: 8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3) + '@react-router/serve': + specifier: ^8.3.0 + version: 8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3) + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/pg@8.20.3)(pg@8.22.0) + isbot: + specifier: ^5.1.36 + version: 5.2.1 + locale-codes: + specifier: ^1.3.1 + version: 1.3.1 + lucide-react: + specifier: ^1.28.0 + version: 1.28.0(react@19.2.8) + pg: + specifier: ^8.22.0 + version: 8.22.0 + react: + specifier: ^19.2.7 + version: 19.2.8 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.18)(react@19.2.8) + react-dom: + specifier: ^19.2.7 + version: 19.2.8(react@19.2.8) + react-hook-form: + specifier: ^7.84.0 + version: 7.84.0(react@19.2.8) + react-router: + specifier: ^8.3.0 + version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + recharts: + specifier: 3.8.0 + version: 3.8.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1) + shadcn: + specifier: ^4.16.1 + version: 4.16.1(babel-plugin-macros@3.1.0)(typescript@5.9.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tailwind-merge: + specifier: ^3.3.1 + version: 3.6.0 + ua-parser-js: + specifier: ^2.0.10 + version: 2.0.10 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@faker-js/faker': + specifier: ^10.1.0 + version: 10.5.0 + '@react-router/dev': + specifier: ^8.3.0 + version: 8.3.0(@react-router/serve@8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3))(babel-plugin-macros@3.1.0)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)) + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.3.3(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.10.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^22 + version: 22.20.1 + '@types/pg': + specifier: ^8.20.3 + version: 8.20.3 + '@types/react': + specifier: ^19.2.14 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.4(@types/react@19.2.18) + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 + jsdom: + specifier: ^30.0.1 + version: 30.0.1(@noble/hashes@2.2.0) + tailwindcss: + specifier: ^4.2.2 + version: 4.3.3 + tracker: + specifier: workspace:* + version: link:../../packages/tracker + tsx: + specifier: ^4.23.5 + version: 4.23.5 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.0.3 + version: 8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)) + + packages/tracker: + devDependencies: + esbuild: + specifier: ^0.27.2 + version: 0.27.7 + jsdom: + specifier: ^30.0.1 + version: 30.0.1(@noble/hashes@2.2.0) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@6.0.5': + resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@base-ui/react@1.6.0': + resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.1': + resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@commitlint/cli@21.2.1': + resolution: {integrity: sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@commitlint/config-conventional@21.2.0': + resolution: {integrity: sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==} + engines: {node: '>=22.12.0'} + + '@commitlint/config-validator@21.2.0': + resolution: {integrity: sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==} + engines: {node: '>=22.12.0'} + + '@commitlint/ensure@21.2.0': + resolution: {integrity: sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==} + engines: {node: '>=22.12.0'} + + '@commitlint/execute-rule@21.0.1': + resolution: {integrity: sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==} + engines: {node: '>=22.12.0'} + + '@commitlint/format@21.2.0': + resolution: {integrity: sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==} + engines: {node: '>=22.12.0'} + + '@commitlint/is-ignored@21.2.0': + resolution: {integrity: sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==} + engines: {node: '>=22.12.0'} + + '@commitlint/lint@21.2.0': + resolution: {integrity: sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==} + engines: {node: '>=22.12.0'} + + '@commitlint/load@21.2.0': + resolution: {integrity: sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==} + engines: {node: '>=22.12.0'} + + '@commitlint/message@21.2.0': + resolution: {integrity: sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==} + engines: {node: '>=22.12.0'} + + '@commitlint/parse@21.2.0': + resolution: {integrity: sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==} + engines: {node: '>=22.12.0'} + + '@commitlint/read@21.2.1': + resolution: {integrity: sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==} + engines: {node: '>=22.12.0'} + + '@commitlint/resolve-extends@21.2.0': + resolution: {integrity: sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==} + engines: {node: '>=22.12.0'} + + '@commitlint/rules@21.2.0': + resolution: {integrity: sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==} + engines: {node: '>=22.12.0'} + + '@commitlint/to-lines@21.0.1': + resolution: {integrity: sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==} + engines: {node: '>=22.12.0'} + + '@commitlint/top-level@21.2.0': + resolution: {integrity: sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==} + engines: {node: '>=22.12.0'} + + '@commitlint/types@21.2.0': + resolution: {integrity: sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==} + engines: {node: '>=22.12.0'} + + '@conventional-changelog/git-client@3.1.0': + resolution: {integrity: sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==} + engines: {node: '>=22'} + peerDependencies: + conventional-commits-filter: ^6.0.1 + conventional-commits-parser: ^7.0.1 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@conventional-changelog/template@1.2.1': + resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} + engines: {node: '>=22'} + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dotenvx/dotenvx@1.75.1': + resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} + hasBin: true + + '@dotenvx/primitives@0.8.0': + resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@faker-js/faker@10.5.0': + resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} + engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@fontsource-variable/geist-mono@5.3.0': + resolution: {integrity: sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==} + + '@fontsource-variable/geist@5.3.0': + resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==} + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + + '@hono/node-server@2.0.12': + resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@hookform/resolvers@5.7.1': + resolution: {integrity: sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==} + peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@typeschema/main': '>=0.13.7' + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^1.2.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^3.10.3 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 + nope-validator: '>=0.12.0' + react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=3.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@typeschema/main': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@oxfmt/binding-android-arm-eabi@0.62.0': + resolution: {integrity: sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.62.0': + resolution: {integrity: sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.62.0': + resolution: {integrity: sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.62.0': + resolution: {integrity: sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.62.0': + resolution: {integrity: sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.62.0': + resolution: {integrity: sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.62.0': + resolution: {integrity: sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.62.0': + resolution: {integrity: sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.62.0': + resolution: {integrity: sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.62.0': + resolution: {integrity: sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.62.0': + resolution: {integrity: sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.62.0': + resolution: {integrity: sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.62.0': + resolution: {integrity: sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.62.0': + resolution: {integrity: sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.62.0': + resolution: {integrity: sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.62.0': + resolution: {integrity: sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.62.0': + resolution: {integrity: sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.62.0': + resolution: {integrity: sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.62.0': + resolution: {integrity: sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.77.0': + resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.77.0': + resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.77.0': + resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.77.0': + resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.77.0': + resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.77.0': + resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.77.0': + resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.77.0': + resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.77.0': + resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.77.0': + resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.77.0': + resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.77.0': + resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.77.0': + resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.77.0': + resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.77.0': + resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@paralleldrive/cuid2@3.3.0': + resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==} + hasBin: true + + '@react-router/dev@8.3.0': + resolution: {integrity: sha512-XR+N2fEFOPjczYo2efc3/AOtosbSICCroLF/IxnZ6ErGBeBGRG6SqAso0SYoff0e18OA05qOyhJHFhXKMGIPRw==} + engines: {node: '>=22.22.0'} + hasBin: true + peerDependencies: + '@react-router/serve': ^8.3.0 + '@vitejs/plugin-rsc': ~0.5.26 + react-router: ^8.3.0 + react-server-dom-webpack: ^19.2.7 + typescript: ^5.1.0 || ^6.0.0 || ^7.0.0 + vite: ^7.0.0 || ^8.0.0 + wrangler: ^4.0.0 + peerDependenciesMeta: + '@react-router/serve': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-webpack: + optional: true + typescript: + optional: true + wrangler: + optional: true + + '@react-router/express@8.3.0': + resolution: {integrity: sha512-ejgJTbGO0CzwjgU4IMM3JTJvqtisc+qF6l0iMzwx+NnrsYwtFy0V1BrajM9499+YQ/420LbuRTtZqDfpUIkwHw==} + engines: {node: '>=22.22.0'} + peerDependencies: + express: ^4.22.2 || ^5 + react-router: 8.3.0 + typescript: ^5.1.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@react-router/node@8.3.0': + resolution: {integrity: sha512-qw5ibcolE1OcwngiEw6t7LR11Hi6yWEDvj6cfvaYROX+w18JPxc0YSmsdn3Wlc9TOX+Qo8FVcxbXRdc9vojn2w==} + engines: {node: '>=22.22.0'} + peerDependencies: + react-router: 8.3.0 + typescript: ^5.1.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@react-router/serve@8.3.0': + resolution: {integrity: sha512-ILFhPizuaNtQKfX1aLg6lr7r5FMZO/DgerUpGFhnK85t7M5c6ZNOb7VUURlJpu/ArUR2Sh+f5nBYTsgFt2R2OA==} + engines: {node: '>=22.22.0'} + hasBin: true + peerDependencies: + react-router: 8.3.0 + + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@remix-run/node-fetch-server@0.13.3': + resolution: {integrity: sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==} + + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + + '@simple-libs/child-process-utils@2.0.0': + resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} + engines: {node: '>=22'} + + '@simple-libs/stream-utils@2.0.0': + resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} + engines: {node: '>=22'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/pg@8.20.3': + resolution: {integrity: sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + argue-cli@3.1.0: + resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} + engines: {node: '>=22'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.12: + resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} + engines: {node: '>=6.0.0'} + hasBin: true + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@9.2.1: + resolution: {integrity: sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==} + engines: {node: '>=22'} + + conventional-changelog-conventionalcommits@10.2.1: + resolution: {integrity: sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==} + engines: {node: '>=22'} + + conventional-commits-parser@7.1.2: + resolution: {integrity: sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==} + engines: {node: '>=22'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-causes@3.0.2: + resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + exit-hook@5.1.0: + resolution: {integrity: sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog==} + engines: {node: '>=20'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.6.1: + resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.34: + resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} + engines: {node: '>=16.9.0'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.15: + resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + iso639-codes@1.0.1: + resolution: {integrity: sha512-jdTSv8yn6D7GODDrRtuWG7y3du3aoa+ki5H8h/Y48/NleNAd7Fw/M2niTTLXGH4QnqhJ98hg1JMQtP9csQ31Lg==} + engines: {node: '>=8'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + joi@17.13.4: + resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==} + + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + langs@2.0.0: + resolution: {integrity: sha512-v4pxOBEQVN1WBTfB1crhTtxzNLZU9HPWgadlwzWKISJtt6Ku/CnpBrwVy+jFv8StjxsPfwPFzO0CMwdZLJ0/BA==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locale-codes@1.3.1: + resolution: {integrity: sha512-C7fxGkU4jAuHqavtKj4IhSD2yPEzChFMRfNHjzwIAz9JTbYHtBJDcQQgmJDezBogk9/vvgS7chKMhpVEKavk5A==} + engines: {node: '>=8'} + + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.28.0: + resolution: {integrity: sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + morgan@1.11.0: + resolution: {integrity: sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==} + engines: {node: '>= 0.8.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + oxfmt@0.62.0: + resolution: {integrity: sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.77.0: + resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + + p-map@7.0.6: + resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} + engines: {node: '>=18'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-hook-form@7.84.0: + resolution: {integrity: sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router@8.3.0: + resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + recast@0.23.19: + resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} + engines: {node: '>= 4'} + + recharts@3.8.0: + resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@4.16.1: + resolution: {integrity: sha512-XLFzfNNIUPlUlyheFEzj0H4Vnhi9nI0nl3Nfgg8HYXW1FkUVhVT1X+mgmOUW8aWL5SeG0A+yJIV5fm3Hr9MVkQ==} + engines: {node: '>=20.18.1'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + systeminformation@5.33.1: + resolution: {integrity: sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==} + engines: {node: '>=10.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser@5.49.0: + resolution: {integrity: sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==} + engines: {node: '>=10'} + hasBin: true + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.5: + resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.10: + resolution: {integrity: sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + windows-locale@1.1.3: + resolution: {integrity: sha512-0OlMOPNGj7GTB6C7WmqS3o4eydjnoYj0uwot2KJf7E0JUucwYwzkcvCWQwnuOV60WqDMeGJpSankgveNMj5r0g==} + engines: {node: '>=v10.24.1'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-spinner@1.2.2: + resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} + engines: {node: '>=18.19'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@6.0.5': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@date-fns/tz': 1.5.0 + '@types/react': 19.2.18 + date-fns: 4.4.0 + + '@base-ui/utils@0.3.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@commitlint/cli@21.2.1(@types/node@22.20.1)(conventional-commits-parser@7.1.2)(typescript@5.9.3)': + dependencies: + '@commitlint/config-conventional': 21.2.0 + '@commitlint/format': 21.2.0 + '@commitlint/lint': 21.2.0 + '@commitlint/load': 21.2.0(@types/node@22.20.1)(typescript@5.9.3) + '@commitlint/read': 21.2.1(conventional-commits-parser@7.1.2) + '@commitlint/types': 21.2.0 + tinyexec: 1.3.0 + yargs: 18.1.0 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + conventional-changelog-conventionalcommits: 10.2.1 + + '@commitlint/config-validator@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + ajv: 8.20.0 + + '@commitlint/ensure@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + es-toolkit: 1.50.0 + + '@commitlint/execute-rule@21.0.1': {} + + '@commitlint/format@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + semver: 7.8.5 + + '@commitlint/lint@21.2.0': + dependencies: + '@commitlint/is-ignored': 21.2.0 + '@commitlint/parse': 21.2.0 + '@commitlint/rules': 21.2.0 + '@commitlint/types': 21.2.0 + + '@commitlint/load@21.2.0(@types/node@22.20.1)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 21.2.0 + '@commitlint/execute-rule': 21.0.1 + '@commitlint/resolve-extends': 21.2.0 + '@commitlint/types': 21.2.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@22.20.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.50.0 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@21.2.0': {} + + '@commitlint/parse@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + conventional-changelog-angular: 9.2.1 + conventional-commits-parser: 7.1.2 + + '@commitlint/read@21.2.1(conventional-commits-parser@7.1.2)': + dependencies: + '@commitlint/top-level': 21.2.0 + '@commitlint/types': 21.2.0 + '@conventional-changelog/git-client': 3.1.0(conventional-commits-parser@7.1.2) + tinyexec: 1.3.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@21.2.0': + dependencies: + '@commitlint/config-validator': 21.2.0 + '@commitlint/types': 21.2.0 + es-toolkit: 1.50.0 + global-directory: 5.0.0 + resolve-from: 5.0.0 + + '@commitlint/rules@21.2.0': + dependencies: + '@commitlint/ensure': 21.2.0 + '@commitlint/message': 21.2.0 + '@commitlint/to-lines': 21.0.1 + '@commitlint/types': 21.2.0 + + '@commitlint/to-lines@21.0.1': {} + + '@commitlint/top-level@21.2.0': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@21.2.0': + dependencies: + conventional-commits-parser: 7.1.2 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@3.1.0(conventional-commits-parser@7.1.2)': + dependencies: + '@simple-libs/child-process-utils': 2.0.0 + '@simple-libs/stream-utils': 2.0.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 7.1.2 + + '@conventional-changelog/template@1.2.1': {} + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@date-fns/tz@1.5.0': {} + + '@dotenvx/dotenvx@1.75.1': + dependencies: + '@dotenvx/primitives': 0.8.0 + commander: 11.1.0 + conf: 10.2.0 + dotenv: 17.4.2 + enquirer: 2.4.1 + env-paths: 2.2.1 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.5) + ignore: 5.3.2 + object-treeify: 1.1.33 + open: 8.4.2 + picomatch: 4.0.5 + systeminformation: 5.33.1 + undici: 7.29.0 + which: 4.0.0 + yocto-spinner: 1.2.2 + + '@dotenvx/primitives@0.8.0': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.1 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + + '@faker-js/faker@10.5.0': {} + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + + '@fontsource-variable/geist-mono@5.3.0': {} + + '@fontsource-variable/geist@5.3.0': {} + + '@hapi/hoek@9.3.0': + optional: true + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + optional: true + + '@hono/node-server@2.0.12(hono@4.12.34)': + dependencies: + hono: 4.12.34 + + '@hookform/resolvers@5.7.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(joi@17.13.4)(react-hook-form@7.84.0(react@19.2.8))(valibot@1.4.2(typescript@5.9.3))(zod@4.4.3)': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.84.0(react@19.2.8) + optionalDependencies: + '@standard-schema/spec': 1.1.0 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + effect: 3.20.0 + joi: 17.13.4 + valibot: 1.4.2(typescript@5.9.3) + zod: 4.4.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 2.0.12(hono@4.12.34) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.1(express@5.2.1) + hono: 4.12.34 + jose: 6.2.8 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@noble/hashes@2.2.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/types@0.142.0': {} + + '@oxfmt/binding-android-arm-eabi@0.62.0': + optional: true + + '@oxfmt/binding-android-arm64@0.62.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.62.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.62.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.62.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.62.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.62.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.62.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.62.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.62.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.62.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.62.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.62.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.62.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.62.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.62.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.62.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.62.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.62.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.77.0': + optional: true + + '@oxlint/binding-android-arm64@1.77.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.77.0': + optional: true + + '@oxlint/binding-darwin-x64@1.77.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.77.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.77.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.77.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.77.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.77.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.77.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.77.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.77.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.77.0': + optional: true + + '@paralleldrive/cuid2@3.3.0': + dependencies: + '@noble/hashes': 2.2.0 + bignumber.js: 9.3.1 + error-causes: 3.0.2 + + '@react-router/dev@8.3.0(@react-router/serve@8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3))(babel-plugin-macros@3.1.0)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@react-router/node': 8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3) + '@remix-run/node-fetch-server': 0.13.3 + babel-dead-code-elimination: 1.0.12 + chokidar: 5.0.0 + dedent: 1.7.2(babel-plugin-macros@3.1.0) + es-module-lexer: 2.3.1 + exit-hook: 5.1.0 + isbot: 5.2.1 + jsesc: 3.1.0 + lodash: 4.18.1 + p-map: 7.0.6 + pathe: 2.0.3 + picocolors: 1.1.1 + pkg-types: 2.3.1 + prettier: 3.9.6 + react-refresh: 0.18.0 + react-router: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + semver: 7.8.5 + tinyglobby: 0.2.17 + valibot: 1.4.2(typescript@5.9.3) + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0) + optionalDependencies: + '@react-router/serve': 8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + '@react-router/express@8.3.0(express@5.2.1)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3)': + dependencies: + '@react-router/node': 8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3) + express: 5.2.1 + react-router: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + optionalDependencies: + typescript: 5.9.3 + + '@react-router/node@8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3)': + dependencies: + '@remix-run/node-fetch-server': 0.13.3 + react-router: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + optionalDependencies: + typescript: 5.9.3 + + '@react-router/serve@8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3)': + dependencies: + '@react-router/express': 8.3.0(express@5.2.1)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3) + '@react-router/node': 8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3) + '@remix-run/node-fetch-server': 0.13.3 + compression: 1.8.1 + express: 5.2.1 + morgan: 1.11.0 + react-router: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + source-map-support: 0.5.21 + transitivePeerDependencies: + - supports-color + - typescript + + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.15 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + + '@remix-run/node-fetch-server@0.13.3': {} + + '@rolldown/binding-android-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + optional: true + + '@sideway/formula@3.0.1': + optional: true + + '@sideway/pinpoint@2.0.0': + optional: true + + '@simple-libs/child-process-utils@2.0.0': + dependencies: + '@simple-libs/stream-utils': 2.0.0 + + '@simple-libs/stream-utils@2.0.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.6 + path-browserify: 1.0.1 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/parse-json@4.0.2': + optional: true + + '@types/pg@8.20.3': + dependencies: + '@types/node': 22.20.1 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@0.0.6': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0) + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn@8.18.0: + optional: true + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + argparse@2.0.1: {} + + argue-cli@3.1.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + atomically@1.7.0: {} + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.7 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + optional: true + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.12: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + bcryptjs@3.0.3: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + bignumber.js@9.3.1: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.12 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.399 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + buffer-from@1.1.2: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001806: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + commander@2.20.3: + optional: true + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.8.5 + + confbox@0.2.4: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + conventional-changelog-angular@9.2.1: + dependencies: + '@conventional-changelog/template': 1.2.1 + + conventional-changelog-conventionalcommits@10.2.1: + dependencies: + '@conventional-changelog/template': 1.2.1 + + conventional-commits-parser@7.1.2: + dependencies: + '@simple-libs/stream-utils': 2.0.0 + argue-cli: 3.1.0 + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig-typescript-loader@6.3.0(@types/node@22.20.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 22.20.1 + cosmiconfig: 9.0.2(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + optional: true + + cosmiconfig@9.0.2(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + data-urls@7.0.0(@noble/hashes@2.2.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + + date-fns@4.4.0: {} + + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + decimal.js@10.6.0: {} + + dedent@1.7.2(babel-plugin-macros@3.1.0): + optionalDependencies: + babel-plugin-macros: 3.1.0 + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-europe-js@0.1.2: {} + + detect-libc@2.1.2: {} + + diff@8.0.4: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.5 + + drizzle-orm@0.45.2(@types/pg@8.20.3)(pg@8.22.0): + optionalDependencies: + '@types/pg': 8.20.3 + pg: 8.22.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + optional: true + + electron-to-chromium@1.5.399: {} + + emoji-regex@10.6.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@8.0.0: {} + + env-paths@2.2.1: {} + + error-causes@3.0.2: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-toolkit@1.50.0: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + exit-hook@5.1.0: {} + + expect-type@1.4.0: {} + + express-rate-limit@8.6.1(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.4.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + optional: true + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.5: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-tsconfig@4.14.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.34: {} + + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + husky@9.1.7: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + immer@10.2.0: {} + + immer@11.1.15: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + ini@6.0.0: {} + + internmap@2.0.3: {} + + ip-address@10.4.0: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + optional: true + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-standalone-pwa@0.1.1: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isbot@5.2.1: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + iso639-codes@1.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.6.1: {} + + jiti@2.7.0: {} + + joi@17.13.4: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + optional: true + + jose@6.2.8: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + jsdom@30.0.1(@noble/hashes@2.2.0): + dependencies: + '@asamuzakjp/css-color': 6.0.5 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0(@noble/hashes@2.2.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@7.0.3: {} + + json-schema-typed@8.0.2: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + langs@2.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lines-and-columns@1.2.4: {} + + locale-codes@1.3.1: + dependencies: + iso639-codes: 1.0.1 + langs: 2.0.0 + windows-locale: 1.1.3 + + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + lodash@4.18.1: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.28.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + math-intrinsics@1.1.0: {} + + mdn-data@2.27.1: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-fn@3.1.0: {} + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimist@1.2.8: {} + + morgan@1.11.0: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.4.1 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.17: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + node-releases@2.0.51: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + obug@2.1.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + oxfmt@0.62.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.62.0 + '@oxfmt/binding-android-arm64': 0.62.0 + '@oxfmt/binding-darwin-arm64': 0.62.0 + '@oxfmt/binding-darwin-x64': 0.62.0 + '@oxfmt/binding-freebsd-x64': 0.62.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.62.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.62.0 + '@oxfmt/binding-linux-arm64-gnu': 0.62.0 + '@oxfmt/binding-linux-arm64-musl': 0.62.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.62.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.62.0 + '@oxfmt/binding-linux-riscv64-musl': 0.62.0 + '@oxfmt/binding-linux-s390x-gnu': 0.62.0 + '@oxfmt/binding-linux-x64-gnu': 0.62.0 + '@oxfmt/binding-linux-x64-musl': 0.62.0 + '@oxfmt/binding-openharmony-arm64': 0.62.0 + '@oxfmt/binding-win32-arm64-msvc': 0.62.0 + '@oxfmt/binding-win32-ia32-msvc': 0.62.0 + '@oxfmt/binding-win32-x64-msvc': 0.62.0 + + oxlint@1.77.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.77.0 + '@oxlint/binding-android-arm64': 1.77.0 + '@oxlint/binding-darwin-arm64': 1.77.0 + '@oxlint/binding-darwin-x64': 1.77.0 + '@oxlint/binding-freebsd-x64': 1.77.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.77.0 + '@oxlint/binding-linux-arm-musleabihf': 1.77.0 + '@oxlint/binding-linux-arm64-gnu': 1.77.0 + '@oxlint/binding-linux-arm64-musl': 1.77.0 + '@oxlint/binding-linux-ppc64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-musl': 1.77.0 + '@oxlint/binding-linux-s390x-gnu': 1.77.0 + '@oxlint/binding-linux-x64-gnu': 1.77.0 + '@oxlint/binding-linux-x64-musl': 1.77.0 + '@oxlint/binding-openharmony-arm64': 1.77.0 + '@oxlint/binding-win32-arm64-msvc': 1.77.0 + '@oxlint/binding-win32-ia32-msvc': 1.77.0 + '@oxlint/binding-win32-x64-msvc': 1.77.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + + p-map@7.0.6: {} + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@3.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: + optional: true + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: + optional: true + + pathe@2.0.3: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pkce-challenge@5.0.1: {} + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.25: + dependencies: + nanoid: 3.3.17 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + powershell-utils@0.1.0: {} + + prettier@3.9.6: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: + optional: true + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-hook-form@7.84.0(react@19.2.8): + dependencies: + react: 19.2.8 + + react-is@17.0.2: {} + + react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + + react-refresh@0.18.0: {} + + react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie-es: 3.1.1 + react: 19.2.8 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + readdirp@5.0.0: {} + + recast@0.23.19: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + recharts@3.8.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.50.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-is: 17.0.2 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.8) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + require-from-string@2.0.2: {} + + reselect@5.1.1: {} + + reselect@5.2.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + optional: true + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shadcn@4.16.1(babel-plugin-macros@3.1.0)(typescript@5.9.3): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@dotenvx/dotenvx': 1.75.1 + '@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.7 + commander: 14.0.3 + cosmiconfig: 9.0.2(typescript@5.9.3) + dedent: 1.7.2(babel-plugin-macros@3.1.0) + deepmerge: 4.3.1 + diff: 8.0.4 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.4.0 + fuzzysort: 3.1.0 + kleur: 4.1.5 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.25 + postcss-selector-parser: 7.1.4 + prompts: 2.4.2 + recast: 0.23.19 + stringify-object: 5.0.0 + tailwind-merge: 3.6.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + undici: 7.29.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - babel-plugin-macros + - supports-color + - typescript + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + stdin-discarder@0.2.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: + optional: true + + symbol-tree@3.2.4: {} + + systeminformation@5.33.1: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + terser@5.49.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@2.1.0: {} + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.23.5: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + tw-animate-css@1.4.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.10: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + + undici-types@6.21.0: {} + + undici@7.29.0: {} + + undici@8.10.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + util-deprecate@1.0.2: {} + + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.49.0 + tsx: 4.23.5 + yaml: 2.9.0 + + vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.49.0 + tsx: 4.23.5 + yaml: 2.9.0 + + vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 30.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + + vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 30.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + windows-locale@1.1.3: {} + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@1.10.3: + optional: true + + yaml@2.9.0: + optional: true + + yargs-parser@22.0.0: {} + + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-spinner@1.2.2: + dependencies: + yoctocolors: 2.2.0 + + yoctocolors@2.2.0: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..604aa519 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - "apps/web" + - "packages/tracker" + +onlyBuiltDependencies: + - "@prisma/client" + - "@prisma/engines" + - "prisma" + - "esbuild" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..226809d5 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-component-in-tag": false, + "bootstrap-sha": "508d1003e086549f2b9eb1766432aa1e0122e5c4", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "refactor", "section": "Refactoring" }, + { "type": "docs", "section": "Documentation" }, + { "type": "revert", "section": "Reverts" }, + { "type": "build", "section": "Build", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "style", "section": "Style", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true } + ], + "packages": { + ".": { + "release-type": "node", + "package-name": "aurora", + "extra-files": [ + { + "type": "json", + "path": "packages/tracker/package.json", + "jsonpath": "$.version" + } + ] + } + } +}