Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Org Analytics UI #1

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
finished the analytics
Chifez committed Dec 4, 2024
commit 2957090040cbfb7fa85c77fc5a537ea914ce9e00
86 changes: 1 addition & 85 deletions app/analytics/query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ColumnMappingToType, ColumnMappings } from "./schema";

import { EngagementResult, SearchFilters } from "~/lib/types";
import { SearchFilters } from "~/lib/types";

import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
@@ -422,90 +422,6 @@ export class AnalyticsEngineAPI {
}
}

async getEngagementMetrics(
siteId: string,
interval: string,
tz?: string,
filters: SearchFilters = {},
) {
const { startIntervalSql, endIntervalSql } = intervalToSql(
interval,
tz,
);
const prevInterval = getPreviousInterval(interval);
const { startIntervalSql: prevStartSql, endIntervalSql: prevEndSql } =
intervalToSql(prevInterval, tz);
const filterStr = filtersToSql(filters);

const query = `
SELECT
SUM(IF(${ColumnMappings.newSession} = 1, _sample_interval, 0)) as total_visits,
SUM(IF(${ColumnMappings.pageViews} = 1 AND ${ColumnMappings.newSession} = 1, _sample_interval, 0)) as bounce_visits,
AVG(IF(${ColumnMappings.newSession} = 1, ${ColumnMappings.visitDuration}, 0.0)) as avg_duration
FROM metricsDataset
WHERE timestamp >= ${startIntervalSql}
AND timestamp < ${endIntervalSql}
AND ${ColumnMappings.siteId} = '${siteId}'
${filterStr}`;

const prevQuery = `
SELECT
SUM(IF(${ColumnMappings.newSession} = 1, _sample_interval, 0)) as total_visits,
SUM(IF(${ColumnMappings.pageViews} = 1 AND ${ColumnMappings.newSession} = 1, _sample_interval, 0)) as bounce_visits,
AVG(IF(${ColumnMappings.newSession} = 1, ${ColumnMappings.visitDuration}, 0.0)) as avg_duration
FROM metricsDataset
WHERE timestamp >= ${prevStartSql}
AND timestamp < ${prevEndSql}
AND ${ColumnMappings.siteId} = '${siteId}'
${filterStr}`;

try {
const [currentResponse, previousResponse] = await Promise.all([
this.query(query),
this.query(prevQuery),
]);

if (!currentResponse.ok || !previousResponse.ok) {
throw new Error("Failed to fetch engagement metrics");
}

const currentData = await currentResponse.json();
const previousData = await previousResponse.json();

const calculateBounceRate = (data: any | unknown) => {
const total = Number(data.total_visits || 0);
const bounces = Number(data.bounce_visits || 0);
return total > 0 ? (bounces / total) * 100 : 0;
};

return {
current: {
bounceRate: calculateBounceRate(
(currentData as EngagementResult["current"] | any)
?.data[0],
),
duration: Number(
(currentData as EngagementResult["current"] | any)
?.data[0]?.avg_duration || 0,
),
},
previous: {
bounceRate: calculateBounceRate(
(previousData as EngagementResult["previous"] | any)
?.data[0],
),
duration: Number(
(previousData as EngagementResult["previous"] | any)
?.data[0]?.avg_duration || 0,
),
},
};
} catch (error) {
console.error("Error fetching engagement metrics:", error);
throw new Error("Failed to fetch engagement metrics");
}
}

