diff --git a/.vercelignore b/.vercelignore index 9c83dcc..5c172e4 100644 --- a/.vercelignore +++ b/.vercelignore @@ -1,4 +1,5 @@ node_modules .env server.js +workers README.md \ No newline at end of file diff --git a/convex/_generated/ai/ai-files.state.json b/convex/_generated/ai/ai-files.state.json deleted file mode 100644 index f13f231..0000000 --- a/convex/_generated/ai/ai-files.state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "guidelinesHash": "5bd785c187c21712add62126eae2a55ff25cf93da28a0cb33bee5511745dafb8", - "agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3", - "claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3", - "agentSkillsSha": "ec1e6baae7d86c7843c22938c75979c016f5c6e9" -} diff --git a/convex/_generated/ai/guidelines.md b/convex/_generated/ai/guidelines.md deleted file mode 100644 index d42b7c7..0000000 --- a/convex/_generated/ai/guidelines.md +++ /dev/null @@ -1,371 +0,0 @@ -# Convex guidelines - -These guidelines target Convex `^1.41.0`. - -## Function guidelines - -### Http endpoint syntax - -- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: - -```typescript -import { httpRouter } from "convex/server"; -import { httpAction } from "./_generated/server"; -const http = httpRouter(); -http.route({ - path: "/echo", - method: "POST", - handler: httpAction(async (ctx, req) => { - const body = await req.bytes(); - return new Response(body, { status: 200 }); - }), -}); -``` - -- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. - -### Validators - -- Below is an example of an array validator: - -```typescript -import { mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export default mutation({ - args: { - simpleArray: v.array(v.union(v.string(), v.number())), - }, - handler: async (ctx, args) => { - //... - }, -}); -``` - -- Below is an example of a schema with validators that codify a discriminated union type: - -```typescript -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - results: defineTable( - v.union( - v.object({ - kind: v.literal("error"), - errorMessage: v.string(), - }), - v.object({ - kind: v.literal("success"), - value: v.number(), - }), - ), - ), -}); -``` - -- Here are the valid Convex types along with their respective validators: - Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | - | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| - | Id | string | `doc._id` | `v.id(tableName)` | | - | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | - | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | - | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | - | Boolean | boolean | `true` | `v.boolean()` | - | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | - | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | - | Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | - | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | -| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". | - -### Function registration - -- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. -- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. -- You CANNOT register a function through the `api` or `internal` objects. -- ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. - -### Function calling - -- Use `ctx.runQuery` to call a query from a query, mutation, or action. -- Use `ctx.runMutation` to call a mutation from a mutation or action. -- Use `ctx.runAction` to call an action from an action. -- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. -- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. -- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. -- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, - -``` -export const f = query({ - args: { name: v.string() }, - handler: async (ctx, args) => { - return "Hello " + args.name; - }, -}); - -export const g = query({ - args: {}, - handler: async (ctx, args) => { - const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); - return null; - }, -}); -``` - -### Function references - -- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. -- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. -- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. -- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. -- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. - -### Pagination - -- Define pagination using the following syntax: - -```ts -import { v } from "convex/values"; -import { query, mutation } from "./_generated/server"; -import { paginationOptsValidator } from "convex/server"; -export const listWithExtraArg = query({ - args: { paginationOpts: paginationOptsValidator, author: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("messages") - .withIndex("by_author", (q) => q.eq("author", args.author)) - .order("desc") - .paginate(args.paginationOpts); - }, -}); -``` - -Note: `paginationOpts` is an object with the following properties: - -- `numItems`: the maximum number of documents to return (the validator is `v.number()`) -- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) -- A query that ends in `.paginate()` returns an object that has the following properties: -- page (contains an array of documents that you fetches) -- isDone (a boolean that represents whether or not this is the last page of documents) -- continueCursor (a string that represents the cursor to use to fetch the next page of documents) - -## Schema guidelines - -- Always define your schema in `convex/schema.ts`. -- Always import the schema definition functions from `convex/server`. -- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. -- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". -- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. -- Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent. -- Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record. - -## Authentication guidelines - -- Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`. -- Example `convex/auth.config.ts`: - -```typescript -export default { - providers: [ - { - domain: "https://your-auth-provider.com", - applicationID: "convex", - }, - ], -}; -``` - -The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim. - -- Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier. -- In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key. -- NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`. -- When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`: - -```tsx -import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react"; - -const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); - -function App({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} -``` - -The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests. - -## Typescript guidelines - -- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. -- Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table. -- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type. -- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: - -```ts -import { query } from "./_generated/server"; -import { Doc, Id } from "./_generated/dataModel"; - -export const exampleQuery = query({ - args: { userIds: v.array(v.id("users")) }, - handler: async (ctx, args) => { - const idToUsername: Record, string> = {}; - for (const userId of args.userIds) { - const user = await ctx.db.get("users", userId); - if (user) { - idToUsername[user._id] = user.username; - } - } - - return idToUsername; - }, -}); -``` - -- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. -- For typed app environment variables, declare them in `convex/convex.config.ts` with `defineApp({ env: { MY_KEY: v.optional(v.string()) } })` and read them with `env` from `./_generated/server` instead of `process.env`. - -## Full text search guidelines - -- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: - -const messages = await ctx.db -.query("messages") -.withSearchIndex("search_body", (q) => -q.search("body", "hello hi").eq("channel", "#general"), -) -.take(10); - -## Query guidelines - -- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. -- If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way. -- Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations. -- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete("tasks", row._id)`, and repeat until no more results are returned. -- Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits. -- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. -- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. - -### Ordering - -- By default Convex always returns documents in ascending `_creationTime` order. -- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. -- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. - -## Mutation guidelines - -- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace("tasks", taskId, { name: "Buy milk", completed: false })` -- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch("tasks", taskId, { completed: true })` - -## Action guidelines - -- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. -- Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file. -- `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`. -- Never use `ctx.db` inside of an action. Actions don't have access to the database. -- Below is an example of the syntax for an action: - -```ts -import { action } from "./_generated/server"; - -export const exampleAction = action({ - args: {}, - handler: async (ctx, args) => { - console.log("This action does not return anything"); - return null; - }, -}); -``` - -## Scheduling guidelines - -### Cron guidelines - -- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. -- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. -- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, - -```ts -import { cronJobs } from "convex/server"; -import { internal } from "./_generated/api"; -import { internalAction } from "./_generated/server"; - -const empty = internalAction({ - args: {}, - handler: async (ctx, args) => { - console.log("empty"); - }, -}); - -const crons = cronJobs(); - -// Run `internal.crons.empty` every two hours. -crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); - -export default crons; -``` - -- You can register Convex functions within `crons.ts` just like any other file. -- If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file. - -## Testing guidelines - -- Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`. - -Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`: - -```typescript -/// -import { convexTest } from "convex-test"; -import { expect, test } from "vitest"; -import { api } from "./_generated/api"; -import schema from "./schema"; - -const modules = import.meta.glob("./**/*.ts"); - -test("some behavior", async () => { - const t = convexTest(schema, modules); - await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" }); - const messages = await t.query(api.messages.list); - expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]); -}); -``` - -The `modules` argument is required so convex-test can discover and load function files. The `/// ` directive is needed for TypeScript to recognize `import.meta.glob`. - -- Only add the `/// ` directive at the top of test files that call `import.meta.glob`; do NOT add it to non-test files. -- Do NOT add a `compilerOptions.types` allowlist to `tsconfig.json` for type packages you have not installed (e.g. `"node"` without `@types/node`, or `"vite/client"` without vite). Any unresolved entry in `types` fails typechecking with TS2688. Leave `types` unset unless a package genuinely requires it and is installed. - -## File storage guidelines - -- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. -- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. - -Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. - -``` -import { query } from "./_generated/server"; -import { Id } from "./_generated/dataModel"; - -type FileMetadata = { - _id: Id<"_storage">; - _creationTime: number; - contentType?: string; - sha256: string; - size: number; -} - -export const exampleQuery = query({ - args: { fileId: v.id("_storage") }, - handler: async (ctx, args) => { - const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId); - console.log(metadata); - return null; - }, -}); -``` - -- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts deleted file mode 100644 index dc5f60a..0000000 --- a/convex/_generated/api.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type * as crons from "../crons.js"; -import type * as dashboard from "../dashboard.js"; -import type * as events from "../events.js"; -import type * as http from "../http.js"; -import type * as stats from "../stats.js"; - -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; - -declare const fullApi: ApiFromModules<{ - crons: typeof crons; - dashboard: typeof dashboard; - events: typeof events; - http: typeof http; - stats: typeof stats; -}>; - -/** - * A utility for referencing Convex functions in your app's public API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export declare const api: FilterApi< - typeof fullApi, - FunctionReference ->; - -/** - * A utility for referencing Convex functions in your app's internal API. - * - * Usage: - * ```js - * const myFunctionReference = internal.myModule.myFunction; - * ``` - */ -export declare const internal: FilterApi< - typeof fullApi, - FunctionReference ->; - -export declare const components: {}; diff --git a/convex/_generated/api.js b/convex/_generated/api.js deleted file mode 100644 index 9124e12..0000000 --- a/convex/_generated/api.js +++ /dev/null @@ -1,23 +0,0 @@ - -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { anyApi, componentsGeneric } from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export const api = anyApi; -export const internal = anyApi; -export const components = componentsGeneric(); diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts deleted file mode 100644 index f29136b..0000000 --- a/convex/_generated/dataModel.d.ts +++ /dev/null @@ -1,60 +0,0 @@ - -/** - * Generated data model types. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames, -} from "convex/server"; -import type { GenericId } from "convex/values"; -import schema from "../schema.js"; - -/** - * The names of all of your Convex tables. - */ -export type TableNames = TableNamesInDataModel; - -/** - * The type of a document stored in Convex. - * - * @typeParam TableName - A string literal type of the table name (like "users"). - */ -export type Doc = DocumentByName< - DataModel, - TableName ->; - -/** - * An identifier for a document in Convex. - * - * Convex documents are uniquely identified by their `Id`, which is accessible - * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). - * - * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. - * - * IDs are just strings at runtime, but this type can be used to distinguish them from other - * strings when type checking. - * - * @typeParam TableName - A string literal type of the table name (like "users"). - */ -export type Id = - GenericId; - -/** - * A type describing your Convex data model. - * - * This type includes information about what tables you have, the type of - * documents stored in those tables, and the indexes defined on them. - * - * This type is used to parameterize methods like `queryGeneric` and - * `mutationGeneric` to make them type-safe. - */ -export type DataModel = DataModelFromSchemaDefinition; diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts deleted file mode 100644 index a6e81e7..0000000 --- a/convex/_generated/server.d.ts +++ /dev/null @@ -1,143 +0,0 @@ - -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter, -} from "convex/server"; -import type { DataModel } from "./dataModel.js"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export declare const query: QueryBuilder; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export declare const internalQuery: QueryBuilder; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export declare const mutation: MutationBuilder; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export declare const internalMutation: MutationBuilder; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export declare const action: ActionBuilder; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export declare const internalAction: ActionBuilder; - -/** - * Define an HTTP action. - * - * The wrapped function will be used to respond to HTTP requests received - * by a Convex deployment if the requests matches the path and method where - * this action is routed. Be sure to route your httpAction in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument - * and a Fetch API `Request` object as its second. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export declare const httpAction: HttpActionBuilder; - -/** - * A set of services for use within Convex query functions. - * - * The query context is passed as the first argument to any Convex query - * function run on the server. - * - * This differs from the {@link MutationCtx} because all of the services are - * read-only. - */ -export type QueryCtx = GenericQueryCtx; - -/** - * A set of services for use within Convex mutation functions. - * - * The mutation context is passed as the first argument to any Convex mutation - * function run on the server. - */ -export type MutationCtx = GenericMutationCtx; - -/** - * A set of services for use within Convex action functions. - * - * The action context is passed as the first argument to any Convex action - * function run on the server. - */ -export type ActionCtx = GenericActionCtx; - -/** - * An interface to read from the database within Convex query functions. - * - * The two entry points are {@link DatabaseReader.get}, which fetches a single - * document by its {@link Id}, or {@link DatabaseReader.query}, which starts - * building a query. - */ -export type DatabaseReader = GenericDatabaseReader; - -/** - * An interface to read from and write to the database within Convex mutation - * functions. - * - * Convex guarantees that all writes within a single mutation are - * executed atomically, so you never have to worry about partial writes leaving - * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) - * for the guarantees Convex provides your functions. - */ -export type DatabaseWriter = GenericDatabaseWriter; diff --git a/convex/_generated/server.js b/convex/_generated/server.js deleted file mode 100644 index 8df3867..0000000 --- a/convex/_generated/server.js +++ /dev/null @@ -1,93 +0,0 @@ - -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, -} from "convex/server"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const query = queryGeneric; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const internalQuery = internalQueryGeneric; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const mutation = mutationGeneric; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const internalMutation = internalMutationGeneric; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export const action = actionGeneric; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export const internalAction = internalActionGeneric; - -/** - * Define an HTTP action. - * - * The wrapped function will be used to respond to HTTP requests received - * by a Convex deployment if the requests matches the path and method where - * this action is routed. Be sure to route your httpAction in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument - * and a Fetch API `Request` object as its second. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export const httpAction = httpActionGeneric; diff --git a/convex/crons.ts b/convex/crons.ts deleted file mode 100644 index 395659a..0000000 --- a/convex/crons.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { cronJobs } from "convex/server"; -import { internal } from "./_generated/api"; - -const crons = cronJobs(); - -// Sweep for sessions that have gone quiet for 30+ min and mark them closed. -crons.interval("close stale sessions", { minutes: 10 }, internal.stats.closeStaleSessions, {}); - -// Roll up yesterday's activity once a day. Run at 00:15 UTC so the full -// previous day's sessions have had a chance to close first. -crons.cron("compute daily stats", "15 0 * * *", internal.stats.computeDailyStats, {}); - -export default crons; diff --git a/convex/dashboard.ts b/convex/dashboard.ts deleted file mode 100644 index d673a73..0000000 --- a/convex/dashboard.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { action, query, type QueryCtx } from "./_generated/server"; -import { internal } from "./_generated/api"; -import { v } from "convex/values"; -import { paginationOptsValidator } from "convex/server"; - -const SESSION_TIMEOUT_MS = 30 * 60 * 1000; -const dateKey = (ms: number) => new Date(ms).toISOString().slice(0, 10); - -export const listSessions = query({ - args: { - paginationOpts: paginationOptsValidator, - search: v.optional(v.string()), - sortBy: v.optional( - v.union(v.literal("startedAt"), v.literal("durationMs"), v.literal("eventCount"), v.literal("errorCount")) - ), - sortDir: v.optional(v.union(v.literal("asc"), v.literal("desc"))), - onlyReturning: v.optional(v.boolean()), - onlyWithErrors: v.optional(v.boolean()), - }, - handler: async (ctx, args) => { - const sortDir = args.sortDir ?? "desc"; - - if (args.search && args.search.trim().length > 0) { - const results = await ctx.db - .query("sessions") - .withSearchIndex("search_entryUrl", (q) => q.search("entryUrl", args.search!)) - .paginate(args.paginationOpts); - - return { - ...results, - page: results.page.filter( - (s) => - (!args.onlyReturning || s.isReturning) && - (!args.onlyWithErrors || s.errorCount > 0) - ), - }; - } - - const indexName = `by_${args.sortBy ?? "startedAt"}` as - | "by_startedAt" - | "by_durationMs" - | "by_eventCount" - | "by_errorCount"; - - let q = ctx.db.query("sessions").withIndex(indexName).order(sortDir); - - if (args.onlyReturning) { - q = q.filter((row) => row.eq(row.field("isReturning"), true)) as typeof q; - } - if (args.onlyWithErrors) { - q = q.filter((row) => row.gt(row.field("errorCount"), 0)) as typeof q; - } - - return await q.paginate(args.paginationOpts); - }, -}); - -export const listEvents = query({ - args: { - paginationOpts: paginationOptsValidator, - search: v.optional(v.string()), - type: v.optional( - v.union(v.literal("pageview"), v.literal("interaction"), v.literal("error"), v.literal("custom")) - ), - }, - handler: async (ctx, args) => { - if (args.search && args.search.trim().length > 0) { - const sq = ctx.db.query("events").withSearchIndex("search_name", (q) => { - const base = q.search("name", args.search!); - return args.type ? base.eq("type", args.type) : base; - }); - return await sq.paginate(args.paginationOpts); - } - - if (args.type) { - return await ctx.db - .query("events") - .withIndex("by_type_time", (q) => q.eq("type", args.type!)) - .order("desc") - .paginate(args.paginationOpts); - } - - return await ctx.db.query("events").order("desc").paginate(args.paginationOpts); - }, -}); - -export const getOverview = query({ - args: { startDate: v.string(), endDate: v.string() }, - handler: async (ctx, { startDate, endDate }) => { - const dayStart = new Date(`${startDate}T00:00:00.000Z`).getTime(); - const dayEnd = new Date(`${endDate}T23:59:59.999Z`).getTime(); - - const daily = await ctx.db - .query("dailyStats") - .withIndex("by_date", (q) => q.gte("date", startDate).lte("date", endDate)) - .collect(); - - const totalPageViews = await countPageViews(ctx, dayStart, dayEnd); - - if (daily.length > 0) { - const totals = daily.reduce( - (acc, d) => ({ - newUsers: acc.newUsers + d.newUsers, - returningUsers: acc.returningUsers + d.returningUsers, - totalSessions: acc.totalSessions + d.totalSessions, - totalErrors: acc.totalErrors + d.totalErrors, - totalEvents: acc.totalEvents + d.totalEvents, - }), - { newUsers: 0, returningUsers: 0, totalSessions: 0, totalErrors: 0, totalEvents: 0 } - ); - - const avgDuration = - daily.length > 0 - ? Math.round(daily.reduce((sum, d) => sum + d.avgSessionDurationMs, 0) / daily.length) - : 0; - - return { - totals: { ...totals, totalPageViews }, - avgSessionDurationMs: avgDuration, - series: daily.sort((a, b) => a.date.localeCompare(b.date)), - }; - } - - const sessionsInRange = await ctx.db - .query("sessions") - .withIndex("by_startedAt", (q) => q.gte("startedAt", dayStart).lt("startedAt", dayEnd)) - .collect(); - - const newMachineIds = new Set(); - const returningSessions = sessionsInRange.filter((s) => s.isReturning); - for (const s of sessionsInRange) { - if (!s.isReturning) newMachineIds.add(s.machineId); - } - - const totalErrors = sessionsInRange.reduce((sum, s) => sum + s.errorCount, 0); - const totalEvents = sessionsInRange.reduce((sum, s) => sum + s.eventCount, 0); - const durations = sessionsInRange - .map((s) => s.durationMs ?? s.lastActivityAt - s.startedAt) - .filter((d) => d > 0); - const avgDuration = - durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0; - - return { - totals: { - newUsers: newMachineIds.size, - returningUsers: new Set(returningSessions.map((s) => s.machineId)).size, - totalSessions: sessionsInRange.length, - totalErrors, - totalEvents, - totalPageViews, - }, - avgSessionDurationMs: Math.round(avgDuration), - series: [ - { - date: dateKey(Date.now()), - newUsers: newMachineIds.size, - returningUsers: new Set(returningSessions.map((s) => s.machineId)).size, - totalSessions: sessionsInRange.length, - totalErrors, - totalEvents, - avgSessionDurationMs: Math.round(avgDuration), - }, - ], - }; - }, -}); - -export const getErrorBreakdown = query({ - args: { since: v.number() }, - handler: async (ctx, { since }) => { - const errors = await ctx.db - .query("events") - .withIndex("by_type_time", (q) => q.eq("type", "error").gte("timestamp", since)) - .collect(); - - const counts = new Map(); - for (const e of errors) counts.set(e.name, (counts.get(e.name) ?? 0) + 1); - - return Array.from(counts.entries()) - .map(([name, value]) => ({ name, value })) - .sort((a, b) => b.value - a.value) - .slice(0, 8); - }, -}); - -/** Fetch full error events for a given error name — shows stack traces, URLs, session IDs, timestamps. */ -export const getErrorDetails = query({ - args: { errorName: v.string(), since: v.number(), limit: v.optional(v.number()) }, - handler: async (ctx, { errorName, since, limit }) => { - const errors = await ctx.db - .query("events") - .withIndex("by_type_time", (q) => q.eq("type", "error").gte("timestamp", since)) - .collect(); - - const filtered = errors - .filter((e) => e.name === errorName) - .sort((a, b) => b.timestamp - a.timestamp); - - const page = limit ? filtered.slice(0, limit) : filtered; - - return page.map((e) => ({ - id: e._id, - name: e.name, - payload: e.payload, - url: e.url, - timestamp: e.timestamp, - sessionId: e.sessionId, - machineId: e.machineId, - })); - }, -}); - -/** Fetch aggregated stats for a single machine. */ -function pii(value: T, identity: unknown): T | null { - return identity ? (value ?? null) : null; -} - -export const getMachineStats = query({ - args: { machineId: v.string() }, - handler: async (ctx, { machineId }) => { - const identity = await ctx.auth.getUserIdentity(); - - const machine = await ctx.db - .query("machines") - .withIndex("by_machineId", (q) => q.eq("machineId", machineId)) - .unique(); - - if (!machine) return null; - - const sessions = await ctx.db - .query("sessions") - .withIndex("by_machineId", (q) => q.eq("machineId", machineId)) - .collect(); - - const totalSessions = sessions.length; - const totalErrors = sessions.reduce((s, x) => s + x.errorCount, 0); - const totalEvents = sessions.reduce((s, x) => s + x.eventCount, 0); - const returningSessions = sessions.filter((s) => s.isReturning).length; - const durations = sessions - .map((s) => s.durationMs ?? s.lastActivityAt - s.startedAt) - .filter((d) => d > 0); - const avgDurationMs = durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0; - - const errorNames = await ctx.db - .query("events") - .withIndex("by_machine_type_time", (q) => - q.eq("machineId", machineId).eq("type", "error") - ) - .collect(); - const topErrors = Array.from( - errorNames.reduce((map, e) => map.set(e.name, (map.get(e.name) ?? 0) + 1), new Map()) - ) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([name, count]) => ({ name, count })); - - const pageviews = await ctx.db - .query("events") - .withIndex("by_machine_type_time", (q) => - q.eq("machineId", machineId).eq("type", "pageview") - ) - .collect(); - const topPages = Array.from( - pageviews.reduce((map, e) => map.set(e.url, (map.get(e.url) ?? 0) + 1), new Map()) - ) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([url, count]) => ({ url, count })); - - const lastSession = sessions.length > 0 - ? sessions.reduce((a, b) => (a.lastActivityAt > b.lastActivityAt ? a : b)) - : null; - - return { - machine: { - id: machine.machineId, - userId: machine.userId ?? null, - firstSeenAt: machine.firstSeenAt, - lastSeenAt: machine.lastSeenAt, - visitCount: machine.visitCount, - userAgent: machine.userAgent ?? null, - platform: machine.platform ?? null, - referrer: machine.referrer ?? null, - ip: machine.ip ?? null, - country: machine.country ?? null, - region: machine.region ?? null, - city: machine.city ?? null, - screen: machine.screen ?? null, - userEmail: pii(machine.userEmail, identity), - userName: pii(machine.userName, identity), - authProvider: pii(machine.authProvider, identity), - }, - stats: { - totalSessions, - totalErrors, - totalEvents, - returningSessions, - avgSessionDurationMs: avgDurationMs, - returningRate: totalSessions > 0 ? Math.round((returningSessions / totalSessions) * 100) : 0, - }, - topErrors, - topPages, - lastSession: lastSession ? { - id: lastSession.sessionId, - startedAt: lastSession.startedAt, - entryUrl: lastSession.entryUrl ?? null, - exitUrl: lastSession.exitUrl ?? null, - durationMs: lastSession.durationMs, - errorCount: lastSession.errorCount, - } : null, - }; - }, -}); - -async function countPageViews(ctx: QueryCtx, dayStart: number, dayEnd: number): Promise { - const pvs = await ctx.db - .query("events") - .withIndex("by_type_time", (q) => - q.eq("type", "pageview").gte("timestamp", dayStart).lt("timestamp", dayEnd) - ) - .collect(); - return pvs.length; -} - -/** Most viewed pages in a date range. */ -export const getTopPages = query({ - args: { startDate: v.string(), endDate: v.string(), limit: v.optional(v.number()) }, - handler: async (ctx, { startDate, endDate, limit }) => { - const dayStart = new Date(`${startDate}T00:00:00.000Z`).getTime(); - const dayEnd = new Date(`${endDate}T23:59:59.999Z`).getTime(); - - const pageviews = await ctx.db - .query("events") - .withIndex("by_type_time", (q) => - q.eq("type", "pageview").gte("timestamp", dayStart).lt("timestamp", dayEnd) - ) - .collect(); - - const pageMap = new Map }>(); - for (const e of pageviews) { - const entry = pageMap.get(e.url) ?? { viewCount: 0, machines: new Set() }; - entry.viewCount++; - entry.machines.add(e.machineId); - pageMap.set(e.url, entry); - } - - return Array.from(pageMap.entries()) - .map(([url, { viewCount, machines }]) => ({ url, viewCount, uniqueMachines: machines.size })) - .sort((a, b) => b.viewCount - a.viewCount) - .slice(0, limit ?? 20); - }, -}); - -/** Machines that visited a specific page. */ -export const getPageVisitors = query({ - args: { url: v.string(), startDate: v.string(), endDate: v.string() }, - handler: async (ctx, { url, startDate, endDate }) => { - const identity = await ctx.auth.getUserIdentity(); - const dayStart = new Date(`${startDate}T00:00:00.000Z`).getTime(); - const dayEnd = new Date(`${endDate}T23:59:59.999Z`).getTime(); - - const pageviews = await ctx.db - .query("events") - .withIndex("by_type_time", (q) => - q.eq("type", "pageview").gte("timestamp", dayStart).lt("timestamp", dayEnd) - ) - .collect(); - - const matching = pageviews.filter((e) => e.url === url); - - const machineMap = new Map(); - for (const e of matching) { - const entry = machineMap.get(e.machineId) ?? { visitCount: 0, firstVisitedAt: e.timestamp, lastVisitedAt: e.timestamp }; - entry.visitCount++; - entry.firstVisitedAt = Math.min(entry.firstVisitedAt, e.timestamp); - entry.lastVisitedAt = Math.max(entry.lastVisitedAt, e.timestamp); - machineMap.set(e.machineId, entry); - } - - const machines = await Promise.all( - Array.from(machineMap.entries()).map(async ([machineId, stats]) => { - const machine = await ctx.db - .query("machines") - .withIndex("by_machineId", (q) => q.eq("machineId", machineId)) - .unique(); - return { - machineId, - visitCount: stats.visitCount, - firstVisitedAt: stats.firstVisitedAt, - lastVisitedAt: stats.lastVisitedAt, - country: machine?.country ?? null, - platform: machine?.platform ?? null, - userAgent: machine?.userAgent ?? null, - userEmail: pii(machine?.userEmail, identity), - userName: pii(machine?.userName, identity), - authProvider: pii(machine?.authProvider, identity), - }; - }) - ); - - return machines.sort((a, b) => b.visitCount - a.visitCount); - }, -}); - -/** Daily time series of page views. */ -export const getPageViewsOverTime = query({ - args: { startDate: v.string(), endDate: v.string() }, - handler: async (ctx, { startDate, endDate }) => { - const dayStart = new Date(`${startDate}T00:00:00.000Z`).getTime(); - const dayEnd = new Date(`${endDate}T23:59:59.999Z`).getTime(); - - const pageviews = await ctx.db - .query("events") - .withIndex("by_type_time", (q) => - q.eq("type", "pageview").gte("timestamp", dayStart).lt("timestamp", dayEnd) - ) - .collect(); - - const dayMap = new Map }>(); - for (const e of pageviews) { - const date = dateKey(e.timestamp); - const entry = dayMap.get(date) ?? { pageViews: 0, machines: new Set() }; - entry.pageViews++; - entry.machines.add(e.machineId); - dayMap.set(date, entry); - } - - return Array.from(dayMap.entries()) - .map(([date, { pageViews, machines }]) => ({ date, pageViews, uniqueMachines: machines.size })) - .sort((a, b) => a.date.localeCompare(b.date)); - }, -}); - -/** Autocomplete / search machines by machineId prefix. */ -export const searchMachines = query({ - args: { prefix: v.string() }, - handler: async (ctx, { prefix }) => { - const identity = await ctx.auth.getUserIdentity(); - if (!prefix || prefix.trim().length === 0) return []; - const results = await ctx.db - .query("machines") - .withSearchIndex("search_machineId", (q) => q.search("machineId", prefix)) - .take(20); - return results.map((m) => ({ - id: m.machineId, - label: `${m.machineId.slice(0, 12)}…`, - country: m.country ?? null, - platform: m.platform ?? null, - lastSeenAt: m.lastSeenAt, - userEmail: pii(m.userEmail, identity), - userName: pii(m.userName, identity), - authProvider: pii(m.authProvider, identity), - })); - }, -}); - -/** Manually recompute daily stats for yesterday and today. Call this after deploying or whenever you want to refresh dashboard data without waiting for the cron. */ -export const recomputeStats = action({ - args: {}, - handler: async (ctx) => { - await ctx.runMutation(internal.stats.closeStaleSessions, {}); - await ctx.runMutation(internal.stats.computeDailyStats, {}); - await ctx.runMutation(internal.stats.computeDailyStats, { - date: dateKey(Date.now()), - }); - return { done: true }; - }, -}); diff --git a/convex/events.ts b/convex/events.ts deleted file mode 100644 index aa079aa..0000000 --- a/convex/events.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { internalMutation, mutation, query } from "./_generated/server"; -import { v } from "convex/values"; -import type { MutationCtx } from "./_generated/server"; - -const dateKey = (ms: number) => new Date(ms).toISOString().slice(0, 10); // "YYYY-MM-DD" - -const eventValidator = v.object({ - sessionId: v.string(), - machineId: v.string(), - userId: v.optional(v.string()), - type: v.union( - v.literal("pageview"), - v.literal("interaction"), - v.literal("error"), - v.literal("custom") - ), - name: v.string(), - payload: v.optional(v.any()), - url: v.string(), - timestamp: v.number(), -}); - -async function upsertMachine( - ctx: MutationCtx, - args: { - machineId: string; - userId?: string; - timestamp: number; - meta?: Record; - geo?: { ip?: string; country?: string; region?: string; city?: string }; - userInfo?: { email?: string; name?: string; provider?: string }; - } -) { - const existing = await ctx.db - .query("machines") - .withIndex("by_machineId", (q) => q.eq("machineId", args.machineId)) - .unique(); - - if (!existing) { - await ctx.db.insert("machines", { - machineId: args.machineId, - userId: args.userId, - firstSeenAt: args.timestamp, - lastSeenAt: args.timestamp, - firstSeenDate: dateKey(args.timestamp), - visitCount: 1, - userAgent: args.meta?.userAgent as string | undefined, - platform: args.meta?.platform as string | undefined, - referrer: args.meta?.referrer as string | undefined, - screen: args.meta?.screen as string | undefined, - ip: args.geo?.ip, - country: args.geo?.country, - region: args.geo?.region, - city: args.geo?.city, - userEmail: args.userInfo?.email, - userName: args.userInfo?.name, - authProvider: args.userInfo?.provider, - }); - return { isNewMachine: true }; - } - - await ctx.db.patch(existing._id, { - lastSeenAt: args.timestamp, - userId: args.userId ?? existing.userId, - userAgent: args.meta?.userAgent ?? existing.userAgent, - platform: args.meta?.platform ?? existing.platform, - referrer: args.meta?.referrer ?? existing.referrer, - screen: args.meta?.screen ?? existing.screen, - userEmail: args.userInfo?.email ?? existing.userEmail, - userName: args.userInfo?.name ?? existing.userName, - authProvider: args.userInfo?.provider ?? existing.authProvider, - }); - return { isNewMachine: false }; -} - -async function upsertSession( - ctx: MutationCtx, - args: { - sessionId: string; - machineId: string; - userId?: string; - timestamp: number; - url: string; - isError: boolean; - } -) { - const existing = await ctx.db - .query("sessions") - .withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId)) - .unique(); - - if (existing) { - await ctx.db.patch(existing._id, { - lastActivityAt: args.timestamp, - eventCount: existing.eventCount + 1, - errorCount: existing.errorCount + (args.isError ? 1 : 0), - exitUrl: args.url, - userId: args.userId ?? existing.userId, - }); - return; - } - - // Brand new session — check if this machine has any prior session to mark "returning". - const priorSession = await ctx.db - .query("sessions") - .withIndex("by_machineId", (q) => q.eq("machineId", args.machineId)) - .first(); - - await ctx.db.insert("sessions", { - sessionId: args.sessionId, - machineId: args.machineId, - userId: args.userId, - startedAt: args.timestamp, - lastActivityAt: args.timestamp, - eventCount: 1, - errorCount: args.isError ? 1 : 0, - isReturning: priorSession !== null, - entryUrl: args.url, - exitUrl: args.url, - }); - - // A brand new session on an existing machine counts as a return visit. - const machine = await ctx.db - .query("machines") - .withIndex("by_machineId", (q) => q.eq("machineId", args.machineId)) - .unique(); - if (machine && priorSession !== null) { - await ctx.db.patch(machine._id, { visitCount: machine.visitCount + 1 }); - } -} - -function buildUserInfoMap(events: Array<{ machineId: string; name: string; payload?: unknown }>): Map { - const map = new Map(); - for (const event of events) { - if (event.name !== "session_identify") continue; - if (!event.payload || typeof event.payload !== "object") continue; - const p = event.payload as Record; - const email = typeof p.email === "string" ? p.email : undefined; - const name = typeof p.name === "string" ? p.name : undefined; - const provider = typeof p.provider === "string" ? p.provider : undefined; - if (email || name || provider) { - map.set(event.machineId, { email, name, provider }); - } - } - return map; -} - -export const recordBatch = mutation({ - args: { events: v.array(eventValidator) }, - handler: async (ctx, { events }) => { - // Process oldest-first so ordering-dependent bookkeeping (session creation, counters) is correct. - const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp); - const userInfoMap = buildUserInfoMap(sorted); - - for (const event of sorted) { - const isBookkeeping = event.name === "session_start" || event.name === "session_identify"; - - await upsertMachine(ctx, { - machineId: event.machineId, - userId: event.userId, - timestamp: event.timestamp, - meta: isBookkeeping ? event.payload : undefined, - userInfo: userInfoMap.get(event.machineId), - }); - - await upsertSession(ctx, { - sessionId: event.sessionId, - machineId: event.machineId, - userId: event.userId, - timestamp: event.timestamp, - url: event.url, - isError: event.type === "error", - }); - - await ctx.db.insert("events", { - sessionId: event.sessionId, - machineId: event.machineId, - userId: event.userId, - type: event.type, - name: event.name, - payload: event.payload, - url: event.url, - timestamp: event.timestamp, - }); - } - - return { inserted: sorted.length }; - }, -}); - -/** Fetch recent events for a single session — useful for a session-replay-lite debug view. */ -export const getSessionEvents = query({ - args: { sessionId: v.string() }, - handler: async (ctx, { sessionId }) => { - return await ctx.db - .query("events") - .withIndex("by_session", (q) => q.eq("sessionId", sessionId)) - .order("asc") - .collect(); - }, -}); - -/** Most frequent error names in a time range — your "top errors" dashboard widget. */ -export const getTopErrors = query({ - args: { since: v.number() }, - handler: async (ctx, { since }) => { - const errors = await ctx.db - .query("events") - .withIndex("by_type_time", (q) => q.eq("type", "error").gte("timestamp", since)) - .collect(); - - const counts = new Map(); - for (const e of errors) counts.set(e.name, (counts.get(e.name) ?? 0) + 1); - - return Array.from(counts.entries()) - .map(([name, count]) => ({ name, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 20); - }, -}); - -const geoValidator = v.object({ - ip: v.optional(v.string()), - country: v.optional(v.string()), - region: v.optional(v.string()), - city: v.optional(v.string()), -}); - -/** Internal mutation called by the HTTP Action. Same as recordBatch but accepts - * geo metadata resolved server-side (IP, country, region, city) and writes it - * into the machines table on first sighting of each machine. */ -export const recordBatchWithGeo = internalMutation({ - args: { - events: v.array(eventValidator), - geo: geoValidator, - }, - handler: async (ctx, { events, geo }) => { - const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp); - const userInfoMap = buildUserInfoMap(sorted); - - for (const event of sorted) { - const isBookkeeping = event.name === "session_start" || event.name === "session_identify"; - - await upsertMachine(ctx, { - machineId: event.machineId, - userId: event.userId, - timestamp: event.timestamp, - meta: isBookkeeping ? event.payload : undefined, - geo, - userInfo: userInfoMap.get(event.machineId), - }); - - await upsertSession(ctx, { - sessionId: event.sessionId, - machineId: event.machineId, - userId: event.userId, - timestamp: event.timestamp, - url: event.url, - isError: event.type === "error", - }); - - await ctx.db.insert("events", { - sessionId: event.sessionId, - machineId: event.machineId, - userId: event.userId, - type: event.type, - name: event.name, - payload: event.payload, - url: event.url, - timestamp: event.timestamp, - }); - } - - return { inserted: sorted.length }; - }, -}); diff --git a/convex/http.ts b/convex/http.ts deleted file mode 100644 index 22b5e2f..0000000 --- a/convex/http.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { httpRouter } from "convex/server"; -import { httpAction } from "./_generated/server"; -import { internal } from "./_generated/api"; - -const http = httpRouter(); - -const WISP_SECRET = process.env.WISP_SECRET; - -function isAuthorized(request: Request): boolean { - if (!WISP_SECRET) return true; - return request.headers.get("x-wisp-token") === WISP_SECRET; -} - -http.route({ - path: "/ingest", - method: "POST", - handler: httpAction(async (ctx, request) => { - if (!isAuthorized(request)) { - return new Response("Unauthorized", { - status: 401, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, x-wisp-token", - }, - }); - } - - const body = await request.json() as { events: unknown[] }; - const geo = await resolveGeo(request); - - await ctx.runMutation(internal.events.recordBatchWithGeo, { - events: body.events, - geo, - }); - - return new Response(null, { - status: 204, - headers: { "Access-Control-Allow-Origin": "*" }, - }); - }), -}); - -http.route({ - path: "/ingest", - method: "OPTIONS", - handler: httpAction(async () => { - return new Response(null, { - status: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, x-wisp-token", - "Access-Control-Max-Age": "86400", - }, - }); - }), -}); - -async function resolveGeo(request: Request): Promise<{ - ip?: string; - country?: string; - region?: string; - city?: string; -}> { - const ip = extractIp(request); - - // Cloudflare injects these on every proxied request — no cost, no API call. - const cfCountry = request.headers.get("cf-ipcountry") ?? undefined; - const cfRegion = request.headers.get("cf-region") ?? undefined; - const cfCity = request.headers.get("cf-ipcity") ?? undefined; - - // If Cloudflare provides all geo fields, skip the API call. - if (cfCountry && cfRegion && cfCity) { - return { ip, country: cfCountry, region: cfRegion, city: cfCity }; - } - - // Fallback: geo-IP lookup when Cloudflare geo headers are incomplete - // (cf-ipcountry is free, but cf-region and cf-ipcity require Business+). - // ip-api.com free tier (45 req/min), no API key required. - if (ip) { - try { - const res = await fetch( - `https://ip-api.com/json/${ip}?fields=country,regionName,city`, - { signal: AbortSignal.timeout(2000) } - ); - if (res.ok) { - const data = await res.json() as { country?: string; regionName?: string; city?: string }; - return { - ip, - country: cfCountry ?? data.country ?? undefined, - region: cfRegion ?? data.regionName ?? undefined, - city: cfCity ?? data.city ?? undefined, - }; - } - } catch { - // Timeout or network error — return Cloudflare data (even partial) silently - } - } - - return { ip, country: cfCountry, region: cfRegion, city: cfCity }; -} - -function extractIp(request: Request): string | undefined { - const cf = request.headers.get("cf-connecting-ip"); - if (cf) return cf; - - const forwarded = request.headers.get("x-forwarded-for"); - if (forwarded) return forwarded.split(",")[0].trim(); - - const real = request.headers.get("x-real-ip"); - if (real) return real; - - return undefined; -} - -export default http; diff --git a/convex/schema.ts b/convex/schema.ts deleted file mode 100644 index 1a006ff..0000000 --- a/convex/schema.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - machines: defineTable({ - machineId: v.string(), - userId: v.optional(v.string()), - firstSeenAt: v.number(), - lastSeenAt: v.number(), - firstSeenDate: v.string(), // "YYYY-MM-DD", set once — drives new-vs-returning - visitCount: v.number(), - userAgent: v.optional(v.string()), - platform: v.optional(v.string()), - referrer: v.optional(v.string()), - ip: v.optional(v.string()), - country: v.optional(v.string()), - region: v.optional(v.string()), - city: v.optional(v.string()), - screen: v.optional(v.string()), - userEmail: v.optional(v.string()), - userName: v.optional(v.string()), - authProvider: v.optional(v.string()), - }) - .index("by_machineId", ["machineId"]) - .index("by_userId", ["userId"]) - .index("by_firstSeenDate", ["firstSeenDate"]) - .index("by_country", ["country"]) - .searchIndex("search_machineId", { searchField: "machineId" }), - - sessions: defineTable({ - sessionId: v.string(), - machineId: v.string(), - userId: v.optional(v.string()), - startedAt: v.number(), - lastActivityAt: v.number(), - endedAt: v.optional(v.number()), - durationMs: v.optional(v.number()), - eventCount: v.number(), - errorCount: v.number(), - isReturning: v.boolean(), - entryUrl: v.optional(v.string()), - exitUrl: v.optional(v.string()), - }) - .index("by_sessionId", ["sessionId"]) - .index("by_machineId", ["machineId"]) - .index("by_open", ["endedAt"]) - .index("by_open_lastActivityAt", ["endedAt", "lastActivityAt"]) - .index("by_startedAt", ["startedAt"]) - .index("by_durationMs", ["durationMs"]) - .index("by_eventCount", ["eventCount"]) - .index("by_errorCount", ["errorCount"]) - .searchIndex("search_entryUrl", { searchField: "entryUrl" }), - - events: defineTable({ - sessionId: v.string(), - machineId: v.string(), - userId: v.optional(v.string()), - type: v.union( - v.literal("pageview"), - v.literal("interaction"), - v.literal("error"), - v.literal("custom") - ), - name: v.string(), - payload: v.optional(v.any()), - url: v.string(), - timestamp: v.number(), - }) - .index("by_session", ["sessionId"]) - .index("by_type_time", ["type", "timestamp"]) - .index("by_machine_time", ["machineId", "timestamp"]) - .index("by_machine_type_time", ["machineId", "type", "timestamp"]) - .searchIndex("search_name", { searchField: "name", filterFields: ["type"] }), - - dailyStats: defineTable({ - date: v.string(), // "YYYY-MM-DD" - newUsers: v.number(), - returningUsers: v.number(), - totalSessions: v.number(), - totalErrors: v.number(), - totalEvents: v.number(), - avgSessionDurationMs: v.number(), - }).index("by_date", ["date"]), -}); diff --git a/convex/stats.ts b/convex/stats.ts deleted file mode 100644 index 45daf01..0000000 --- a/convex/stats.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { internalMutation, query } from "./_generated/server"; -import { v } from "convex/values"; - -const SESSION_TIMEOUT_MS = 30 * 60 * 1000; -const dateKey = (ms: number) => new Date(ms).toISOString().slice(0, 10); - -/** - * Runs periodically (see crons.ts). A session can't "know" it ended — the - * client just stops sending events — so this sweep is the server-side source - * of truth: any session whose lastActivityAt is more than 30 min old and has - * no endedAt yet is considered closed, and gets a final durationMs. - */ -export const closeStaleSessions = internalMutation({ - args: {}, - handler: async (ctx) => { - const cutoff = Date.now() - SESSION_TIMEOUT_MS; - - const stale = await ctx.db - .query("sessions") - .withIndex("by_open_lastActivityAt", (q) => - q.eq("endedAt", undefined).lt("lastActivityAt", cutoff) - ) - .collect(); - - for (const session of stale) { - await ctx.db.patch(session._id, { - endedAt: session.lastActivityAt, - durationMs: session.lastActivityAt - session.startedAt, - }); - } - - return { closed: stale.length }; - }, -}); - -/** - * Rolls yesterday's (or today's, if run more frequently) sessions/machines up - * into a single dailyStats row. Cheap to query from a dashboard instead of - * scanning raw sessions every time. - */ -export const computeDailyStats = internalMutation({ - args: { date: v.optional(v.string()) }, - handler: async (ctx, { date }) => { - const targetDate = date ?? dateKey(Date.now() - 24 * 60 * 60 * 1000); // default: yesterday - const dayStart = new Date(`${targetDate}T00:00:00.000Z`).getTime(); - const dayEnd = dayStart + 24 * 60 * 60 * 1000; - - const sessionsToday = await ctx.db - .query("sessions") - .withIndex("by_startedAt", (q) => q.gte("startedAt", dayStart).lt("startedAt", dayEnd)) - .collect(); - - const newMachines = await ctx.db - .query("machines") - .withIndex("by_firstSeenDate", (q) => q.eq("firstSeenDate", targetDate)) - .collect(); - - const returningSessions = sessionsToday.filter((s) => s.isReturning); - const totalErrors = sessionsToday.reduce((sum, s) => sum + s.errorCount, 0); - const totalEvents = sessionsToday.reduce((sum, s) => sum + s.eventCount, 0); - const durations = sessionsToday - .map((s) => s.durationMs ?? s.lastActivityAt - s.startedAt) - .filter((d) => d > 0); - const avgDuration = - durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0; - - const existing = await ctx.db - .query("dailyStats") - .withIndex("by_date", (q) => q.eq("date", targetDate)) - .unique(); - - const row = { - date: targetDate, - newUsers: newMachines.length, - returningUsers: new Set(returningSessions.map((s) => s.machineId)).size, - totalSessions: sessionsToday.length, - totalErrors, - totalEvents, - avgSessionDurationMs: Math.round(avgDuration), - }; - - if (existing) { - await ctx.db.patch(existing._id, row); - } else { - await ctx.db.insert("dailyStats", row); - } - - return row; - }, -}); - -/** Dashboard query: stats over a date range, e.g. last 30 days. */ -export const getDailyStats = query({ - args: { startDate: v.string(), endDate: v.string() }, - handler: async (ctx, { startDate, endDate }) => { - return await ctx.db - .query("dailyStats") - .withIndex("by_date", (q) => q.gte("date", startDate).lte("date", endDate)) - .collect(); - }, -}); diff --git a/eslint.config.js b/eslint.config.js index e67846f..03e6383 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist"] }, + { ignores: ["dist", "workers"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], diff --git a/package.json b/package.json index be11377..aaa4314 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", - "engines": { "node": ">=20" }, + "engines": { + "node": ">=20" + }, "scripts": { "prebuild": "node scripts/generate_sitemap.mjs && node -e \"const fs=require('fs'),p=require('path');fs.mkdirSync('dist',{recursive:true});const src='changelog.md',dst=p.join('public','changelog.md');if(fs.existsSync(src)){fs.mkdirSync('public',{recursive:true});fs.copyFileSync(src,dst);}\"", "dev": "concurrently \"pnpm run dev:vite\" \"pnpm run dev:server\"", @@ -62,7 +64,6 @@ "@radix-ui/react-use-layout-effect": "^1.1.1", "@radix-ui/react-use-previous": "^1.1.1", "@radix-ui/react-visually-hidden": "^1.2.4", - "@renderdragonorg/wisp": "^0.2.0", "@sentry/react": "^10.38.0", "@supabase/supabase-js": "^2.95.3", "@tabler/icons-react": "^3.36.1", @@ -107,6 +108,7 @@ "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.3", "react-turnstile": "^1.1.5", + "recharts": "2.15.4", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^2.6.1", @@ -132,7 +134,6 @@ "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.24", "concurrently": "^9.2.1", - "convex": "^1.42.1", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 295fe52..15b8de4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@distube/ytdl-core': specifier: ^4.16.12 - version: 4.16.12(supports-color@8.1.1) + version: 4.16.12 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -31,7 +31,7 @@ importers: version: 5.2.8 '@google/genai': specifier: ^0.13.0 - version: 0.13.0(supports-color@8.1.1) + version: 0.13.0 '@hcaptcha/react-hcaptcha': specifier: ^1.17.4 version: 1.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -149,9 +149,6 @@ importers: '@radix-ui/react-visually-hidden': specifier: ^1.2.4 version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@renderdragonorg/wisp': - specifier: ^0.2.0 - version: 0.2.0(@supabase/supabase-js@2.98.0)(react@18.3.1) '@sentry/react': specifier: ^10.38.0 version: 10.40.0(react@18.3.1) @@ -166,16 +163,16 @@ importers: version: 5.90.21(react@18.3.1) '@uploadthing/react': specifier: ^7.3.3 - version: 7.3.3(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.7.4(express@5.2.1(supports-color@8.1.1))(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0))) + version: 7.3.3(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.7.4(express@5.2.1)(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0))) '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.6.1(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/node': specifier: ^5.5.33 - version: 5.6.9(rollup@4.59.0)(supports-color@8.1.1) + version: 5.6.9(rollup@4.59.0) '@vercel/speed-insights': specifier: ^1.3.1 - version: 1.3.1(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.3.1(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) aria-hidden: specifier: ^1.2.6 version: 1.2.6 @@ -211,7 +208,7 @@ importers: version: 8.6.0(react@18.3.1) express: specifier: ^5.2.1 - version: 5.2.1(supports-color@8.1.1) + version: 5.2.1 file-saver: specifier: ^2.0.5 version: 2.0.5 @@ -244,7 +241,7 @@ importers: version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) openai: specifier: ^4.104.0 - version: 4.104.0(ws@8.19.0)(zod@3.25.76) + version: 4.104.0(ws@8.21.0)(zod@3.25.76) pixelarticons: specifier: ^1.8.1 version: 1.8.2 @@ -271,7 +268,7 @@ importers: version: 9.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@18.3.28)(react@18.3.1)(supports-color@8.1.1) + version: 10.1.0(@types/react@18.3.28)(react@18.3.1) react-remove-scroll: specifier: ^2.7.2 version: 2.7.2(@types/react@18.3.28)(react@18.3.1) @@ -284,9 +281,12 @@ importers: react-turnstile: specifier: ^1.1.5 version: 1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) remark-gfm: specifier: ^4.0.1 - version: 4.0.1(supports-color@8.1.1) + version: 4.0.1 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -298,7 +298,7 @@ importers: version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)) uploadthing: specifier: ^7.7.4 - version: 7.7.4(express@5.2.1(supports-color@8.1.1))(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)) + version: 7.7.4(express@5.2.1)(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)) vaul: specifier: ^0.9.9 version: 0.9.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -320,7 +320,7 @@ importers: version: 9.39.3 '@sentry/vite-plugin': specifier: ^4.9.0 - version: 4.9.1(supports-color@8.1.1) + version: 4.9.1 '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)) @@ -354,18 +354,15 @@ importers: concurrently: specifier: ^9.2.1 version: 9.2.1 - convex: - specifier: ^1.42.1 - version: 1.42.1(react@18.3.1) eslint: specifier: ^9.39.2 - version: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + version: 9.39.3(jiti@1.21.7) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1)) + version: 5.2.0(eslint@9.39.3(jiti@1.21.7)) eslint-plugin-react-refresh: specifier: ^0.4.26 - version: 0.4.26(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1)) + version: 0.4.26(eslint@9.39.3(jiti@1.21.7)) globals: specifier: ^15.15.0 version: 15.15.0 @@ -380,7 +377,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.54.0 - version: 8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + version: 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) vite: specifier: ^5.4.21 version: 5.4.21(@types/node@22.19.13) @@ -964,105 +961,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1174,28 +1155,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.3.5': resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.3.5': resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.3.5': resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.3.5': resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==} @@ -1959,14 +1936,6 @@ packages: resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} - '@renderdragonorg/wisp@0.2.0': - resolution: {integrity: sha512-xgT+Ti2xBXIKQharK4lHC95e4zJYJQMyEPBBA2s3uqwU0ec7edEgLpDiWHd7wSd8JRPua0jy+XOOEdGcF0/ERw==} - peerDependencies: - '@supabase/supabase-js': ^2.110.0 - peerDependenciesMeta: - '@supabase/supabase-js': - optional: true - '@renovatebot/pep440@4.2.1': resolution: {integrity: sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==} engines: {node: ^20.9.0 || ^22.11.0 || ^24, pnpm: ^10.0.0} @@ -2017,79 +1986,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -2268,28 +2224,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.18': resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.18': resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.18': resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.18': resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} @@ -2372,6 +2324,33 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@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.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@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/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2921,25 +2900,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convex@1.42.1: - resolution: {integrity: sha512-yFKp4xerVuBwQqpuTtvosOk9F/fX0twZs+Xti3oj/MlwPUU90QuhgCYo/I2wvFBAFwXsmx9tXpf2q8ekh1+3ug==} - engines: {node: '>=18.0.0', npm: '>=7.0.0'} - hasBin: true - peerDependencies: - '@auth0/auth0-react': ^2.0.1 - '@clerk/clerk-react': ^4.12.8 || ^5.0.0 - '@clerk/react': ^6.4.3 - react: ^18.0.0 || ^19.0.0-0 || ^19.0.0 - peerDependenciesMeta: - '@auth0/auth0-react': - optional: true - '@clerk/clerk-react': - optional: true - '@clerk/react': - optional: true - react: - optional: true - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2970,6 +2930,50 @@ packages: 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'} + date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} @@ -2982,6 +2986,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3016,6 +3023,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} @@ -3144,6 +3154,7 @@ packages: eslint@9.39.3: resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3185,6 +3196,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -3202,6 +3216,10 @@ packages: fast-equals@2.0.4: resolution: {integrity: sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==} + fast-equals@5.4.2: + resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -3498,6 +3516,10 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -3649,6 +3671,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4187,11 +4212,6 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} - engines: {node: '>=14'} - hasBin: true - pretty-ms@7.0.1: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} @@ -4289,6 +4309,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -4334,6 +4357,12 @@ packages: peerDependencies: react: '>=16.8' + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4344,6 +4373,12 @@ packages: '@types/react': optional: true + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react-turnstile@1.1.5: resolution: {integrity: sha512-VTL5OeHAatzCEVQxAZox70/TPmhKxEbNgtr++dg+8zm9QrWKuoU9E0+7gqmycOSCDZuJFzvMMLKQb5PVUPLV6w==} peerDependencies: @@ -4369,6 +4404,16 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4837,6 +4882,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + video.js@8.23.7: resolution: {integrity: sha512-cG4HOygYt+Z8j6Sf5DuK6OgEOoM+g9oGP6vpqoZRaD13aHE4PMITbyjJUXZcIQbgB0wJEadBRaVm5lJIzo2jAA==} @@ -5007,20 +5055,20 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.0(supports-color@8.1.1)': + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.28.6 '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0(supports-color@8.1.1) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5045,19 +5093,19 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.28.6(supports-color@8.1.1)': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.29.0(supports-color@8.1.1) + '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) - '@babel/helper-module-imports': 7.28.6(supports-color@8.1.1) + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0(supports-color@8.1.1) + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -5084,7 +5132,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0(supports-color@8.1.1)': + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -5092,7 +5140,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5103,10 +5151,10 @@ snapshots: '@bytecodealliance/preview2-shim@0.17.6': {} - '@distube/ytdl-core@4.16.12(supports-color@8.1.1)': + '@distube/ytdl-core@4.16.12': dependencies: http-cookie-agent: 7.0.3(tough-cookie@5.1.2)(undici@7.22.0) - https-proxy-agent: 7.0.6(supports-color@8.1.1) + https-proxy-agent: 7.0.6 m3u8stream: 0.8.6 miniget: 4.2.3 sax: 1.4.4 @@ -5312,17 +5360,17 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@1.21.7))': dependencies: - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + eslint: 9.39.3(jiti@1.21.7) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1(supports-color@8.1.1)': + '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -5335,10 +5383,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.4(supports-color@8.1.1)': + '@eslint/eslintrc@3.3.4': dependencies: ajv: 6.14.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -5391,9 +5439,9 @@ snapshots: '@fontsource/jetbrains-mono@5.2.8': {} - '@google/genai@0.13.0(supports-color@8.1.1)': + '@google/genai@0.13.0': dependencies: - google-auth-library: 9.15.1(supports-color@8.1.1) + google-auth-library: 9.15.1 ws: 8.19.0 zod: 3.25.76 zod-to-json-schema: 3.25.1(zod@3.25.76) @@ -5576,11 +5624,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mapbox/node-pre-gyp@2.0.3(supports-color@8.1.1)': + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 detect-libc: 2.1.2 - https-proxy-agent: 7.0.6(supports-color@8.1.1) + https-proxy-agent: 7.0.6 node-fetch: 2.6.9 nopt: 8.1.0 semver: 7.7.4 @@ -6421,19 +6469,6 @@ snapshots: '@remix-run/router@1.23.2': {} - '@renderdragonorg/wisp@0.2.0(@supabase/supabase-js@2.98.0)(react@18.3.1)': - dependencies: - convex: 1.42.1(react@18.3.1) - optionalDependencies: - '@supabase/supabase-js': 2.98.0 - transitivePeerDependencies: - - '@auth0/auth0-react' - - '@clerk/clerk-react' - - '@clerk/react' - - bufferutil - - react - - utf-8-validate - '@renovatebot/pep440@4.2.1': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -6549,11 +6584,11 @@ snapshots: '@sentry-internal/replay-canvas': 10.40.0 '@sentry/core': 10.40.0 - '@sentry/bundler-plugin-core@4.9.1(supports-color@8.1.1)': + '@sentry/bundler-plugin-core@4.9.1': dependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/core': 7.29.0 '@sentry/babel-plugin-component-annotate': 4.9.1 - '@sentry/cli': 2.58.5(supports-color@8.1.1) + '@sentry/cli': 2.58.5 dotenv: 16.6.1 find-up: 5.0.0 glob: 10.5.0 @@ -6587,9 +6622,9 @@ snapshots: '@sentry/cli-win32-x64@2.58.5': optional: true - '@sentry/cli@2.58.5(supports-color@8.1.1)': + '@sentry/cli@2.58.5': dependencies: - https-proxy-agent: 5.0.1(supports-color@8.1.1) + https-proxy-agent: 5.0.1 node-fetch: 2.7.0 progress: 2.0.3 proxy-from-env: 1.1.0 @@ -6615,9 +6650,9 @@ snapshots: '@sentry/core': 10.40.0 react: 18.3.1 - '@sentry/vite-plugin@4.9.1(supports-color@8.1.1)': + '@sentry/vite-plugin@4.9.1': dependencies: - '@sentry/bundler-plugin-core': 4.9.1(supports-color@8.1.1) + '@sentry/bundler-plugin-core': 4.9.1 unplugin: 1.0.1 transitivePeerDependencies: - encoding @@ -6772,6 +6807,30 @@ snapshots: dependencies: '@types/node': 22.19.13 + '@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.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -6864,15 +6923,15 @@ snapshots: dependencies: '@types/node': 22.19.13 - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + eslint: 9.39.3(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6880,23 +6939,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + debug: 4.4.3 + eslint: 9.39.3(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) '@typescript-eslint/types': 8.56.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6910,13 +6969,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.3(jiti@1.21.7) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -6924,13 +6983,13 @@ snapshots: '@typescript-eslint/types@8.56.1': {} - '@typescript-eslint/typescript-estree@8.56.1(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -6939,13 +6998,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@1.21.7)) '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(supports-color@8.1.1)(typescript@5.9.3) - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 9.39.3(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6959,14 +7018,14 @@ snapshots: '@uploadthing/mime-types@0.3.6': {} - '@uploadthing/react@7.3.3(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.7.4(express@5.2.1(supports-color@8.1.1))(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)))': + '@uploadthing/react@7.3.3(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.7.4(express@5.2.1)(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)))': dependencies: '@uploadthing/shared': 7.1.10 file-selector: 0.6.0 react: 18.3.1 - uploadthing: 7.7.4(express@5.2.1(supports-color@8.1.1))(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)) + uploadthing: 7.7.4(express@5.2.1)(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)) optionalDependencies: - next: 15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@uploadthing/shared@7.1.10': dependencies: @@ -6974,9 +7033,9 @@ snapshots: effect: 3.17.7 sqids: 0.3.0 - '@vercel/analytics@1.6.1(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@1.6.1(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: - next: 15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 '@vercel/build-utils@13.6.1': @@ -6985,9 +7044,9 @@ snapshots: '@vercel/error-utils@2.0.3': {} - '@vercel/nft@1.1.1(rollup@4.59.0)(supports-color@8.1.1)': + '@vercel/nft@1.1.1(rollup@4.59.0)': dependencies: - '@mapbox/node-pre-gyp': 2.0.3(supports-color@8.1.1) + '@mapbox/node-pre-gyp': 2.0.3 '@rollup/pluginutils': 5.3.0(rollup@4.59.0) acorn: 8.16.0 acorn-import-attributes: 1.9.5(acorn@8.16.0) @@ -7004,7 +7063,7 @@ snapshots: - rollup - supports-color - '@vercel/node@5.6.9(rollup@4.59.0)(supports-color@8.1.1)': + '@vercel/node@5.6.9(rollup@4.59.0)': dependencies: '@edge-runtime/node-utils': 2.3.0 '@edge-runtime/primitives': 4.1.0 @@ -7012,7 +7071,7 @@ snapshots: '@types/node': 20.11.0 '@vercel/build-utils': 13.6.1 '@vercel/error-utils': 2.0.3 - '@vercel/nft': 1.1.1(rollup@4.59.0)(supports-color@8.1.1) + '@vercel/nft': 1.1.1(rollup@4.59.0) '@vercel/static-config': 3.1.2 async-listen: 3.0.0 cjs-module-lexer: 1.2.3 @@ -7044,9 +7103,9 @@ snapshots: smol-toml: 1.5.2 zod: 3.22.4 - '@vercel/speed-insights@1.3.1(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@1.3.1(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: - next: 15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 '@vercel/static-config@3.1.2': @@ -7115,9 +7174,9 @@ snapshots: global: 4.4.0 pkcs7: 1.0.4 - agent-base@6.0.2(supports-color@8.1.1): + agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7205,11 +7264,11 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - body-parser@2.2.2(supports-color@8.1.1): + body-parser@2.2.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -7370,17 +7429,6 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.42.1(react@18.3.1): - dependencies: - esbuild: 0.27.0 - prettier: 3.9.4 - ws: 8.21.0 - optionalDependencies: - react: 18.3.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -7406,13 +7454,51 @@ snapshots: 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: {} + date-fns@3.6.0: {} - debug@4.4.3(supports-color@8.1.1): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 + + decimal.js-light@2.5.1: {} decode-named-character-reference@1.3.0: dependencies: @@ -7438,6 +7524,11 @@ snapshots: dlv@1.1.3: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.6 + csstype: 3.2.3 + dom-walk@0.1.2: {} dotenv@16.6.1: {} @@ -7573,13 +7664,13 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-plugin-react-hooks@5.2.0(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1)): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.3(jiti@1.21.7)): dependencies: - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + eslint: 9.39.3(jiti@1.21.7) - eslint-plugin-react-refresh@0.4.26(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1)): + eslint-plugin-react-refresh@0.4.26(eslint@9.39.3(jiti@1.21.7)): dependencies: - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + eslint: 9.39.3(jiti@1.21.7) eslint-scope@8.4.0: dependencies: @@ -7592,14 +7683,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1): + eslint@9.39.3(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1(supports-color@8.1.1) + '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.4(supports-color@8.1.1) + '@eslint/eslintrc': 3.3.4 '@eslint/js': 9.39.3 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -7609,7 +7700,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -7659,20 +7750,22 @@ snapshots: event-target-shim@5.0.1: {} - express@5.2.1(supports-color@8.1.1): + eventemitter3@4.0.7: {} + + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2(supports-color@8.1.1) + body-parser: 2.2.2 content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + 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(supports-color@8.1.1) + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -7683,9 +7776,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.0 range-parser: 1.2.1 - router: 2.2.0(supports-color@8.1.1) - send: 1.2.1(supports-color@8.1.1) - serve-static: 2.2.1(supports-color@8.1.1) + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 @@ -7702,6 +7795,8 @@ snapshots: fast-equals@2.0.4: {} + fast-equals@5.4.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7738,9 +7833,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1(supports-color@8.1.1): + finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -7816,10 +7911,10 @@ snapshots: fuse.js@6.6.2: {} - gaxios@6.7.1(supports-color@8.1.1): + gaxios@6.7.1: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6(supports-color@8.1.1) + https-proxy-agent: 7.0.6 is-stream: 2.0.1 node-fetch: 2.7.0 uuid: 9.0.1 @@ -7827,9 +7922,9 @@ snapshots: - encoding - supports-color - gcp-metadata@6.1.1(supports-color@8.1.1): + gcp-metadata@6.1.1: dependencies: - gaxios: 6.7.1(supports-color@8.1.1) + gaxios: 6.7.1 google-logging-utils: 0.0.2 json-bigint: 1.0.0 transitivePeerDependencies: @@ -7898,13 +7993,13 @@ snapshots: globals@15.15.0: {} - google-auth-library@9.15.1(supports-color@8.1.1): + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1(supports-color@8.1.1) - gcp-metadata: 6.1.1(supports-color@8.1.1) - gtoken: 7.1.0(supports-color@8.1.1) + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 jws: 4.0.1 transitivePeerDependencies: - encoding @@ -7916,9 +8011,9 @@ snapshots: graceful-fs@4.2.11: {} - gtoken@7.1.0(supports-color@8.1.1): + gtoken@7.1.0: dependencies: - gaxios: 6.7.1(supports-color@8.1.1) + gaxios: 6.7.1 jws: 4.0.1 transitivePeerDependencies: - encoding @@ -7936,7 +8031,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-to-jsx-runtime@2.3.6(supports-color@8.1.1): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -7945,9 +8040,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@8.1.1) - mdast-util-mdx-jsx: 3.2.0(supports-color@8.1.1) - mdast-util-mdxjs-esm: 2.0.1(supports-color@8.1.1) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -7986,17 +8081,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https-proxy-agent@5.0.1(supports-color@8.1.1): + https-proxy-agent@5.0.1: dependencies: - agent-base: 6.0.2(supports-color@8.1.1) - debug: 4.4.3(supports-color@8.1.1) + agent-base: 6.0.2 + debug: 4.4.3 transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@8.1.1): + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -8032,6 +8127,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -8176,6 +8273,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.18.1: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -8216,14 +8315,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3(supports-color@8.1.1): + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@8.1.1) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -8241,67 +8340,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@8.1.1): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@8.1.1): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@8.1.1): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@8.1.1): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@8.1.1): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@8.1.1) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@8.1.1) - mdast-util-gfm-table: 2.0.0(supports-color@8.1.1) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@8.1.1) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@8.1.1): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@8.1.1): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -8309,7 +8408,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -8318,13 +8417,13 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@8.1.1): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -8537,10 +8636,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@8.1.1): + micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -8663,7 +8762,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.3.8 '@swc/counter': 0.1.3 @@ -8673,7 +8772,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@8.1.1))(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 15.3.5 '@next/swc-darwin-x64': 15.3.5 @@ -8731,7 +8830,7 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@4.104.0(ws@8.19.0)(zod@3.25.76): + openai@4.104.0(ws@8.21.0)(zod@3.25.76): dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.13 @@ -8741,7 +8840,7 @@ snapshots: formdata-node: 4.4.1 node-fetch: 2.7.0 optionalDependencies: - ws: 8.19.0 + ws: 8.21.0 zod: 3.25.76 transitivePeerDependencies: - encoding @@ -8883,8 +8982,6 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.4: {} - pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -8973,17 +9070,19 @@ snapshots: react-is@16.13.1: {} - react-markdown@10.1.0(@types/react@18.3.28)(react@18.3.1)(supports-color@8.1.1): + react-is@18.3.1: {} + + react-markdown@10.1.0(@types/react@18.3.28)(react@18.3.1): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/react': 18.3.28 devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6(supports-color@8.1.1) + hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 react: 18.3.1 - remark-parse: 11.0.0(supports-color@8.1.1) + remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 unist-util-visit: 5.1.0 @@ -9027,6 +9126,14 @@ snapshots: '@remix-run/router': 1.23.2 react: 18.3.1 + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.4.2 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-style-singleton@2.2.3(@types/react@18.3.28)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -9035,6 +9142,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.6 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-turnstile@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -9067,21 +9183,38 @@ snapshots: dependencies: picomatch: 2.3.1 - remark-gfm@4.0.1(supports-color@8.1.1): + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@8.1.1) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@8.1.1) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@8.1.1): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@8.1.1) + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -9150,9 +9283,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - router@2.2.0(supports-color@8.1.1): + router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -9184,9 +9317,9 @@ snapshots: semver@7.7.4: {} - send@1.2.1(supports-color@8.1.1): + send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -9200,12 +9333,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1(supports-color@8.1.1): + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@8.1.1) + send: 1.2.1 transitivePeerDependencies: - supports-color @@ -9344,12 +9477,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0(supports-color@8.1.1))(react@18.3.1): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 optionalDependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) + '@babel/core': 7.29.0 optional: true sucrase@3.35.1: @@ -9495,13 +9628,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): + typescript-eslint@8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@1.21.7)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - eslint: 9.39.3(jiti@1.21.7)(supports-color@8.1.1) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.3(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -9568,7 +9701,7 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uploadthing@7.7.4(express@5.2.1(supports-color@8.1.1))(next@15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)): + uploadthing@7.7.4(express@5.2.1)(next@15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.19(tsx@4.21.0)): dependencies: '@effect/platform': 0.90.3(effect@3.17.7) '@standard-schema/spec': 1.0.0-beta.4 @@ -9576,8 +9709,8 @@ snapshots: '@uploadthing/shared': 7.1.10 effect: 3.17.7 optionalDependencies: - express: 5.2.1(supports-color@8.1.1) - next: 15.3.8(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + express: 5.2.1 + next: 15.3.8(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwindcss: 3.4.19(tsx@4.21.0) uri-js@4.4.1: @@ -9632,6 +9765,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@36.9.2: + 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.2.0 + '@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 + video.js@8.23.7: dependencies: '@babel/runtime': 7.28.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index da77883..21b1725 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,5 +6,3 @@ allowBuilds: msgpackr-extract: true protobufjs: true sharp: true -minimumReleaseAgeExclude: - - '@renderdragonorg/wisp@0.1.1 || 0.2.0' diff --git a/src/App.tsx b/src/App.tsx index 481a47e..fe18522 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; -import VercelAnalytics from "@/components/VercelAnalytics"; +import CloudflareAnalytics from "@/components/CloudflareAnalytics"; import { SpeedInsights } from "@vercel/speed-insights/react"; import { AuthProvider } from "@/providers/AuthProvider"; import { useAuth } from "@/hooks/useAuth"; @@ -49,6 +49,7 @@ const PlayerRenderer = lazy(() => import("@/pages/PlayerRenderer")); const Renderbot = lazy(() => import("@/pages/Renderbot")); const Account = lazy(() => import("@/pages/Account")); const Admin = lazy(() => import("@/pages/Admin")); +const Analytics = lazy(() => import("@/pages/Analytics")); const BlogEditor = lazy(() => import("@/components/admin/BlogEditor")); const ProfileEditor = lazy(() => import("@/components/profile/ProfileEditor")); @@ -149,6 +150,7 @@ const App = () => { } /> } /> + } /> @@ -183,10 +185,10 @@ const App = () => { + - diff --git a/src/components/CloudflareAnalytics.tsx b/src/components/CloudflareAnalytics.tsx new file mode 100644 index 0000000..21579fc --- /dev/null +++ b/src/components/CloudflareAnalytics.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { trackPageView } from "@/lib/analytics"; + +const CloudflareAnalytics = () => { + const location = useLocation(); + + useEffect(() => { + trackPageView(location.pathname + location.search); + }, [location.pathname, location.search]); + + return null; +}; + +export default CloudflareAnalytics; diff --git a/src/components/VercelAnalytics.tsx b/src/components/VercelAnalytics.tsx deleted file mode 100644 index fefd2fa..0000000 --- a/src/components/VercelAnalytics.tsx +++ /dev/null @@ -1,14 +0,0 @@ - -import { useEffect } from 'react'; -import { inject } from '@vercel/analytics'; - -const VercelAnalytics = () => { - useEffect(() => { - // Initialize Vercel Analytics - inject(); - }, []); - - return null; // This component doesn't render anything -}; - -export default VercelAnalytics; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..23dc1c1 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,367 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +
`; + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, + }); + } + + const to = toMs(url.searchParams.get("to"), Date.now()); + const from = toMs(url.searchParams.get("from"), to - DEFAULT_RANGE_MS); + const stats = from < to ? await statsFor(env, from, to) : await statsFor(env, to - DEFAULT_RANGE_MS, to); + + const rows = stats.daily + .map( + (row) => + `${escapeHtml(row.day)}${row.newUsers}${row.returningUsers}${row.visits}`, + ) + .join(""); + + const html = `RenderDragon analytics +

