Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 12 additions & 4 deletions client/src/api/analytics/analyticsRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import { CommonApiParams, toQueryParams } from "./endpoints/types";
/**
* The analytics read layer, as a value.
*
* Every analytics endpoint is `/sites/:site/<path>` carrying the shared time
* window and filters on the query string plus a handful of endpoint params.
* Analytics endpoints carry the shared time window and filters on the query
* string plus a handful of endpoint params.
* A descriptor says only what differs between them; `buildAnalyticsRequest`
* turns it into the exact request that goes on the wire, and the query key is
* built from that same object — so key and request cannot drift.
*/
export interface AnalyticsDescriptor {
/** Path under `/sites/:site` — e.g. "overview", "events/names". */
/** Path under the Site or organization — e.g. "overview", "site-cards-lite". */
path: string;
/** Organization-scoped analytics, instead of the default `/sites/:site` scope. */
organizationId?: string;
/**
* Endpoint params beyond the shared time/filter context, already named as
* the API expects them on the wire. Undefined values are dropped.
Expand Down Expand Up @@ -44,6 +46,7 @@ export interface AnalyticsContext {

export interface AnalyticsRequest {
path: string;
organizationId?: string;
params: Record<string, unknown>;
body?: unknown;
unwrap: boolean;
Expand All @@ -64,6 +67,7 @@ export function buildAnalyticsRequest(descriptor: AnalyticsDescriptor, context:

return {
path: descriptor.path,
...(descriptor.organizationId !== undefined ? { organizationId: descriptor.organizationId } : {}),
params: omitUndefined({ ...contextParams, ...descriptor.params }),
body: descriptor.body?.(common),
unwrap: descriptor.unwrap ?? true,
Expand All @@ -75,8 +79,12 @@ export function buildAnalyticsRequest(descriptor: AnalyticsDescriptor, context:
* callers (CSV export), so both go through the same seam.
*/
export async function fetchAnalytics<TData>(site: number | string, request: AnalyticsRequest): Promise<TData> {
const base =
request.organizationId === undefined
? `/sites/${site}`
: `/organizations/${encodeURIComponent(request.organizationId)}`;
const response = await authedFetch<TData | { data: TData }>(
`/sites/${site}/${request.path}`,
`${base}/${request.path}`,
request.params,
request.body === undefined ? {} : { method: "POST", data: request.body }
);
Expand Down
7 changes: 7 additions & 0 deletions client/src/api/analytics/endpoints/siteCards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface SiteCardMetrics {
current: { sessions: number; users: number };
previous: { sessions: number; users: number } | null;
series: { time: string; sessions: number }[];
}

export type SiteCardsResponse = Record<number, SiteCardMetrics>;
22 changes: 22 additions & 0 deletions client/src/api/analytics/hooks/useGetSiteCards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useStore } from "@/lib/store";
import { buildAnalyticsRequest } from "../analyticsRequest";
import { SiteCardsResponse } from "../endpoints/siteCards";
import { useAnalyticsContext, useAnalyticsQuery } from "../useAnalyticsQuery";

export function useGetSiteCards(organizationId: string, siteIds: number[], lite: boolean) {
const bucket = useStore(state => state.bucket);
const previous = useAnalyticsContext({ periodTime: "previous", useFilters: false });
const comparison = previous.hasPeriod ? buildAnalyticsRequest({ path: "" }, previous.context).params : null;

return useAnalyticsQuery<SiteCardsResponse>({
key: "site-cards",
path: lite ? "site-cards-lite" : "site-cards",
organizationId,
useFilters: false,
params: { bucket },
body: () => ({ siteIds: [...new Set(siteIds)].sort((a, b) => a - b), comparison }),
enabled: siteIds.length > 0,
// A new page, organization or period must never show the old cards' totals.
placeholder: false,
});
}
23 changes: 13 additions & 10 deletions client/src/api/analytics/useAnalyticsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ export function useAnalyticsContext(options: AnalyticsContextOptions = {}): {

function useAnalyticsRequest(options: AnalyticsContextOptions & AnalyticsDescriptor) {
const { site, context, hasPeriod } = useAnalyticsContext(options);
return { site, hasPeriod, request: buildAnalyticsRequest(options, context) };
const request = buildAnalyticsRequest(options, context);
const scope = request.organizationId === undefined ? site : `organization:${request.organizationId}`;
const hasScope = request.organizationId === undefined ? !!site : !!request.organizationId;
return { site, scope, hasScope, hasPeriod, request };
}

export interface AnalyticsQueryOptions<TData> extends AnalyticsContextOptions, AnalyticsDescriptor {
Expand All @@ -115,24 +118,24 @@ export interface AnalyticsQueryOptions<TData> extends AnalyticsContextOptions, A

const buildQueryKey = (
key: string | readonly unknown[],
site: number | string | undefined,
scope: number | string | undefined,
request: AnalyticsRequest
) => [...(Array.isArray(key) ? key : [key]), site, request.path, request.params, request.body];
) => [...(Array.isArray(key) ? key : [key]), scope, request.path, request.params, request.body];

export function useAnalyticsQuery<TData>(options: AnalyticsQueryOptions<TData>): UseQueryResult<TData> {
const { site, request, hasPeriod } = useAnalyticsRequest(options);
const { site, scope, hasScope, request, hasPeriod } = useAnalyticsRequest(options);

return useQuery<TData, Error>({
queryKey: buildQueryKey(options.key, site, request),
queryKey: buildQueryKey(options.key, scope, request),
queryFn: () => fetchAnalytics<TData>(site!, request),
staleTime: options.staleTime ?? 60_000,
refetchInterval: options.refetchInterval,
placeholderData:
(options.placeholder ?? true)
? (previousData, previousQuery) =>
site !== undefined && previousQuery?.queryKey?.includes(site) ? previousData : undefined
scope !== undefined && previousQuery?.queryKey?.includes(scope) ? previousData : undefined
: undefined,
enabled: (options.enabled ?? true) && !!site && hasPeriod,
enabled: (options.enabled ?? true) && hasScope && hasPeriod,
...options.props,
});
}
Expand All @@ -153,10 +156,10 @@ export interface AnalyticsInfiniteQueryOptions<TPage, TCursor> extends Analytics
export function useAnalyticsInfiniteQuery<TPage, TCursor = number>(
options: AnalyticsInfiniteQueryOptions<TPage, TCursor>
): UseInfiniteQueryResult<InfiniteData<TPage>> {
const { site, request, hasPeriod } = useAnalyticsRequest(options);
const { site, scope, hasScope, request, hasPeriod } = useAnalyticsRequest(options);

return useInfiniteQuery<TPage, Error, InfiniteData<TPage>, readonly unknown[], TCursor>({
queryKey: [...buildQueryKey(options.key, site, request), "infinite"],
queryKey: [...buildQueryKey(options.key, scope, request), "infinite"],
queryFn: ({ pageParam }) =>
fetchAnalytics<TPage>(site!, {
...request,
Expand All @@ -167,7 +170,7 @@ export function useAnalyticsInfiniteQuery<TPage, TCursor = number>(
staleTime: options.staleTime ?? 60_000,
refetchInterval: options.refetchInterval,
refetchOnWindowFocus: options.refetchOnWindowFocus,
enabled: (options.enabled ?? true) && !!site && hasPeriod,
enabled: (options.enabled ?? true) && hasScope && hasPeriod,
});
}

Expand Down
86 changes: 61 additions & 25 deletions client/src/app/(home)/SiteCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Tag, Settings } from "lucide-react";
import { useExtracted } from "next-intl";
import Link from "next/link";
import { useRef } from "react";
import { Ref, useRef } from "react";
import { SiteCardMetrics } from "@/api/analytics/endpoints/siteCards";
import { useGetOverview } from "@/api/analytics/hooks/useGetOverview";
import { useGetOverviewBucketed } from "@/api/analytics/hooks/useGetOverviewBucketed";
import { ChangePercentage } from "@/app/[site]/main/components/MainSection/Overview";
Expand All @@ -17,7 +18,7 @@ import { LITE_DASHBOARD } from "@/lib/const";
import { useStore } from "@/lib/store";
import { formatter } from "@/lib/utils";

interface SiteCardProps {
export interface SiteCardProps {
siteId: number;
name: string;
domain: string;
Expand All @@ -28,17 +29,8 @@ interface SiteCardProps {
onTagClick?: (tag: string) => void;
}

export function SiteCard({
siteId,
name,
domain,
tags = [],
allTags = [],
onTagsUpdated,
selectedTags = [],
onTagClick,
}: SiteCardProps) {
const t = useExtracted();
export function SiteCard(props: SiteCardProps) {
const { siteId } = props;
const { ref, isInView } = useInView({
// Start loading slightly before the card comes into view
rootMargin: "200px",
Expand Down Expand Up @@ -84,15 +76,64 @@ export function SiteCard({
hasLoadedData.current = true;
}

const hasData = (overviewData?.sessions || 0) > 0;

// Show skeleton when loading or not yet in view, but not if we've already loaded data previously
const showSkeleton = (isLoading || isOverviewLoading || !isInView) && !hasLoadedData.current;

return (
<SiteCardView
{...props}
cardRef={ref}
data={data}
overviewData={overviewData}
overviewDataPrevious={overviewDataPrevious}
showSkeleton={showSkeleton}
/>
);
}

export function BatchedSiteCard({ metrics, ...props }: SiteCardProps & { metrics?: SiteCardMetrics }) {
const { ref, isInView } = useInView({ rootMargin: "200px", persistVisibility: true });
return (
<SiteCardView
{...props}
cardRef={ref}
data={metrics?.series}
overviewData={metrics?.current}
overviewDataPrevious={metrics?.previous}
showSkeleton={!metrics}
showChart={isInView}
/>
);
}

function SiteCardView({
siteId,
name,
domain,
tags = [],
allTags = [],
onTagsUpdated,
onTagClick,
cardRef,
data,
overviewData,
overviewDataPrevious,
showSkeleton,
showChart = true,
}: SiteCardProps & {
cardRef: Ref<HTMLDivElement>;
data?: SiteCardMetrics["series"];
overviewData?: SiteCardMetrics["current"];
overviewDataPrevious?: SiteCardMetrics["previous"];
showSkeleton: boolean;
showChart?: boolean;
}) {
const t = useExtracted();
const hasData = (overviewData?.sessions || 0) > 0;
return (
<Link href={`/${siteId}`}>
<div
ref={ref}
ref={cardRef}
className="flex flex-col md:flex-row md:justify-between gap-3 rounded-lg bg-white dark:bg-neutral-900/70 px-3 py-2 border border-neutral-100 dark:border-neutral-850 transition-all duration-300 hover:translate-y-[-2px] w-full"
>
{showSkeleton ? (
Expand Down Expand Up @@ -124,6 +165,7 @@ export function SiteCard({
<Tooltip>
<SiteSettings
siteId={siteId}
lazy
trigger={
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
Expand Down Expand Up @@ -176,7 +218,7 @@ export function SiteCard({
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-center justify-between">
<div className="relative rounded-md w-40 h-12.5">
<SiteSessionChart data={data ?? []} />
{showChart && <SiteSessionChart data={data ?? []} />}
{!hasData && (
<div className="absolute inset-0 flex items-center justify-center bg-white/70 dark:bg-neutral-900/70 backdrop-blur-sm">
<span className="text-sm text-neutral-500 dark:text-neutral-400">{t("No data available")}</span>
Expand All @@ -190,10 +232,7 @@ export function SiteCard({
<div className="font-semibold text-xl flex gap-2">
{formatter(overviewData?.sessions ?? 0)}{" "}
{overviewData?.sessions && overviewDataPrevious?.sessions ? (
<ChangePercentage
current={overviewData?.sessions}
previous={overviewDataPrevious?.sessions}
/>
<ChangePercentage current={overviewData?.sessions} previous={overviewDataPrevious?.sessions} />
) : null}
</div>
</div>
Expand All @@ -203,10 +242,7 @@ export function SiteCard({
<div className="font-semibold text-xl flex gap-2">
{formatter(overviewData?.users ?? 0)}{" "}
{overviewData?.users && overviewDataPrevious?.users ? (
<ChangePercentage
current={overviewData?.users}
previous={overviewDataPrevious?.users}
/>
<ChangePercentage current={overviewData?.users} previous={overviewDataPrevious?.users} />
) : null}
</div>
</div>
Expand Down
Loading
Loading