-
![CounterScale Logo](/counterscale-logo.webp)
+ return (
+
+
+
+
+
![CounterScale Logo](/counterscale-logo.webp)
+
-
-
-
-
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.
+
+
- div>
+ );
}
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 (
-
-
-
-
changeInterval(interval)}>
+ 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()],
+});