From f312779b7e5989162eb1f6256b50abd7d125b075 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 4 Feb 2024 09:08:37 -0500 Subject: [PATCH] Add Prettier config + run on pre-commit hook (#34) --- .eslintrc.cjs | 155 +++++++------- .github/workflows/ci.yaml | 34 +-- .husky/pre-commit | 12 ++ .prettierignore | 5 + .prettierrc | 3 + .vscode/settings.json | 4 +- README.md | 31 ++- app/analytics/collect.test.ts | 132 +++++++----- app/analytics/collect.ts | 77 +++---- app/analytics/query.test.ts | 325 +++++++++++++++------------- app/analytics/query.ts | 328 +++++++++++++++++------------ app/analytics/schema.ts | 4 +- app/components/TableCard.tsx | 64 +++--- app/components/TimeSeriesChart.tsx | 58 +++-- app/components/ui/button.tsx | 93 ++++---- app/components/ui/card.tsx | 127 +++++------ app/components/ui/select.tsx | 258 +++++++++++------------ app/components/ui/table.tsx | 183 ++++++++-------- app/entry.client.tsx | 2 +- app/entry.server.tsx | 4 +- app/globals.css | 102 ++++----- app/lib/utils.ts | 6 +- app/root.tsx | 49 ++++- app/routes/_index.test.tsx | 13 +- app/routes/_index.tsx | 103 ++++++--- app/routes/admin-redirect.tsx | 4 +- app/routes/dashboard.test.tsx | 264 ++++++++++++----------- app/routes/dashboard.tsx | 147 ++++++++----- codecov.yml | 16 +- components.json | 32 +-- global.d.ts | 14 +- package.json | 140 ++++++------ postcss.config.js | 11 +- public/tracker.js | 69 +++--- server.ts | 11 +- tailwind.config.js | 144 ++++++------- tsconfig.json | 18 +- vitest.config.js | 16 +- 38 files changed, 1688 insertions(+), 1370 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 633fec0..db65b08 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -6,88 +6,91 @@ /** @type {import('eslint').Linter.Config} */ module.exports = { - root: true, - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - ecmaFeatures: { - jsx: true, + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, }, - }, - env: { - browser: true, - commonjs: true, - es6: true, - }, - globals: { - // tracker global on window - 'counterscale': true - }, + globals: { + // tracker global on window + counterscale: true, + }, - // Base config - extends: ["eslint:recommended"], + // Base config + extends: ["eslint:recommended"], - overrides: [ - // React - { - files: ["**/*.{js,jsx,ts,tsx}"], - plugins: ["react", "jsx-a11y"], - extends: [ - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:react-hooks/recommended", - "plugin:jsx-a11y/recommended", - ], - settings: { - react: { - version: "detect", + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + }, }, - formComponents: ["Form"], - linkComponents: [ - { name: "Link", linkAttribute: "to" }, - { name: "NavLink", linkAttribute: "to" }, - ], - }, - }, - // Typescript - { - files: ["**/*.{ts,tsx}"], - plugins: ["@typescript-eslint", "import"], - parser: "@typescript-eslint/parser", - settings: { - "import/internal-regex": "^~/", - "import/resolver": { - node: { - extensions: [".ts", ".tsx"], - }, - typescript: { - alwaysTryTypes: true, - }, - }, - }, - rules: { - // we're cool with explicit any (for now) - "@typescript-eslint/no-explicit-any": 0, + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + rules: { + // we're cool with explicit any (for now) + "@typescript-eslint/no-explicit-any": 0, - // https://stackoverflow.com/questions/68802881/get-rid-of-is-defined-but-never-used-in-function-parameter - "no-unused-vars": 0, - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] - }, - extends: [ - "plugin:@typescript-eslint/recommended", - "plugin:import/recommended", - "plugin:import/typescript", - ], - }, + // https://stackoverflow.com/questions/68802881/get-rid-of-is-defined-but-never-used-in-function-parameter + "no-unused-vars": 0, + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_" }, + ], + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, - // Node - { - files: [".eslintrc.js"], - env: { - node: true, - }, - }, - ], + // Node + { + files: [".eslintrc.js"], + env: { + node: true, + }, + }, + ], }; diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b8b6406..114313d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,22 +1,22 @@ name: ci on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: 'package.json' - - run: npm install - - run: npm run lint - - run: npm run build - - run: npm run test-ci - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + - run: npm install + - run: npm run lint + - run: npm run build + - run: npm run test-ci + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.husky/pre-commit b/.husky/pre-commit index ff28b2e..0ae7989 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,17 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" +# Prettify all files that are about to be committed +FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') +[ -z "$FILES" ] && exit 0 + +# Prettify all selected files +echo "$FILES" | xargs ./node_modules/.bin/prettier --ignore-unknown --write + +# Add back the modified/prettified files to staging +echo "$FILES" | xargs git add + npm test npm run lint + +exit 0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ab0c21c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Ignore artifacts: +build +coverage +public/build +package-lock.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0a02bce --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 0b01db8..430afc4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ // Place your settings in this file to overwrite the default settings { "editor.insertSpaces": true, - "editor.tabSize": 4, -} \ No newline at end of file + "editor.tabSize": 4 +} diff --git a/README.md b/README.md index 8053b40..6c6fae0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ If you don't have one already, [create a Cloudflare account here](https://dash.c 1. Run `npx wrangler secret put CF_BEARER_TOKEN` → when prompted, paste the API token you created 1. Run `npx wrangler secret put CF_ACCOUNT_ID` → when prompted, paste your Cloudflare Account ID 1. Run `npm run deploy` – this will do two things: - 1. Create a new worker, `counterscale`, now visible under *Workers and Pages* in Cloudflare + 1. Create a new worker, `counterscale`, now visible under _Workers and Pages_ in Cloudflare 1. Create a new Analytics Engine dataset, called `metricsDataset` 1. It should now be live. Visit `https://counterscale.{yoursubdomain}.workers.dev`. @@ -31,7 +31,6 @@ If the website is not immediately available (e.g. "Secure Connection Failed"), i ### Custom Domains - The deployment URL can always be changed to go behind a custom domain you own. [More here](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/). ## Installing the Tracker @@ -46,16 +45,17 @@ To start tracking website traffic on your web property, copy/paste the following ```html - + ``` Be sure to replace `your-unique-site-id` with a unique string/slug representing your web property. Use a unique site ID for each property you place the tracking script on. @@ -72,8 +72,8 @@ Open `.dev.vars` and enter the same values for `CF_BEARER_TOKEN` and `CF_ACCOUNT Counterscale is built on Remix and Cloudflare Workers. In development, you'll run two servers: -- The Remix development server -- The Miniflare server (local environment for Cloudflare Workers) +- The Remix development server +- The Miniflare server (local environment for Cloudflare Workers) You run both using: @@ -100,6 +100,5 @@ There is only one "database": the Cloudflare Analytics Engine dataset, which is Right now there is no local "test" database. This means in local development: -* Writes will no-op (no hits will be recorded) -* Reads will be read from the production Analaytics Engine dataset (local development shows production data) - +- Writes will no-op (no hits will be recorded) +- Reads will be read from the production Analaytics Engine dataset (local development shows production data) diff --git a/app/analytics/collect.test.ts b/app/analytics/collect.test.ts index 6fded55..4da192d 100644 --- a/app/analytics/collect.test.ts +++ b/app/analytics/collect.test.ts @@ -1,32 +1,35 @@ -import { describe, expect, test, vi } from 'vitest' -import httpMocks from 'node-mocks-http'; +import { describe, expect, test, vi } from "vitest"; +import httpMocks from "node-mocks-http"; -import { collectRequestHandler } from './collect'; +import { collectRequestHandler } from "./collect"; const defaultRequestParams = generateRequestParams({ - "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36" + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", }); function generateRequestParams(headers: any /* todo */) { return { - method: 'GET', - url: 'https://example.com/user/42?' + new URLSearchParams({ - sid: 'example', - h: 'example.com', - p: '/post/123', - r: 'https://google.com', - nv: '1', - ns: '1', - }).toString(), + method: "GET", + url: + "https://example.com/user/42?" + + new URLSearchParams({ + sid: "example", + h: "example.com", + p: "/post/123", + r: "https://google.com", + nv: "1", + ns: "1", + }).toString(), headers: { get: (_header: string) => { return headers[_header]; - } + }, }, // Cloudflare-specific request properties cf: { - country: 'US' - } + country: "US", + }, }; } @@ -34,7 +37,7 @@ describe("collectRequestHandler", () => { test("invokes writeDataPoint with transformed params", () => { const env = { WEB_COUNTER_AE: { - writeDataPoint: vi.fn() + writeDataPoint: vi.fn(), } as CFAnalyticsEngine, } as Environment; @@ -58,21 +61,20 @@ describe("collectRequestHandler", () => { "", "example", // site id ], - "doubles": [ + doubles: [ 1, // new visitor 1, // new session ], - "indexes": [ + indexes: [ "example", // site id is index ], - }); }); test("if-modified-since is absent", () => { const env = { WEB_COUNTER_AE: { - writeDataPoint: vi.fn() + writeDataPoint: vi.fn(), } as CFAnalyticsEngine, } as Environment; @@ -82,78 +84,96 @@ describe("collectRequestHandler", () => { collectRequestHandler(request, env); const writeDataPoint = env.WEB_COUNTER_AE.writeDataPoint; - expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty("doubles", [ - 1, // new visitor - 1, // new session - ]); + expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty( + "doubles", + [ + 1, // new visitor + 1, // new session + ], + ); }); test("if-modified-since is within 30 minutes", () => { const env = { WEB_COUNTER_AE: { - writeDataPoint: vi.fn() + writeDataPoint: vi.fn(), } as CFAnalyticsEngine, } as Environment; // @ts-expect-error - we're mocking the request object - const request = httpMocks.createRequest(generateRequestParams( - { - 'if-modified-since': new Date(Date.now() - 5 * 60 * 1000).toUTCString() - } - )); + const request = httpMocks.createRequest( + generateRequestParams({ + "if-modified-since": new Date( + Date.now() - 5 * 60 * 1000, + ).toUTCString(), + }), + ); collectRequestHandler(request, env); const writeDataPoint = env.WEB_COUNTER_AE.writeDataPoint; - expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty("doubles", [ - 0, // new visitor - 0, // new session - ]); + expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty( + "doubles", + [ + 0, // new visitor + 0, // new session + ], + ); }); test("if-modified-since is over 30 ago", () => { const env = { WEB_COUNTER_AE: { - writeDataPoint: vi.fn() + writeDataPoint: vi.fn(), } as CFAnalyticsEngine, } as Environment; // @ts-expect-error - we're mocking the request object - const request = httpMocks.createRequest(generateRequestParams( - { - 'if-modified-since': new Date(Date.now() - 31 * 60 * 1000).toUTCString() - } - )); + const request = httpMocks.createRequest( + generateRequestParams({ + "if-modified-since": new Date( + Date.now() - 31 * 60 * 1000, + ).toUTCString(), + }), + ); collectRequestHandler(request, env); const writeDataPoint = env.WEB_COUNTER_AE.writeDataPoint; - expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty("doubles", [ - 0, // new visitor - 1, // new session - ]); + expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty( + "doubles", + [ + 0, // new visitor + 1, // new session + ], + ); }); test("if-modified-since was yesterday", () => { const env = { WEB_COUNTER_AE: { - writeDataPoint: vi.fn() + writeDataPoint: vi.fn(), } as CFAnalyticsEngine, } as Environment; // @ts-expect-error - we're mocking the request object - const request = httpMocks.createRequest(generateRequestParams( - { - 'if-modified-since': new Date(Date.now() - 60 * 24 * 60 * 1000).toUTCString() - } - )); + const request = httpMocks.createRequest( + generateRequestParams({ + "if-modified-since": new Date( + Date.now() - 60 * 24 * 60 * 1000, + ).toUTCString(), + }), + ); collectRequestHandler(request, env); const writeDataPoint = env.WEB_COUNTER_AE.writeDataPoint; - expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty("doubles", [ - 1, // new visitor - 1, // new session - ]); + expect((writeDataPoint as any).mock.calls[0][0]).toHaveProperty( + "doubles", + [ + 1, // new visitor + 1, // new session + ], + ); }); -}); \ No newline at end of file +}); diff --git a/app/analytics/collect.ts b/app/analytics/collect.ts index b49cc37..a140e4d 100644 --- a/app/analytics/collect.ts +++ b/app/analytics/collect.ts @@ -1,10 +1,10 @@ -import { UAParser } from 'ua-parser-js'; +import { UAParser } from "ua-parser-js"; import type { RequestInit } from "@cloudflare/workers-types"; function checkVisitorSession(ifModifiedSince: string | null): { - newVisitor: boolean, - newSession: boolean + newVisitor: boolean; + newSession: boolean; } { let newVisitor = true; let newSession = true; @@ -24,7 +24,8 @@ function checkVisitorSession(ifModifiedSince: string | null): { } // check ifModifiedSince is less than 30 mins ago - if (Date.now() - new Date(ifModifiedSince).getTime() < + if ( + Date.now() - new Date(ifModifiedSince).getTime() < minutesUntilSessionResets * 60 * 1000 ) { // this is a continuation of the same session @@ -35,15 +36,17 @@ function checkVisitorSession(ifModifiedSince: string | null): { return { newVisitor, newSession }; } -function extractParamsFromQueryString(requestUrl: string): { [key: string]: string } { +function extractParamsFromQueryString(requestUrl: string): { + [key: string]: string; +} { const url = new URL(requestUrl); - const queryString = url.search.slice(1).split('&') + const queryString = url.search.slice(1).split("&"); const params: { [key: string]: string } = {}; - queryString.forEach(item => { - const kv = item.split('=') - if (kv[0]) params[kv[0]] = decodeURIComponent(kv[1]) + queryString.forEach((item) => { + const kv = item.split("="); + if (kv[0]) params[kv[0]] = decodeURIComponent(kv[1]); }); return params; } @@ -51,12 +54,14 @@ function extractParamsFromQueryString(requestUrl: string): { [key: string]: stri export function collectRequestHandler(request: Request, env: Environment) { const params = extractParamsFromQueryString(request.url); - const userAgent = request.headers.get('user-agent') || undefined; + const userAgent = request.headers.get("user-agent") || undefined; const parsedUserAgent = new UAParser(userAgent); parsedUserAgent.getBrowser().name; - const { newVisitor, newSession } = checkVisitorSession(request.headers.get('if-modified-since')); + const { newVisitor, newSession } = checkVisitorSession( + request.headers.get("if-modified-since"), + ); const data: DataPoint = { siteId: params.sid, @@ -68,13 +73,13 @@ export function collectRequestHandler(request: Request, env: Environment) { // user agent stuff userAgent: userAgent, browserName: parsedUserAgent.getBrowser().name, - deviceModel: parsedUserAgent.getDevice().model - } + deviceModel: parsedUserAgent.getDevice().model, + }; // NOTE: location is derived from Cloudflare-specific request properties // see: https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties const country = (request as RequestInit).cf?.country; - if (typeof country === 'string') { + if (typeof country === "string") { data.country = country; } @@ -90,43 +95,44 @@ export function collectRequestHandler(request: Request, env: Environment) { uintArray[i] = gifData.charCodeAt(i); } - return new Response(arrayBuffer, { headers: { "Content-Type": "image/gif", - "Expires": "Mon, 01 Jan 1990 00:00:00 GMT", + Expires: "Mon, 01 Jan 1990 00:00:00 GMT", "Cache-Control": "no-cache", - "Pragma": "no-cache", + Pragma: "no-cache", "Last-Modified": new Date().toUTCString(), - "Tk": "N", // not tracking + Tk: "N", // not tracking }, - status: 200 + status: 200, }); } - interface DataPoint { // index - siteId?: string, + siteId?: string; // blobs - host?: string | undefined, - userAgent?: string, - path?: string, - country?: string, - referrer?: string, - browserName?: string, - deviceModel?: string, + host?: string | undefined; + userAgent?: string; + path?: string; + country?: string; + referrer?: string; + browserName?: string; + deviceModel?: string; // doubles - newVisitor: number, - newSession: number, + newVisitor: number; + newSession: number; } // NOTE: Cloudflare Analytics Engine has limits on total number of bytes, number of fields, etc. // More here: https://developers.cloudflare.com/analytics/analytics-engine/get-started/#limits -export function writeDataPoint(analyticsEngine: CFAnalyticsEngine, data: DataPoint) { +export function writeDataPoint( + analyticsEngine: CFAnalyticsEngine, + data: DataPoint, +) { const datapoint = { indexes: [data.siteId || ""], // Supply one index blobs: [ @@ -139,11 +145,8 @@ export function writeDataPoint(analyticsEngine: CFAnalyticsEngine, data: DataPoi data.deviceModel || "", // blob7 data.siteId || "", // blob8 ], - doubles: [ - data.newVisitor || 0, - data.newSession || 0, - ], - } + doubles: [data.newVisitor || 0, data.newSession || 0], + }; if (!analyticsEngine) { // no-op @@ -152,4 +155,4 @@ export function writeDataPoint(analyticsEngine: CFAnalyticsEngine, data: DataPoi } analyticsEngine.writeDataPoint(datapoint); -} \ No newline at end of file +} diff --git a/app/analytics/query.test.ts b/app/analytics/query.test.ts index fd88236..d5c9481 100644 --- a/app/analytics/query.test.ts +++ b/app/analytics/query.test.ts @@ -1,21 +1,24 @@ -import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; import { AnalyticsEngineAPI } from "./query"; function createFetchResponse(data: any) { return { ok: true, - json: () => new Promise((resolve) => resolve(data)) - } + json: () => new Promise((resolve) => resolve(data)), + }; } describe("AnalyticsEngineAPI", () => { - const api = new AnalyticsEngineAPI("test_account_id_abc123", "test_api_token_def456"); + const api = new AnalyticsEngineAPI( + "test_account_id_abc123", + "test_api_token_def456", + ); let fetch: any; // todo: figure out how to type this mocked fetch beforeEach(() => { fetch = global.fetch = vi.fn(); - vi.useFakeTimers() + vi.useFakeTimers(); }); afterEach(() => { @@ -25,22 +28,26 @@ describe("AnalyticsEngineAPI", () => { describe("query", () => { test("forms a valid HTTP request query for CF analytics engine", () => { - fetch.mockResolvedValue(new Promise(resolve => { - resolve(createFetchResponse({})) - })); + fetch.mockResolvedValue( + new Promise((resolve) => { + resolve(createFetchResponse({})); + }), + ); api.query("SELECT * FROM web_counter"); - expect(fetch).toHaveBeenCalledWith("https://api.cloudflare.com/client/v4/accounts/test_account_id_abc123/analytics_engine/sql", + expect(fetch).toHaveBeenCalledWith( + "https://api.cloudflare.com/client/v4/accounts/test_account_id_abc123/analytics_engine/sql", { - "body": "SELECT * FROM web_counter", - "headers": { - "Authorization": "Bearer test_api_token_def456", + body: "SELECT * FROM web_counter", + headers: { + Authorization: "Bearer test_api_token_def456", "X-Source": "Cloudflare-Workers", "content-type": "application/json;charset=UTF-8", }, - "method": "POST", - }); + method: "POST", + }, + ); }); }); @@ -48,29 +55,38 @@ describe("AnalyticsEngineAPI", () => { test("should return an array of [timestamp, count] tuples grouped by day", async () => { expect(process.env.TZ).toBe("EST"); - fetch.mockResolvedValue(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { - count: 3, - // note: intentionally sparse data (data for some timestamps missing) - bucket: "2024-01-13 05:00:00", - }, - { - count: 2, - bucket: "2024-01-16 05:00:00" - }, - { - count: 1, - bucket: "2024-01-17 05:00:00" - } - ] - })) - })); + fetch.mockResolvedValue( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [ + { + count: 3, + // note: intentionally sparse data (data for some timestamps missing) + bucket: "2024-01-13 05:00:00", + }, + { + count: 2, + bucket: "2024-01-16 05:00:00", + }, + { + count: 1, + bucket: "2024-01-17 05:00:00", + }, + ], + }), + ); + }), + ); vi.setSystemTime(new Date("2024-01-18T09:33:02").getTime()); - const result1 = await api.getViewsGroupedByInterval("example.com", "DAY", 7, 'America/New_York'); + const result1 = await api.getViewsGroupedByInterval( + "example.com", + "DAY", + 7, + "America/New_York", + ); // results should all be at 05:00:00 because local timezone is UTC-5 -- // this set of results represents "start of day" in local tz, which is 5 AM UTC @@ -85,7 +101,12 @@ describe("AnalyticsEngineAPI", () => { ["2024-01-18 05:00:00", 0], ]); - const result2 = await api.getViewsGroupedByInterval("example.com", "DAY", 5, 'America/New_York'); + const result2 = await api.getViewsGroupedByInterval( + "example.com", + "DAY", + 5, + "America/New_York", + ); expect(result2).toEqual([ ["2024-01-13 05:00:00", 3], ["2024-01-14 05:00:00", 0], @@ -100,85 +121,97 @@ describe("AnalyticsEngineAPI", () => { test("should return an array of [timestamp, count] tuples grouped by hour", async () => { expect(process.env.TZ).toBe("EST"); - fetch.mockResolvedValue(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { - count: 3, - // note: intentionally sparse data (data for some timestamps missing) - bucket: "2024-01-17 11:00:00", - }, - { - count: 2, - bucket: "2024-01-17 14:00:00" - }, - { - count: 1, - bucket: "2024-01-17 16:00:00" - } - ] - })) - })); + fetch.mockResolvedValue( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [ + { + count: 3, + // note: intentionally sparse data (data for some timestamps missing) + bucket: "2024-01-17 11:00:00", + }, + { + count: 2, + bucket: "2024-01-17 14:00:00", + }, + { + count: 1, + bucket: "2024-01-17 16:00:00", + }, + ], + }), + ); + }), + ); vi.setSystemTime(new Date("2024-01-18T05:33:02").getTime()); - const result1 = await api.getViewsGroupedByInterval("example.com", "HOUR", 1); + const result1 = await api.getViewsGroupedByInterval( + "example.com", + "HOUR", + 1, + ); // reminder results are expressed as UTC // so if we want the last 24 hours from 05:00:00 in local time (EST), the actual // time range in UTC starts and ends at 10:00:00 (+5 hours) expect(result1).toEqual([ - ['2024-01-17 10:00:00', 0], - ['2024-01-17 11:00:00', 3], - ['2024-01-17 12:00:00', 0], - ['2024-01-17 13:00:00', 0], - ['2024-01-17 14:00:00', 2], - ['2024-01-17 15:00:00', 0], - ['2024-01-17 16:00:00', 1], - ['2024-01-17 17:00:00', 0], - ['2024-01-17 18:00:00', 0], - ['2024-01-17 19:00:00', 0], - ['2024-01-17 20:00:00', 0], - ['2024-01-17 21:00:00', 0], - ['2024-01-17 22:00:00', 0], - ['2024-01-17 23:00:00', 0], - ['2024-01-18 00:00:00', 0], - ['2024-01-18 01:00:00', 0], - ['2024-01-18 02:00:00', 0], - ['2024-01-18 03:00:00', 0], - ['2024-01-18 04:00:00', 0], - ['2024-01-18 05:00:00', 0], - ['2024-01-18 06:00:00', 0], - ['2024-01-18 07:00:00', 0], - ['2024-01-18 08:00:00', 0], - ['2024-01-18 09:00:00', 0], - ['2024-01-18 10:00:00', 0] + ["2024-01-17 10:00:00", 0], + ["2024-01-17 11:00:00", 3], + ["2024-01-17 12:00:00", 0], + ["2024-01-17 13:00:00", 0], + ["2024-01-17 14:00:00", 2], + ["2024-01-17 15:00:00", 0], + ["2024-01-17 16:00:00", 1], + ["2024-01-17 17:00:00", 0], + ["2024-01-17 18:00:00", 0], + ["2024-01-17 19:00:00", 0], + ["2024-01-17 20:00:00", 0], + ["2024-01-17 21:00:00", 0], + ["2024-01-17 22:00:00", 0], + ["2024-01-17 23:00:00", 0], + ["2024-01-18 00:00:00", 0], + ["2024-01-18 01:00:00", 0], + ["2024-01-18 02:00:00", 0], + ["2024-01-18 03:00:00", 0], + ["2024-01-18 04:00:00", 0], + ["2024-01-18 05:00:00", 0], + ["2024-01-18 06:00:00", 0], + ["2024-01-18 07:00:00", 0], + ["2024-01-18 08:00:00", 0], + ["2024-01-18 09:00:00", 0], + ["2024-01-18 10:00:00", 0], ]); }); describe("getCounts", () => { test("should return an object with view, visit, and visitor counts", async () => { - fetch.mockResolvedValue(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { - count: 3, - isVisit: 1, - isVisitor: 0 - }, - { - count: 2, - isVisit: 0, - isVisitor: 0 - }, - { - count: 1, - isVisit: 0, - isVisitor: 1 - } - ] - })) - })); + fetch.mockResolvedValue( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [ + { + count: 3, + isVisit: 1, + isVisitor: 0, + }, + { + count: 2, + isVisit: 0, + isVisitor: 0, + }, + { + count: 1, + isVisit: 0, + isVisitor: 1, + }, + ], + }), + ); + }), + ); const result = api.getCounts("example.com", 7); @@ -194,26 +227,34 @@ describe("AnalyticsEngineAPI", () => { describe("getVisitorCountByColumn", () => { test("it should map logical columns to schema columns and return an array of [column, count] tuples", async () => { - fetch.mockResolvedValue(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { - blob4: "CA", - count: 3, - }, - { - blob4: "US", - count: 2, - }, - { - blob4: "GB", - count: 1, - } - ] - })) - })); + fetch.mockResolvedValue( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [ + { + blob4: "CA", + count: 3, + }, + { + blob4: "US", + count: 2, + }, + { + blob4: "GB", + count: 1, + }, + ], + }), + ); + }), + ); - const result = api.getVisitorCountByColumn("example.com", "country", 7); + const result = api.getVisitorCountByColumn( + "example.com", + "country", + 7, + ); // verify fetch was invoked before awaiting result expect(fetch).toHaveBeenCalled(); @@ -227,28 +268,30 @@ describe("AnalyticsEngineAPI", () => { describe("getSitesOrderedByHits", () => { test("it should return an array of [siteId, count] tuples", async () => { - // note: getSitesByHits orders by count descending in SQL; since we're mocking // the HTTP/SQL response, the mocked results are pre-sorted - fetch.mockResolvedValue(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { - siteId: "example.com", - count: 130, - }, - { - siteId: "foo.com", - count: 100, - }, - { - siteId: "test.dev", - count: 90, - } - ] - })) - })); - + fetch.mockResolvedValue( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [ + { + siteId: "example.com", + count: 130, + }, + { + siteId: "foo.com", + count: 100, + }, + { + siteId: "test.dev", + count: 90, + }, + ], + }), + ); + }), + ); const result = api.getSitesOrderedByHits(7); // verify fetch was invoked before awaiting result @@ -260,4 +303,4 @@ describe("AnalyticsEngineAPI", () => { ]); }); }); -}); \ No newline at end of file +}); diff --git a/app/analytics/query.ts b/app/analytics/query.ts index 5eaa36a..a81544c 100644 --- a/app/analytics/query.ts +++ b/app/analytics/query.ts @@ -1,41 +1,50 @@ -import { ColumnMappings } from './schema'; +import { ColumnMappings } from "./schema"; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; -import timezone from 'dayjs/plugin/timezone'; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; -dayjs.extend(utc) -dayjs.extend(timezone) +dayjs.extend(utc); +dayjs.extend(timezone); export interface AnalyticsQueryResultRow { - [key: string]: any + [key: string]: any; } interface AnalyticsQueryResult { - meta: string, - data: AnalyticsQueryResultRow[], - rows: number, - rows_before_limit_at_least: number + meta: string; + data: AnalyticsQueryResultRow[]; + rows: number; + rows_before_limit_at_least: number; } interface AnalyticsCountResult { - views: number, - visits: number, - visitors: number + views: number; + visits: number; + visitors: number; } /** * Convert a Date object to YY-MM-DD HH:MM:SS */ function formatDateString(d: Date) { - function pad(n: number) { return n < 10 ? "0" + n : n } + function pad(n: number) { + return n < 10 ? "0" + n : n; + } const dash = "-"; const colon = ":"; - return d.getFullYear() + dash + - pad(d.getMonth() + 1) + dash + - pad(d.getDate()) + " " + - pad(d.getHours()) + colon + - pad(d.getMinutes()) + colon + + return ( + d.getFullYear() + + dash + + pad(d.getMonth() + 1) + + dash + + pad(d.getDate()) + + " " + + pad(d.getHours()) + + colon + + pad(d.getMinutes()) + + colon + pad(d.getSeconds()) + ); } /** @@ -47,39 +56,37 @@ function formatDateString(d: Date) { * "2021-01-01 04:00:00": 0, * ... * } - * + * * */ -function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number, tz?: string): [Date, any] { - +function generateEmptyRowsOverInterval( + intervalType: string, + daysAgo: number, + tz?: string, +): [Date, any] { if (!tz) { - tz = 'Etc/UTC'; + tz = "Etc/UTC"; } let localDateTime = dayjs(); let intervalMs = 0; // get start date in the past by subtracting interval * type - if (intervalType === 'DAY') { + if (intervalType === "DAY") { localDateTime = dayjs() .utc() - .subtract(daysAgo, 'day') + .subtract(daysAgo, "day") .tz(tz) - .startOf('day'); + .startOf("day"); // assumes interval is 24 hours intervalMs = 24 * 60 * 60 * 1000; - - } else if (intervalType === 'HOUR') { - localDateTime = dayjs() - .utc() - .subtract(daysAgo, 'day') - .startOf('hour'); + } else if (intervalType === "HOUR") { + localDateTime = dayjs().utc().subtract(daysAgo, "day").startOf("hour"); // assumes interval is hourly intervalMs = 60 * 60 * 1000; } - const startDateTime = localDateTime.toDate(); const initialRows: any = {}; @@ -88,7 +95,9 @@ function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number, tz // get date as utc const rowDate = new Date(i); // convert to UTC - const utcDateTime = new Date(rowDate.getTime() + rowDate.getTimezoneOffset() * 60_000); + const utcDateTime = new Date( + rowDate.getTime() + rowDate.getTimezoneOffset() * 60_000, + ); const key = formatDateString(utcDateTime); initialRows[key] = 0; @@ -114,7 +123,7 @@ export class AnalyticsEngineAPI { defaultHeaders: { "content-type": string; "X-Source": string; - "Authorization": string; + Authorization: string; }; defaultUrl: string; @@ -126,31 +135,40 @@ export class AnalyticsEngineAPI { this.defaultHeaders = { "content-type": "application/json;charset=UTF-8", "X-Source": "Cloudflare-Workers", - "Authorization": `Bearer ${this.cfApiToken}` - } + Authorization: `Bearer ${this.cfApiToken}`, + }; } async query(query: string): Promise { return fetch(this.defaultUrl, { - method: 'POST', + method: "POST", body: query, headers: this.defaultHeaders, }); } - async getViewsGroupedByInterval(siteId: string, intervalType: string, sinceDays: number, tz: string): Promise { + async getViewsGroupedByInterval( + siteId: string, + intervalType: string, + sinceDays: number, + tz: string, + ): Promise { let intervalCount = 1; // keeping this code here once we start allowing bigger intervals (e.g. intervals of 2 hours) switch (intervalType) { - case 'DAY': - case 'HOUR': + case "DAY": + case "HOUR": intervalCount = 1; break; } // note interval count hard-coded to hours at the moment - const [startDateTime, initialRows] = generateEmptyRowsOverInterval(intervalType, sinceDays, tz); + const [startDateTime, initialRows] = generateEmptyRowsOverInterval( + intervalType, + sinceDays, + tz, + ); // NOTE: when using toStartOfInterval, cannot group by other columns // like double1 (isVisitor) or double2 (isSession/isVisit). This @@ -172,42 +190,51 @@ export class AnalyticsEngineAPI { ORDER BY _bucket ASC`; const queryResult = this.query(query); - const returnPromise = new Promise((resolve, reject) => (async () => { - const response = await queryResult; - - if (!response.ok) { - reject(response.statusText); - } - - const responseData = await response.json() as AnalyticsQueryResult; - - // note this query will return sparse data (i.e. only rows where count > 0) - // merge returnedRows with initial rows to fill in any gaps - const rowsByDateTime = responseData.data.reduce((accum, row) => { - - const utcDateTime = new Date(row['bucket']); - const key = formatDateString(utcDateTime); - accum[key] = row['count']; - return accum; - }, initialRows); - - // return as sorted array of tuples (i.e. [datetime, count]) - const sortedRows = Object.entries(rowsByDateTime).sort((a: any, b: any) => { - if (a[0] < b[0]) return -1; - else if (a[0] > b[0]) return 1; - else return 0; - }); - - resolve(sortedRows); - })()); + const returnPromise = new Promise((resolve, reject) => + (async () => { + const response = await queryResult; + + if (!response.ok) { + reject(response.statusText); + } + + const responseData = + (await response.json()) as AnalyticsQueryResult; + + // note this query will return sparse data (i.e. only rows where count > 0) + // merge returnedRows with initial rows to fill in any gaps + const rowsByDateTime = responseData.data.reduce( + (accum, row) => { + const utcDateTime = new Date(row["bucket"]); + const key = formatDateString(utcDateTime); + accum[key] = row["count"]; + return accum; + }, + initialRows, + ); + + // return as sorted array of tuples (i.e. [datetime, count]) + const sortedRows = Object.entries(rowsByDateTime).sort( + (a: any, b: any) => { + if (a[0] < b[0]) return -1; + else if (a[0] > b[0]) return 1; + else return 0; + }, + ); + + resolve(sortedRows); + })(), + ); return returnPromise; } - async getCounts(siteId: string, sinceDays: number): Promise { - + async getCounts( + siteId: string, + sinceDays: number, + ): Promise { // defaults to 1 day if not specified const interval = sinceDays || 1; - const siteIdColumn = ColumnMappings['siteId']; + const siteIdColumn = ColumnMappings["siteId"]; const query = ` SELECT SUM(_sample_interval) as count, @@ -220,39 +247,48 @@ export class AnalyticsEngineAPI { ORDER BY isVisitor, isVisit ASC`; const queryResult = this.query(query); - const returnPromise = new Promise((resolve, reject) => (async () => { - const response = await queryResult; - - if (!response.ok) { - reject(response.statusText); - } - - const responseData = await response.json() as AnalyticsQueryResult; - - const counts: AnalyticsCountResult = { - views: 0, - visitors: 0, - visits: 0 - } - - // NOTE: note it's possible to get no results, or half results (i.e. a row where isVisit=1 but - // no row where isVisit=0), so this code makes no assumption on number of results - responseData.data.forEach((row) => { - if (row.isVisit == 1) { - counts.visits += Number(row.count); - } - if (row.isVisitor == 1) { - counts.visitors += Number(row.count); - } - counts.views += Number(row.count); - }); - resolve(counts); - })()); + const returnPromise = new Promise( + (resolve, reject) => + (async () => { + const response = await queryResult; + + if (!response.ok) { + reject(response.statusText); + } + + const responseData = + (await response.json()) as AnalyticsQueryResult; + + const counts: AnalyticsCountResult = { + views: 0, + visitors: 0, + visits: 0, + }; + + // NOTE: note it's possible to get no results, or half results (i.e. a row where isVisit=1 but + // no row where isVisit=0), so this code makes no assumption on number of results + responseData.data.forEach((row) => { + if (row.isVisit == 1) { + counts.visits += Number(row.count); + } + if (row.isVisitor == 1) { + counts.visitors += Number(row.count); + } + counts.views += Number(row.count); + }); + resolve(counts); + })(), + ); return returnPromise; } - async getVisitorCountByColumn(siteId: string, column: string, sinceDays: number, limit?: number): Promise { + async getVisitorCountByColumn( + siteId: string, + column: string, + sinceDays: number, + limit?: number, + ): Promise { // defaults to 1 day if not specified const interval = sinceDays || 1; limit = limit || 10; @@ -269,47 +305,56 @@ export class AnalyticsEngineAPI { LIMIT ${limit}`; const queryResult = this.query(query); - const returnPromise = new Promise((resolve, reject) => (async () => { - const response = await queryResult; - - if (!response.ok) { - reject(response.statusText); - } - - const responseData = await response.json() as AnalyticsQueryResult; - resolve(responseData.data.map((row) => { - const key = row[_column] === '' ? '(none)' : row[_column]; - return [key, row['count']]; - })); - })()); + const returnPromise = new Promise((resolve, reject) => + (async () => { + const response = await queryResult; + + if (!response.ok) { + reject(response.statusText); + } + + const responseData = + (await response.json()) as AnalyticsQueryResult; + resolve( + responseData.data.map((row) => { + const key = + row[_column] === "" ? "(none)" : row[_column]; + return [key, row["count"]]; + }), + ); + })(), + ); return returnPromise; } async getCountByUserAgent(siteId: string, sinceDays: number): Promise { - return this.getVisitorCountByColumn(siteId, 'userAgent', sinceDays); + return this.getVisitorCountByColumn(siteId, "userAgent", sinceDays); } async getCountByCountry(siteId: string, sinceDays: number): Promise { - return this.getVisitorCountByColumn(siteId, 'country', sinceDays); + return this.getVisitorCountByColumn(siteId, "country", sinceDays); } async getCountByReferrer(siteId: string, sinceDays: number): Promise { - return this.getVisitorCountByColumn(siteId, 'referrer', sinceDays); + return this.getVisitorCountByColumn(siteId, "referrer", sinceDays); } async getCountByPath(siteId: string, sinceDays: number): Promise { - return this.getVisitorCountByColumn(siteId, 'path', sinceDays); + return this.getVisitorCountByColumn(siteId, "path", sinceDays); } async getCountByBrowser(siteId: string, sinceDays: number): Promise { - return this.getVisitorCountByColumn(siteId, 'browserName', sinceDays); + return this.getVisitorCountByColumn(siteId, "browserName", sinceDays); } async getCountByDevice(siteId: string, sinceDays: number): Promise { - return this.getVisitorCountByColumn(siteId, 'deviceModel', sinceDays); + return this.getVisitorCountByColumn(siteId, "deviceModel", sinceDays); } - async getSitesOrderedByHits(sinceDays: number, limit?: number): Promise { + async getSitesOrderedByHits( + sinceDays: number, + limit?: number, + ): Promise { // defaults to 1 day if not specified const interval = sinceDays || 1; limit = limit || 10; @@ -325,22 +370,25 @@ export class AnalyticsEngineAPI { `; const queryResult = this.query(query); - const returnPromise = new Promise((resolve, reject) => (async () => { - const response = await queryResult; - - if (!response.ok) { - reject(response.statusText); - return; - } - - const responseData = await response.json() as AnalyticsQueryResult; - const result = responseData.data.reduce((acc, cur) => { - acc.push([cur['siteId'], cur['count']]); - return acc; - }, []); - - resolve(result); - })()); + const returnPromise = new Promise((resolve, reject) => + (async () => { + const response = await queryResult; + + if (!response.ok) { + reject(response.statusText); + return; + } + + const responseData = + (await response.json()) as AnalyticsQueryResult; + const result = responseData.data.reduce((acc, cur) => { + acc.push([cur["siteId"], cur["count"]]); + return acc; + }, []); + + resolve(result); + })(), + ); return returnPromise; } -} \ No newline at end of file +} diff --git a/app/analytics/schema.ts b/app/analytics/schema.ts index 31f27f0..49b45c9 100644 --- a/app/analytics/schema.ts +++ b/app/analytics/schema.ts @@ -1,5 +1,5 @@ export interface ColumnMappingsType { - [key: string]: string + [key: string]: string; } /** @@ -27,4 +27,4 @@ export const ColumnMappings: ColumnMappingsType = { // this record is a new session (resets after 30m inactivity) newSession: "double2", -}; \ No newline at end of file +}; diff --git a/app/components/TableCard.tsx b/app/components/TableCard.tsx index e18962f..c2191a8 100644 --- a/app/components/TableCard.tsx +++ b/app/components/TableCard.tsx @@ -1,4 +1,4 @@ -import PropTypes, { InferProps } from 'prop-types'; +import PropTypes, { InferProps } from "prop-types"; import { Table, @@ -7,34 +7,50 @@ import { TableHead, TableHeader, TableRow, -} from "~/components/ui/table" +} from "~/components/ui/table"; -import { Card } from "~/components/ui/card" +import { Card } from "~/components/ui/card"; -export default function TableCard({ countByProperty, columnHeaders }: InferProps) { - return ( - - - - {(columnHeaders || []).map((header: string, index) => ( - {header} - ))} - - - - {(countByProperty || []).map((item: any) => ( - - {item[0]} - {item[1]} +export default function TableCard({ + countByProperty, + columnHeaders, +}: InferProps) { + return ( + +
+ + + {(columnHeaders || []).map((header: string, index) => ( + + {header} + + ))} - ))} - -
-
) + + + {(countByProperty || []).map((item: any) => ( + + + {item[0]} + + + {item[1]} + + + ))} + + + + ); } TableCard.propTypes = { propertyName: PropTypes.string, countByProperty: PropTypes.array, - columnHeaders: PropTypes.array -} \ No newline at end of file + columnHeaders: PropTypes.array, +}; diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f4a7782..b6082e7 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -1,8 +1,19 @@ -import PropTypes, { InferProps } from 'prop-types'; +import PropTypes, { InferProps } from "prop-types"; -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; -export default function TimeSeriesChart({ data, intervalType }: InferProps) { +export default function TimeSeriesChart({ + data, + intervalType, +}: InferProps) { // chart doesn't really work no data points, so just bail out if (data.length === 0) { return null; @@ -12,30 +23,41 @@ export default function TimeSeriesChart({ data, intervalType }: InferProps item.views)); function xAxisDateFormatter(date: string): string { - const dateObj = new Date(date); // convert from utc to local time dateObj.setMinutes(dateObj.getMinutes() - dateObj.getTimezoneOffset()); switch (intervalType) { - case 'DAY': - return dateObj.toLocaleDateString('en-us', { weekday: "short", month: "short", day: "numeric" }); - case 'HOUR': - return dateObj.toLocaleTimeString('en-us', { hour: "numeric", minute: "numeric" }); + case "DAY": + return dateObj.toLocaleDateString("en-us", { + weekday: "short", + month: "short", + day: "numeric", + }); + case "HOUR": + return dateObj.toLocaleTimeString("en-us", { + hour: "numeric", + minute: "numeric", + }); default: - throw new Error('Invalid interval type'); + throw new Error("Invalid interval type"); } } function tooltipDateFormatter(date: string): string { - const dateObj = new Date(date); // convert from utc to local time dateObj.setMinutes(dateObj.getMinutes() - dateObj.getTimezoneOffset()); - return dateObj.toLocaleString('en-us', { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "numeric" }); + return dateObj.toLocaleString("en-us", { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); } return ( @@ -57,14 +79,18 @@ export default function TimeSeriesChart({ data, intervalType }: InferProps - + - + ); - } TimeSeriesChart.propTypes = { data: PropTypes.any, - intervalType: PropTypes.string -} \ No newline at end of file + intervalType: PropTypes.string, +}; diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx index b96f14c..6a4d834 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -1,57 +1,58 @@ /* eslint react/prop-types: 0 */ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "~/lib/utils" +import { cn } from "~/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", - }, + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) +); export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/app/components/ui/card.tsx b/app/components/ui/card.tsx index ee7b0d9..7e4bfd7 100644 --- a/app/components/ui/card.tsx +++ b/app/components/ui/card.tsx @@ -1,81 +1,88 @@ /* eslint react/prop-types: 0 */ -import * as React from "react" +import * as React from "react"; -import { cn } from "~/lib/utils" +import { cn } from "~/lib/utils"; const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -Card.displayName = "Card" +
+)); +Card.displayName = "Card"; const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -CardHeader.displayName = "CardHeader" +
+)); +CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes + HTMLParagraphElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - /* eslint jsx-a11y/heading-has-content: 0 */ -