async getVisitorCountByColumn<T extends keyof typeof ColumnMappings>(
siteId: string,
column: T,
18 changes: 15 additions & 3 deletions app/components/ChangeIndicator.tsx
Original file line number Diff line number Diff line change
@@ -15,15 +15,27 @@ const ChangeIndicator = ({

const renderArrow = () => {
if (isIncreased === true)
return <ArrowUp size={16} strokeWidth={0.75} />;
return (
<ArrowUp
size={16}
strokeWidth={0.75}
className="fill-green-200"
/>
);
if (isIncreased === false)
return <ArrowDown size={16} strokeWidth={0.75} />;
return (
<ArrowDown
size={16}
strokeWidth={0.75}
className="fill-red-200"
/>
);
return "-";
};

return (
<span
className={`rounded py-1 px-2 ${getIndicatorStyles()} flex items-center gap-2 w-fit`}
className={`rounded text-black py-1 px-2 ${getIndicatorStyles()} flex items-center gap-2 w-fit`}
>
{renderArrow()}
<p className="font-semibold text-sm">{percentageChange}</p>
1 change: 0 additions & 1 deletion app/components/PaginationButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React from "react";

import { ArrowLeft, ArrowRight } from "lucide-react";

interface PaginationButtonsProps {
1 change: 0 additions & 1 deletion app/components/SearchFilterBadges.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React from "react";

import { SearchFilters } from "~/lib/types";

interface SearchFiltersProps {
4 changes: 2 additions & 2 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -72,12 +72,12 @@
}

/* Or alternatively, you can use this more specific selector */
.dark body {
body .dark {
@apply text-white;
}

/* You can also add this to ensure all text elements inherit the color */
.dark * {
* .dark {
@apply text-white;
}
}
12 changes: 0 additions & 12 deletions app/lib/types.ts
Original file line number Diff line number Diff line change
@@ -6,22 +6,10 @@ export interface SearchFilters {
browserName?: string;
}

export interface EngagementMetrics {
bounceRate: number; // as percentage (0-100)
duration: number; // in seconds
}

export interface EngagementResult {
current: EngagementMetrics;
previous: EngagementMetrics;
}

export type MetricData = {
views: number;
visits: number;
visitors: number;
bounceRate: number;
duration: number;
};

export type MetricChange = {
26 changes: 1 addition & 25 deletions app/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -110,34 +110,10 @@ export function getDateTimeRange(interval: string, tz: string) {
};
}

export function formatDuration(seconds: number): string {
if (!seconds || seconds === 0) return "0s";

const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);

if (minutes === 0) return `${remainingSeconds}s`;
return `${minutes}m${remainingSeconds}s`;
}

export function calculateMetricsChange(
current: MetricData,
previous: MetricData,
): Record<keyof MetricData, MetricChange> {
const formatValue = (
metric: keyof MetricData,
value: number,
): string | number => {
switch (metric) {
case "bounceRate":
return `${value.toFixed(1)}%`;
case "duration":
return formatDuration(value);
default:
return value;
}
};

const calculateChange = (
currentVal: number,
previousVal: number,
@@ -168,7 +144,7 @@ export function calculateMetricsChange(
const change = calculateChange(currentValue, previousValue);

acc[key] = {
value: formatValue(key, currentValue),
value: currentValue,
percentage: change.percentage,
isIncreased: change.isIncreased,
};
25 changes: 18 additions & 7 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -13,34 +13,45 @@
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";

export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];

export const loader = ({ context, request }: LoaderFunctionArgs) => {
const url = new URL(request.url);

const theme = url.searchParams.get("theme");

return json({
version: context.cloudflare?.env?.CF_PAGES_COMMIT_SHA,
origin: url.origin,
url: request.url,
theme: theme,
});
};

export const Layout = ({ children = [] }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState("false");
const data = useLoaderData<typeof loader>() ?? {
version: "unknown",
origin: "counterscale.dev",
url: "https://counterscale.dev/",
theme: "false",
};

const bodyRef = useRef<HTMLBodyElement>(null);

useEffect(() => {
const theme = localStorage.getItem("theme");
if (theme == "dark") {
document.documentElement.classList.add("dark");
const getTheme = data?.theme;
if (getTheme) {
setTheme(getTheme);
}
if (theme === "true") {
bodyRef.current?.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
bodyRef.current?.classList.remove("dark");
}
}, []);
}, [theme]);

Check warning on line 54 in app/root.tsx

GitHub Actions / test

React Hook useEffect has a missing dependency: 'data?.theme'. Either include it or remove the dependency array

return (
<html lang="en">
@@ -79,7 +90,7 @@
<Meta />
<Links />
</head>
<body>
<body ref={bodyRef}>
<div className="p-4 mx-auto">{children}</div>
<ScrollRestoration />
<Scripts />
13 changes: 6 additions & 7 deletions app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -62,6 +62,7 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => {
const url = new URL(request.url);

let interval;

try {
interval = url.searchParams.get("interval") || "7d";
} catch (err) {
@@ -82,6 +83,7 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => {
}

const siteId = url.searchParams.get("site") || "";

const actualSiteId = siteId === "@unknown" ? "" : siteId;

const filters = getFiltersFromSearchParams(url.searchParams);
@@ -95,7 +97,6 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => {
const sitesByHits = analyticsEngine.getSitesOrderedByHits(
`${MAX_RETENTION_DAYS}d`,
);
console.log("by hit", await sitesByHits);
const intervalType = getIntervalType(interval);

// await all requests to AE then return the results
@@ -204,7 +205,7 @@ export default function Dashboard() {

return (
<div className="space-y-6">
<div className="md:sticky dark:bg-black md:z-50 top-0 flex flex-col lg:flex-row lg:items-center justify-between gap-4 py-4">
<div className="md:sticky dark:bg-black bg-white md:z-50 top-0 flex flex-col lg:flex-row lg:items-center justify-between gap-4 py-4">
<div className="w-full mb-4">
<StatsCard
siteId={data.siteId}
@@ -242,28 +243,26 @@ export default function Dashboard() {
<div className="flex items-center justify-center divide-x">
<button
onClick={() => switchInterval("prev")}
className="bg-gray-100 hover:bg-gray-300 flex items-center justify-center p-2"
className="bg-gray-100 text-black hover:bg-gray-300 flex items-center justify-center p-2"
>
<ChevronLeft strokeWidth={0.75} />
</button>
<button
onClick={() => switchInterval("next")}
className="bg-gray-100 hover:bg-gray-300 flex items-center justify-center p-2"
className="bg-gray-100 text-black hover:bg-gray-300 flex items-center justify-center p-2"
>
<ChevronRight strokeWidth={0.75} />
</button>
</div>
</div>
</div>

{/* <div className="basis-auto flex"> */}
<div className="m-auto">
<SearchFilterBadges
filters={data.filters}
onFilterDelete={handleFilterDelete}
/>
</div>
{/* </div> */}

<div
className="w-full transition py-4"
@@ -279,7 +278,7 @@ export default function Dashboard() {
</div>
</div>

<div className="divide-y">
<div className="divide-y border">
<div className="grid grid-cols-1 md:grid-cols-2 divide-x [&>*]:h-full">
<PathsCard
siteId={data.siteId}
44 changes: 8 additions & 36 deletions app/routes/resources.stats.tsx
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@ import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import {
calculateMetricsChange,
formatDuration,
getFiltersFromSearchParams,
paramsFromUrl,
} from "~/lib/utils";
@@ -19,23 +18,13 @@ export async function loader({ context, request }: LoaderFunctionArgs) {
const filters = getFiltersFromSearchParams(url.searchParams);

const counts = await analyticsEngine.getCounts(site, interval, tz, filters);
const metrics = await analyticsEngine.getEngagementMetrics(
site,
interval,
tz,
filters,
);

const allMetrics = {
current: {
...counts.current,
bounceRate: metrics.current.bounceRate,
duration: metrics.current.duration,
},
previous: {
...counts.previous,
bounceRate: metrics.previous.bounceRate,
duration: metrics.previous.duration,
},
};

@@ -62,37 +51,19 @@ export const StatsCard = ({

const { metrics, changes } = dataFetcher.data || {};

console.log("metrics", metrics, "changes", changes);

const formatValue = (
label: keyof MetricData,
value: number | undefined,
) => {
const formatValue = (value: number | undefined) => {
if (value === undefined || value === null) return "-";

switch (label) {
case "bounceRate":
return `${value.toFixed(1)}%`;
case "duration":
return formatDuration(value);
default:
return new Intl.NumberFormat("en", {
notation: "compact",
}).format(value);
}
return new Intl.NumberFormat("en", {
notation: "compact",
}).format(value);
};

const metricCards: Array<{
label: keyof MetricData;
formatter?: (value: number) => string;
}> = [
{ label: "views" },
{ label: "visits" },
{ label: "visitors" },
{ label: "bounceRate" },
{ label: "duration" },
];
// In your component, after fetching the dat
}> = [{ label: "views" }, { label: "visits" }, { label: "visitors" }];

useEffect(() => {
const params = {
site: siteId,
@@ -108,6 +79,7 @@ export const StatsCard = ({
// NOTE: dataFetcher is intentionally omitted from the useEffect dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [siteId, interval, filters, timezone]);

return (
<div className="flex flex-wrap justify-start items-center lg:justify-around gap-10 w-full md:w-fit rounded-md py-2 lg:p-2">
{metricCards.map(({ label }) => {
@@ -124,7 +96,7 @@ export const StatsCard = ({
{label}
</p>
<p className="font-bold text-xl md:text-3xl">
{formatValue(label, currentValue)}
{formatValue(currentValue)}
</p>
<div>
{change && (
2 changes: 1 addition & 1 deletion tailwind.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
darkMode: "class",
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",

Unchanged files with check annotations Beta

interface PaginatedTableCardProps {
siteId: string;
interval: string;
dataFetcher: undefined | any; // Changed EntrypointBranded to any

Check warning on line 11 in app/components/PaginatedTableCard.tsx

GitHub Actions / test

Unexpected any. Specify a different type
columnHeaders: string[];
filters?: SearchFilters;
loaderUrl: string;
"https://example.com/resources/stats?site=test-site&interval=24h&timezone=UTC",
);
const response = await loader({ context, request } as undefined | any);

Check warning on line 22 in app/routes/__tests__/resources.stats.test.tsx

GitHub Actions / test

Unexpected any. Specify a different type
const data = await response.json();
expect(mockGetCounts).toHaveBeenCalledWith(