RenderDragon analytics

+
${escapeHtml(new Date(stats.from).toISOString().slice(0, 10))} to ${escapeHtml(new Date(stats.to).toISOString().slice(0, 10))} (UTC)
+
+
${stats.newUsers}new users
+
${stats.returningUsers}returning users
+
${stats.totalUsers}unique users
+
${stats.visits}visits
+
+ ${rows}
daynewreturningvisits
+ `; + + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, + }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const { pathname } = new URL(request.url); + + if (request.method === "OPTIONS") { + const headers = withCors(env, request, new Headers()); + headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + headers.set("Access-Control-Max-Age", "86400"); + return new Response(null, { status: 204, headers }); + } + + try { + if (pathname === "/track") return await handleTrack(request, env); + if (pathname === "/stats") return await handleStats(request, env); + if (pathname === "/" || pathname === "/dashboard") + return await handleDashboard(request, env); + return json(env, request, { error: "not found" }, 404); + } catch (error) { + return json(env, request, { error: "internal error", detail: String(error) }, 500); + } + }, +}; diff --git a/workers/analytics/wrangler.toml b/workers/analytics/wrangler.toml new file mode 100644 index 0000000..3ae85aa --- /dev/null +++ b/workers/analytics/wrangler.toml @@ -0,0 +1,23 @@ +# Deploy steps: +# 1. npx wrangler d1 create renderdragon-analytics +# 2. paste the printed database_id below +# 3. npx wrangler d1 execute renderdragon-analytics --remote --file schema.sql +# 4. npx wrangler secret put STATS_TOKEN +# 5. npx wrangler deploy +# 6. custom domain analytics.codersoft.xyz is attached via the routes block below + +name = "renderdragon-analytics" +main = "src/index.ts" +compatibility_date = "2026-09-01" + +routes = [ + { pattern = "analytics.codersoft.xyz", custom_domain = true } +] + +[[d1_databases]] +binding = "DB" +database_name = "renderdragon-analytics" +database_id = "6fc5d7ca-40e3-4f03-b5e2-1d0c0ef1e07d" + +[vars] +ALLOWED_ORIGIN = "https://renderdragon.org"