-)) -CardTitle.displayName = "CardTitle" + /* eslint jsx-a11y/heading-has-content: 0 */ +

+)); +CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes + HTMLParagraphElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

-)) -CardDescription.displayName = "CardDescription" +

+)); +CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

-)) -CardContent.displayName = "CardContent" +
+)); +CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -CardFooter.displayName = "CardFooter" +
+)); +CardFooter.displayName = "CardFooter"; -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/app/components/ui/select.tsx b/app/components/ui/select.tsx index 6c2376b..f5d54cb 100644 --- a/app/components/ui/select.tsx +++ b/app/components/ui/select.tsx @@ -1,159 +1,159 @@ /* eslint react/prop-types: 0 */ -import * as React from "react" -import * as SelectPrimitive from "@radix-ui/react-select" -import { Check, ChevronDown, ChevronUp } from "lucide-react" +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; -import { cn } from "~/lib/utils" +import { cn } from "~/lib/utils"; -const Select = SelectPrimitive.Root +const Select = SelectPrimitive.Root; -const SelectGroup = SelectPrimitive.Group +const SelectGroup = SelectPrimitive.Group; -const SelectValue = SelectPrimitive.Value +const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - span]:line-clamp-1", - className - )} - {...props} - > - {children} - - - - -)) -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; const SelectScrollUpButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - - - -)) -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; const SelectScrollDownButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - - - -)) + + + +)); SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName + SelectPrimitive.ScrollDownButton.displayName; const SelectContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, position = "popper", ...props }, ref) => ( - - - - - {children} - - - - -)) -SelectContent.displayName = SelectPrimitive.Content.displayName + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -SelectLabel.displayName = SelectPrimitive.Label.displayName + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - - - - - - + + + + + + - {children} - -)) -SelectItem.displayName = SelectPrimitive.Item.displayName + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, -} + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/app/components/ui/table.tsx b/app/components/ui/table.tsx index 41f1e69..a2566be 100644 --- a/app/components/ui/table.tsx +++ b/app/components/ui/table.tsx @@ -1,118 +1,121 @@ /* eslint react/prop-types: 0 */ -import * as React from "react" +import * as React from "react"; -import { cn } from "~/lib/utils" +import { cn } from "~/lib/utils"; const Table = React.forwardRef< - HTMLTableElement, - React.HTMLAttributes + HTMLTableElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
- - -)) -Table.displayName = "Table" +
+
+ +)); +Table.displayName = "Table"; const TableHeader = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableHeader.displayName = "TableHeader" + +)); +TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableBody.displayName = "TableBody" + +)); +TableBody.displayName = "TableBody"; const TableFooter = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - tr]:last:border-b-0", - className - )} - {...props} - /> -)) -TableFooter.displayName = "TableFooter" + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; const TableRow = React.forwardRef< - HTMLTableRowElement, - React.HTMLAttributes + HTMLTableRowElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableRow.displayName = "TableRow" + +)); +TableRow.displayName = "TableRow"; const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes + HTMLTableCellElement, + React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -TableHead.displayName = "TableHead" + +)); +TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< - HTMLTableCellElement, - React.TdHTMLAttributes + HTMLTableCellElement, + React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableCell.displayName = "TableCell" + +)); +TableCell.displayName = "TableCell"; const TableCaption = React.forwardRef< - HTMLTableCaptionElement, - React.HTMLAttributes + HTMLTableCaptionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -TableCaption.displayName = "TableCaption" + +)); +TableCaption.displayName = "TableCaption"; export { - Table, - TableHeader, - TableBody, - TableFooter, - TableHead, - TableRow, - TableCell, - TableCaption, -} + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 3311aad..64ea0b3 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -13,6 +13,6 @@ startTransition(() => { document, - + , ); }); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index a17a631..93f2a86 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -19,7 +19,7 @@ export default async function handleRequest( // free to delete this parameter in your app if you're not using it! // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadContext: AppLoadContext + loadContext: AppLoadContext, ) { const body = await renderToReadableStream( , @@ -30,7 +30,7 @@ export default async function handleRequest( console.error(error); responseStatusCode = 500; }, - } + }, ); if (isbot(request.headers.get("user-agent"))) { diff --git a/app/globals.css b/app/globals.css index b3109d3..d5057b2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,75 +3,75 @@ @tailwind utilities; @layer base { - :root { - --background: 42 69% 85%; - --foreground: 164 14% 21%; + :root { + --background: 42 69% 85%; + --foreground: 164 14% 21%; - --card: 42 69% 88%; - --card-foreground: 222.2 84% 4.9%; + --card: 42 69% 88%; + --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; - --primary: 15 94% 61%; - --primary-foreground: 210 40% 98%; + --primary: 15 94% 61%; + --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; - --border: 15 90% 60%; - --input: 15 90% 60%; - --ring: 222.2 84% 4.9%; + --border: 15 90% 60%; + --input: 15 90% 60%; + --ring: 222.2 84% 4.9%; - --radius: 0.5rem; - } + --radius: 0.5rem; + } - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - } + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } } @layer base { - * { - @apply border-border; - } - - body { - @apply bg-background text-foreground; - } -} \ No newline at end of file + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/app/lib/utils.ts b/app/lib/utils.ts index d084cca..e644794 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -1,6 +1,6 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/app/root.tsx b/app/root.tsx index 8dfec2f..5de1972 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,4 @@ -import styles from "./globals.css" +import styles from "./globals.css"; import type { LinksFunction } from "@remix-run/cloudflare"; import { cssBundleHref } from "@remix-run/css-bundle"; import { @@ -16,30 +16,55 @@ export const links: LinksFunction = () => [ ]; export default function App() { - return ( - +
-
@@ -51,7 +76,11 @@ export default function App() { - + diff --git a/app/routes/_index.test.tsx b/app/routes/_index.test.tsx index 2307281..41e32a7 100644 --- a/app/routes/_index.test.tsx +++ b/app/routes/_index.test.tsx @@ -1,12 +1,9 @@ // @vitest-environment jsdom import { test, describe, expect } from "vitest"; -import 'vitest-dom/extend-expect'; +import "vitest-dom/extend-expect"; import { createRemixStub } from "@remix-run/testing"; -import { - render, - screen, -} from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import Index from "./_index"; @@ -22,7 +19,9 @@ describe("Index route", () => { render(); expect( - screen.getByText('Scalable web analytics you run yourself on Cloudflare') + screen.getByText( + "Scalable web analytics you run yourself on Cloudflare", + ), ).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 04aaab7..758b33f 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -9,46 +9,79 @@ export const meta: MetaFunction = () => { }; export default function Index() { - return
- -
-
-

- Scalable web analytics you run yourself on Cloudflare -

- - - or - Browse the demo - -
-
- CounterScale Logo + return ( +
+
+
+

+ Scalable web analytics you run yourself on Cloudflare +

+ + + or + + Browse the demo + + +
+
+ CounterScale Logo +
-
-
-
-

Free and open source

-

Counterscale is MIT licensed. You run it yourself on your own Cloudflare account.

-
+
+
+

Free and open source

+

+ Counterscale is MIT licensed. You run it yourself on + your own Cloudflare account. +

+
-
-

Simple to deploy and maintain

-

Counterscale is deployed as a single Cloudflare Worker, with event data stored using Cloudflare Analytics Engine (beta).

-
+
+

+ Simple to deploy and maintain +

+

+ Counterscale is deployed as a single Cloudflare Worker, + with event data stored using Cloudflare Analytics Engine + (beta). +

+
-
-

Don't break the bank

-

Pay pennies to handle 100ks of requests on Cloudflare's infrastructure.

-
+
+

Don't break the bank

+

+ Pay pennies to handle 100ks of requests on + Cloudflare's infrastructure. +

+
-
-

Privacy focused

-

Doesn't set any cookies, and you control your data end-to-end. Data is retained for only 90 days.

+
+

Privacy focused

+

+ Doesn't set any cookies, and you control your data + end-to-end. Data is retained for only 90 days. +

+
- + ); } diff --git a/app/routes/admin-redirect.tsx b/app/routes/admin-redirect.tsx index 6361234..3f7ef74 100644 --- a/app/routes/admin-redirect.tsx +++ b/app/routes/admin-redirect.tsx @@ -1,5 +1,7 @@ import { LoaderFunctionArgs, redirect } from "@remix-run/cloudflare"; export const loader = async ({ context }: LoaderFunctionArgs) => { - return redirect(`https://dash.cloudflare.com/${context.env.CF_ACCOUNT_ID}/workers/services/view/counterscale/production`); + return redirect( + `https://dash.cloudflare.com/${context.env.CF_ACCOUNT_ID}/workers/services/view/counterscale/production`, + ); }; diff --git a/app/routes/dashboard.test.tsx b/app/routes/dashboard.test.tsx index 2a97e0a..0e05af8 100644 --- a/app/routes/dashboard.test.tsx +++ b/app/routes/dashboard.test.tsx @@ -1,14 +1,10 @@ // @vitest-environment jsdom import { json } from "@remix-run/node"; import { vi, test, describe, beforeAll, expect } from "vitest"; -import 'vitest-dom/extend-expect'; +import "vitest-dom/extend-expect"; import { createRemixStub } from "@remix-run/testing"; -import { - render, - screen, - waitFor -} from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import Dashboard, { loader } from "./dashboard"; @@ -16,8 +12,8 @@ global.fetch = vi.fn(); function createFetchResponse(data: any) { return { ok: true, - json: () => new Promise((resolve) => resolve(data)) - } + json: () => new Promise((resolve) => resolve(data)), + }; } describe("Dashboard route", () => { @@ -25,133 +21,150 @@ describe("Dashboard route", () => { beforeAll(() => { // polyfill needed for recharts (used by TimeSeriesChart) - global.ResizeObserver = require('resize-observer-polyfill') + global.ResizeObserver = require("resize-observer-polyfill"); }); describe("loader", () => { test("assembles data returned from CF API", async () => { // response for getSitesByOrderedHits - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { siteId: 'test-siteid', count: 1 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [{ siteId: "test-siteid", count: 1 }], + }), + ); + }), + ); // response for get counts - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { isVisit: 1, isVisitor: 1, count: 1 }, - { isVisit: 1, isVisitor: 0, count: 2 }, - { isVisit: 0, isVisitor: 0, count: 3 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [ + { isVisit: 1, isVisitor: 1, count: 1 }, + { isVisit: 1, isVisitor: 0, count: 2 }, + { isVisit: 0, isVisitor: 0, count: 3 }, + ], + }), + ); + }), + ); // response for getCountByPath - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { blob3: "/", count: 1 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [{ blob3: "/", count: 1 }], + }), + ); + }), + ); // response for getCountByCountry - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { blob4: "US", count: 1 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [{ blob4: "US", count: 1 }], + }), + ); + }), + ); // response for getCountByReferrer - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { blob5: "google.com", count: 1 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [{ blob5: "google.com", count: 1 }], + }), + ); + }), + ); // response for getCountByBrowser - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { blob6: "Chrome", count: 2 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [{ blob6: "Chrome", count: 2 }], + }), + ); + }), + ); // response for getCountByDevice - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { blob7: "Desktop", count: 3 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [{ blob7: "Desktop", count: 3 }], + }), + ); + }), + ); // response for getViewsGroupedByInterval - fetch.mockResolvedValueOnce(new Promise(resolve => { - resolve(createFetchResponse({ - data: [ - { bucket: "2024-01-11 00:00:00", count: 4 } - ] - })) - })); + fetch.mockResolvedValueOnce( + new Promise((resolve) => { + resolve( + createFetchResponse({ + data: [{ bucket: "2024-01-11 00:00:00", count: 4 }], + }), + ); + }), + ); vi.setSystemTime(new Date("2024-01-18T09:33:02").getTime()); const response = await loader({ context: { env: { - CF_BEARER_TOKEN: 'fake', - CF_ACCOUNT_ID: 'fake', - } + CF_BEARER_TOKEN: "fake", + CF_ACCOUNT_ID: "fake", + }, }, // @ts-expect-error we don't need to provide all the properties of the request object request: { - url: 'http://localhost:3000/dashboard' - } + url: "http://localhost:3000/dashboard", + }, }); const json = await response.json(); expect(json).toEqual({ - siteId: 'test-siteid', - sites: ['test-siteid'], + siteId: "test-siteid", + sites: ["test-siteid"], views: 6, visits: 3, visitors: 1, - countByPath: [['/', 1]], - countByCountry: [['US', 1]], - countByReferrer: [['google.com', 1]], - countByBrowser: [['Chrome', 2]], - countByDevice: [['Desktop', 3]], + countByPath: [["/", 1]], + countByCountry: [["US", 1]], + countByReferrer: [["google.com", 1]], + countByBrowser: [["Chrome", 2]], + countByDevice: [["Desktop", 3]], viewsGroupedByInterval: [ - ['2024-01-11 00:00:00', 4], - ['2024-01-12 00:00:00', 0], - ['2024-01-13 00:00:00', 0], - ['2024-01-14 00:00:00', 0], - ['2024-01-15 00:00:00', 0], - ['2024-01-16 00:00:00', 0], - ['2024-01-17 00:00:00', 0], - ['2024-01-18 00:00:00', 0], + ["2024-01-11 00:00:00", 4], + ["2024-01-12 00:00:00", 0], + ["2024-01-13 00:00:00", 0], + ["2024-01-14 00:00:00", 0], + ["2024-01-15 00:00:00", 0], + ["2024-01-16 00:00:00", 0], + ["2024-01-17 00:00:00", 0], + ["2024-01-18 00:00:00", 0], ], - intervalType: 'DAY' + intervalType: "DAY", }); }); }); test("renders when no data", async () => { - function loader() { return json({ - siteId: '@unknown', + siteId: "@unknown", sites: [], views: [], visits: [], @@ -162,7 +175,7 @@ describe("Dashboard route", () => { countByReferrer: [], countByDevice: [], viewsGroupedByInterval: [], - intervalType: 'day' + intervalType: "day", }); } @@ -170,7 +183,7 @@ describe("Dashboard route", () => { { path: "/", Component: Dashboard, - loader + loader, }, ]); @@ -178,39 +191,39 @@ describe("Dashboard route", () => { // wait until the rows render in the document await waitFor(() => screen.findByText("Country")); - expect(screen.getByText('Country')).toBeInTheDocument(); + expect(screen.getByText("Country")).toBeInTheDocument(); }); const defaultMockedLoaderJson = { - siteId: 'example', - sites: ['example'], + siteId: "example", + sites: ["example"], views: 100, visits: 80, visitors: 33, countByPath: [ - ['/', 100], - ['/about', 80], - ['/contact', 60], + ["/", 100], + ["/about", 80], + ["/contact", 60], ], countByBrowser: [ - ['Chrome', 100], - ['Safari', 80], - ['Firefox', 60], + ["Chrome", 100], + ["Safari", 80], + ["Firefox", 60], ], countByCountry: [ - ['US', 100], - ['CA', 80], - ['UK', 60], + ["US", 100], + ["CA", 80], + ["UK", 60], ], countByReferrer: [ - ['google.com', 100], - ['facebook.com', 80], - ['twitter.com', 60], + ["google.com", 100], + ["facebook.com", 80], + ["twitter.com", 60], ], countByDevice: [ - ['Desktop', 100], - ['Mobile', 80], - ['Tablet', 60], + ["Desktop", 100], + ["Mobile", 80], + ["Tablet", 60], ], viewsGroupedByInterval: [ ["2024-01-11 00:00:00", 0], @@ -222,11 +235,10 @@ describe("Dashboard route", () => { ["2024-01-17 00:00:00", 1], ["2024-01-18 00:00:00", 0], ], - intervalType: 'day' + intervalType: "day", }; test("renders with valid data", async () => { - function loader() { return json({ ...defaultMockedLoaderJson }); } @@ -235,7 +247,7 @@ describe("Dashboard route", () => { { path: "/", Component: Dashboard, - loader + loader, }, ]); @@ -245,12 +257,12 @@ describe("Dashboard route", () => { await waitFor(() => screen.findByText("Chrome")); // assert some of the data we mocked actually rendered into the document - expect(screen.getByText('33')).toBeInTheDocument(); - expect(screen.getByText('/about')).toBeInTheDocument(); - expect(screen.getByText('Chrome')).toBeInTheDocument(); - expect(screen.getByText('google.com')).toBeInTheDocument(); - expect(screen.getByText('Canada')).toBeInTheDocument(); // assert converted CA -> Canada - expect(screen.getByText('Mobile')).toBeInTheDocument(); + expect(screen.getByText("33")).toBeInTheDocument(); + expect(screen.getByText("/about")).toBeInTheDocument(); + expect(screen.getByText("Chrome")).toBeInTheDocument(); + expect(screen.getByText("google.com")).toBeInTheDocument(); + expect(screen.getByText("Canada")).toBeInTheDocument(); // assert converted CA -> Canada + expect(screen.getByText("Mobile")).toBeInTheDocument(); }); test("renders with invalid country code", async () => { @@ -258,10 +270,10 @@ describe("Dashboard route", () => { return json({ ...defaultMockedLoaderJson, countByCountry: [ - ['US', 100], - ['CA', 80], - ['not_a_valid_country_code', 60], - ] + ["US", 100], + ["CA", 80], + ["not_a_valid_country_code", 60], + ], }); } @@ -269,7 +281,7 @@ describe("Dashboard route", () => { { path: "/", Component: Dashboard, - loader + loader, }, ]); @@ -279,6 +291,6 @@ describe("Dashboard route", () => { await waitFor(() => screen.findByText("Chrome")); // assert the invalid country code was converted to "(unknown)" - expect(screen.getByText('(unknown)')).toBeInTheDocument(); + expect(screen.getByText("(unknown)")).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index 8439aac..8dec444 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -1,17 +1,20 @@ -import { Card, CardContent } from "~/components/ui/card" +import { Card, CardContent } from "~/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "~/components/ui/select" +} from "~/components/ui/select"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/cloudflare"; import { json } from "@remix-run/cloudflare"; import { useLoaderData, useSearchParams } from "@remix-run/react"; -import { AnalyticsEngineAPI, AnalyticsQueryResultRow } from "../analytics/query"; +import { + AnalyticsEngineAPI, + AnalyticsQueryResultRow, +} from "../analytics/query"; import TableCard from "~/components/TableCard"; import TimeSeriesChart from "~/components/TimeSeriesChart"; @@ -26,65 +29,84 @@ export const meta: MetaFunction = () => { declare module "@remix-run/server-runtime" { export interface AppLoadContext { env: { - CF_BEARER_TOKEN: string, - CF_ACCOUNT_ID: string + CF_BEARER_TOKEN: string; + CF_ACCOUNT_ID: string; }; } } export const loader = async ({ context, request }: LoaderFunctionArgs) => { - const analyticsEngine = new AnalyticsEngineAPI(context.env.CF_ACCOUNT_ID, context.env.CF_BEARER_TOKEN); - + const analyticsEngine = new AnalyticsEngineAPI( + context.env.CF_ACCOUNT_ID, + context.env.CF_BEARER_TOKEN, + ); const url = new URL(request.url); - let siteId = url.searchParams.get("site") || ''; + let siteId = url.searchParams.get("site") || ""; let interval; try { - interval = url.searchParams.get("interval") || '7'; + interval = url.searchParams.get("interval") || "7"; interval = Number(interval); } catch (err) { interval = 7; } - const sitesByHits = (await analyticsEngine.getSitesOrderedByHits(interval)); + const sitesByHits = await analyticsEngine.getSitesOrderedByHits(interval); if (!siteId) { // pick first non-empty site siteId = sitesByHits[0][0]; } - const actualSiteId = siteId == '@unknown' ? '' : siteId; + const actualSiteId = siteId == "@unknown" ? "" : siteId; const counts = analyticsEngine.getCounts(actualSiteId, interval); const countByPath = analyticsEngine.getCountByPath(actualSiteId, interval); - const countByCountry = analyticsEngine.getCountByCountry(actualSiteId, interval); - const countByReferrer = analyticsEngine.getCountByReferrer(actualSiteId, interval); - const countByBrowser = analyticsEngine.getCountByBrowser(actualSiteId, interval); - const countByDevice = analyticsEngine.getCountByDevice(actualSiteId, interval); + const countByCountry = analyticsEngine.getCountByCountry( + actualSiteId, + interval, + ); + const countByReferrer = analyticsEngine.getCountByReferrer( + actualSiteId, + interval, + ); + const countByBrowser = analyticsEngine.getCountByBrowser( + actualSiteId, + interval, + ); + const countByDevice = analyticsEngine.getCountByDevice( + actualSiteId, + interval, + ); - let intervalType = 'DAY'; + let intervalType = "DAY"; switch (interval) { case 1: - intervalType = 'HOUR'; + intervalType = "HOUR"; break; case 7: - intervalType = 'DAY'; + intervalType = "DAY"; break; case 30: - intervalType = 'DAY'; + intervalType = "DAY"; break; case 90: - intervalType = 'DAY'; + intervalType = "DAY"; break; } const tz = context.requestTimezone as string; - const viewsGroupedByInterval = analyticsEngine.getViewsGroupedByInterval(actualSiteId, intervalType, interval, tz); + const viewsGroupedByInterval = analyticsEngine.getViewsGroupedByInterval( + actualSiteId, + intervalType, + interval, + tz, + ); return json({ - siteId: siteId || '@unknown', - sites: sitesByHits.map(([site,]: [string,]) => site), + siteId: siteId || "@unknown", + sites: sitesByHits.map(([site]: [string]) => site), views: (await counts).views, visits: (await counts).visits, visitors: (await counts).visitors, @@ -94,25 +116,27 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => { countByReferrer: await countByReferrer, countByDevice: await countByDevice, viewsGroupedByInterval: await viewsGroupedByInterval, - intervalType + intervalType, }); }; -function convertCountryCodesToNames(countByCountry: AnalyticsQueryResultRow[]): AnalyticsQueryResultRow[] { - const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); +function convertCountryCodesToNames( + countByCountry: AnalyticsQueryResultRow[], +): AnalyticsQueryResultRow[] { + const regionNames = new Intl.DisplayNames(["en"], { type: "region" }); return countByCountry.map((countByBrowserRow: AnalyticsQueryResultRow) => { let countryName; try { // throws an exception if country code isn't valid // use try/catch to be defensive and not explode if an invalid // country code gets insrted into Analytics Engine - countryName = regionNames.of(countByBrowserRow[0]); // "United States" + countryName = regionNames.of(countByBrowserRow[0]); // "United States" } catch (err) { - countryName = '(unknown)' + countryName = "(unknown)"; } const count = countByBrowserRow[1]; return [countryName, count]; - }) + }); } export default function Dashboard() { @@ -138,7 +162,7 @@ export default function Dashboard() { data.viewsGroupedByInterval.forEach((row: AnalyticsQueryResultRow) => { chartData.push({ date: row[0], - views: row[1] + views: row[1], }); }); @@ -147,24 +171,33 @@ export default function Dashboard() { return (
-
- changeSite(site)} + > {/* SelectItem explodes if given an empty string for `value` so coerce to @unknown */} - {data.sites.map((siteId: string) => - {siteId || '(unknown)'} - )} + {data.sites.map((siteId: string) => ( + + {siteId || "(unknown)"} + + ))}
- - changeInterval(interval)} + > @@ -191,7 +224,9 @@ export default function Dashboard() {
{data.visits}
-
Visitors
+
+ Visitors +
{data.visitors}
@@ -203,24 +238,42 @@ export default function Dashboard() {
- +
- - - + + +
- - - - - + + + + +
); diff --git a/codecov.yml b/codecov.yml index 97c3332..1ad7da5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,12 +1,12 @@ ignore: - - "*.config.js" + - "*.config.js" # Coverage checks shouldn't fail the build coverage: - status: - project: - default: - target: 0% - patch: - default: - target: 0% + status: + project: + default: + target: 0% + patch: + default: + target: 0% diff --git a/components.json b/components.json index 812e152..1160037 100644 --- a/components.json +++ b/components.json @@ -1,17 +1,17 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "app/globals.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "~/components", - "utils": "~/lib/utils" - } -} \ No newline at end of file + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "~/components", + "utils": "~/lib/utils" + } +} diff --git a/global.d.ts b/global.d.ts index 73cce0e..d08cf69 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,14 +1,14 @@ interface CFAnalyticsEngine { writeDataPoint: (data: { - indexes: string[], - blobs: string[], - doubles: number[], + indexes: string[]; + blobs: string[]; + doubles: number[]; }) => void; } interface Environment { __STATIC_CONTENT: Fetcher; - WEB_COUNTER_AE: CFAnalyticsEngine - CF_BEARER_TOKEN: string - CF_ACCOUNT_ID: string -} \ No newline at end of file + WEB_COUNTER_AE: CFAnalyticsEngine; + CF_BEARER_TOKEN: string; + CF_ACCOUNT_ID: string; +} diff --git a/package.json b/package.json index e82a7e4..5940aff 100644 --- a/package.json +++ b/package.json @@ -1,71 +1,71 @@ { - "name": "counterscale", - "private": true, - "sideEffects": false, - "type": "module", - "scripts": { - "build": "remix build", - "deploy": "npm run build && wrangler deploy", - "dev": "remix dev --manual -c \"npm start\"", - "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", - "start": "wrangler dev ./build/index.js", - "test": "TZ=EST vitest run", - "test-ci": "TZ=EST vitest run --coverage", - "typecheck": "tsc", - "prepare": "husky install" - }, - "dependencies": { - "@cloudflare/kv-asset-handler": "^0.1.3", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-slot": "^1.0.2", - "@remix-run/cloudflare": "^2.4.1", - "@remix-run/css-bundle": "^2.4.1", - "@remix-run/react": "^2.4.1", - "class-variance-authority": "^0.7.0", - "clsx": "^2.0.0", - "dayjs": "^1.11.10", - "isbot": "^3.6.8", - "lucide-react": "^0.302.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "recharts": "^2.10.3", - "run": "^1.4.0", - "tailwind-merge": "^2.2.0", - "tailwindcss-animate": "^1.0.7", - "ua-parser-js": "^1.0.37" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20230518.0", - "@remix-run/dev": "^2.4.1", - "@remix-run/testing": "^2.5.0", - "@testing-library/react": "^14.1.2", - "@types/react": "^18.2.20", - "@types/react-dom": "^18.2.7", - "@types/recharts": "^1.8.29", - "@types/ua-parser-js": "^0.7.39", - "@typescript-eslint/eslint-plugin": "^6.7.4", - "@vitest/coverage-istanbul": "^1.2.0", - "@vitest/coverage-v8": "^1.2.0", - "autoprefixer": "^10.4.16", - "eslint": "^8.38.0", - "eslint-config-prettier": "^9.0.0", - "eslint-import-resolver-typescript": "^3.6.1", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", - "husky": "^8.0.3", - "jsdom": "^23.2.0", - "node-mocks-http": "^1.14.0", - "resize-observer-polyfill": "^1.5.1", - "tailwindcss": "^3.4.0", - "typescript": "^5.1.6", - "vite-tsconfig-paths": "^4.2.3", - "vitest": "^1.2.0", - "vitest-dom": "^0.1.1", - "wrangler": "3.22.1" - }, - "engines": { - "node": ">=20.0.0" - } -} \ No newline at end of file + "name": "counterscale", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix build", + "deploy": "npm run build && wrangler deploy", + "dev": "remix dev --manual -c \"npm start\"", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "wrangler dev ./build/index.js", + "test": "TZ=EST vitest run", + "test-ci": "TZ=EST vitest run --coverage", + "typecheck": "tsc", + "prepare": "husky install" + }, + "dependencies": { + "@cloudflare/kv-asset-handler": "^0.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "@remix-run/cloudflare": "^2.4.1", + "@remix-run/css-bundle": "^2.4.1", + "@remix-run/react": "^2.4.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "dayjs": "^1.11.10", + "isbot": "^3.6.8", + "lucide-react": "^0.302.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.10.3", + "run": "^1.4.0", + "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7", + "ua-parser-js": "^1.0.37" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20230518.0", + "@remix-run/dev": "^2.4.1", + "@remix-run/testing": "^2.5.0", + "@testing-library/react": "^14.1.2", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@types/recharts": "^1.8.29", + "@types/ua-parser-js": "^0.7.39", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@vitest/coverage-istanbul": "^1.2.0", + "@vitest/coverage-v8": "^1.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.38.0", + "eslint-config-prettier": "^9.0.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "husky": "^8.0.3", + "jsdom": "^23.2.0", + "node-mocks-http": "^1.14.0", + "resize-observer-polyfill": "^1.5.1", + "tailwindcss": "^3.4.0", + "typescript": "^5.1.6", + "vite-tsconfig-paths": "^4.2.3", + "vitest": "^1.2.0", + "vitest-dom": "^0.1.1", + "wrangler": "3.22.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/postcss.config.js b/postcss.config.js index b4a6220..49c0612 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,7 +1,6 @@ export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} - + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/tracker.js b/public/tracker.js index a4faf65..02d51b0 100644 --- a/public/tracker.js +++ b/public/tracker.js @@ -32,17 +32,17 @@ SOFTWARE. */ (function () { - 'use strict'; + "use strict"; let queue = (window.counterscale && window.counterscale.q) || []; let config = { - 'siteId': '', - 'trackerUrl': '', + siteId: "", + trackerUrl: "", }; const commands = { - "set": set, - "trackPageview": trackPageview, - "setTrackerUrl": setTrackerUrl, + set: set, + trackPageview: trackPageview, + setTrackerUrl: setTrackerUrl, }; function set(key, value) { @@ -57,15 +57,21 @@ SOFTWARE. function stringifyObject(obj) { var keys = Object.keys(obj); - return '?' + - keys.map(function (k) { - return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k]); - }).join('&'); + return ( + "?" + + keys + .map(function (k) { + return ( + encodeURIComponent(k) + "=" + encodeURIComponent(obj[k]) + ); + }) + .join("&") + ); } function findTrackerUrl() { - const el = document.getElementById('counterscale-script') - return el ? el.src.replace('tracker.js', 'collect') : ''; + const el = document.getElementById("counterscale-script"); + return el ? el.src.replace("tracker.js", "collect") : ""; } function trackPageview(vars) { @@ -77,7 +83,10 @@ SOFTWARE. // } // ignore prerendered pages - if ('visibilityState' in document && document.visibilityState === 'prerender') { + if ( + "visibilityState" in document && + document.visibilityState === "prerender" + ) { return; } @@ -85,7 +94,7 @@ SOFTWARE. if (document.body === null) { document.addEventListener("DOMContentLoaded", () => { trackPageview(vars); - }) + }); return; } @@ -93,30 +102,30 @@ SOFTWARE. let req = window.location; // do not track if not served over HTTP or HTTPS (eg from local filesystem) and we're not in an Electron app - if (req.host === '' && navigator.userAgent.indexOf("Electron") < 0) { + if (req.host === "" && navigator.userAgent.indexOf("Electron") < 0) { return; } // find canonical URL let canonical = document.querySelector('link[rel="canonical"][href]'); if (canonical) { - let a = document.createElement('a'); + let a = document.createElement("a"); a.href = canonical.href; // use parsed canonical as location object req = a; } - let path = vars.path || (req.pathname + req.search); + let path = vars.path || req.pathname + req.search; if (!path) { - path = '/'; + path = "/"; } // determine hostname - let hostname = vars.hostname || (req.protocol + "//" + req.hostname); + let hostname = vars.hostname || req.protocol + "//" + req.hostname; // only set referrer if not internal - let referrer = vars.referrer || ''; + let referrer = vars.referrer || ""; if (document.referrer.indexOf(hostname) < 0) { referrer = document.referrer; } @@ -128,15 +137,15 @@ SOFTWARE. sid: config.siteId, }; - let url = config.trackerUrl || findTrackerUrl() - let img = document.createElement('img'); - img.setAttribute('alt', ''); - img.setAttribute('aria-hidden', 'true'); - img.setAttribute('style', 'position:absolute'); + let url = config.trackerUrl || findTrackerUrl(); + let img = document.createElement("img"); + img.setAttribute("alt", ""); + img.setAttribute("aria-hidden", "true"); + img.setAttribute("style", "position:absolute"); img.src = url + stringifyObject(d); - img.addEventListener('load', function () { + img.addEventListener("load", function () { // remove tracking img from DOM - document.body.removeChild(img) + document.body.removeChild(img); }); // in case img.onload never fires, remove img after 1s & reset src attribute to cancel request @@ -145,8 +154,8 @@ SOFTWARE. return; } - img.src = ''; - document.body.removeChild(img) + img.src = ""; + document.body.removeChild(img); }, 1000); // add to DOM to fire request @@ -162,4 +171,4 @@ SOFTWARE. // process existing queue queue.forEach((i) => counterscale.apply(this, i)); -})() \ No newline at end of file +})(); diff --git a/server.ts b/server.ts index 20c14eb..6b38f1a 100644 --- a/server.ts +++ b/server.ts @@ -21,7 +21,7 @@ export default { async fetch( request: Request, env: Environment, - ctx: ExecutionContext + ctx: ExecutionContext, ): Promise { try { const url = new URL(request.url); @@ -46,7 +46,7 @@ export default { browserTTL: ttl, edgeTTL: ttl, }, - } + }, ); } catch (error) { // No-op @@ -55,12 +55,15 @@ export default { try { const loadContext: AppLoadContext = { env, - requestTimezone: (request as RequestInit).cf?.timezone as string + requestTimezone: (request as RequestInit).cf + ?.timezone as string, }; return await handleRemixRequest(request, loadContext); } catch (error) { console.log(error); - return new Response("An unexpected error occurred", { status: 500 }); + return new Response("An unexpected error occurred", { + status: 500, + }); } }, }; diff --git a/tailwind.config.js b/tailwind.config.js index 7cb7e37..0976199 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,77 +1,77 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', - ], - prefix: "", - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, }, - }, - plugins: [require("tailwindcss-animate")], -} \ No newline at end of file + plugins: [require("tailwindcss-animate")], +}; diff --git a/tsconfig.json b/tsconfig.json index 9f37a15..31b1140 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,7 @@ { - "include": [ - "remix.env.d.ts", - "**/*.ts", - "**/*.tsx" - ], + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], "compilerOptions": { - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "isolatedModules": true, "esModuleInterop": true, "jsx": "react-jsx", @@ -21,11 +13,9 @@ "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { - "~/*": [ - "./app/*" - ] + "~/*": ["./app/*"] }, // Remix takes care of building everything in `remix build`. "noEmit": true } -} \ No newline at end of file +} diff --git a/vitest.config.js b/vitest.config.js index 9b8767e..bbc5a43 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,12 +1,12 @@ // vitest.config.ts -import { defineConfig } from 'vitest/config' -import tsconfigPaths from 'vite-tsconfig-paths' +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ - test: { - coverage: { - provider: 'v8' // or 'v8' + test: { + coverage: { + provider: "v8", // or 'v8' + }, }, - }, - plugins: [tsconfigPaths()] -}) + plugins: [tsconfigPaths()], +});