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
198 changes: 81 additions & 117 deletions src/api/stats.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import { request } from "@/api/client";
import type { StatsQuery, StatsResponse } from "@/api/types";
import { API_BASE_URL, API_V1, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants";
import type { PublicStatsQuery, StatsQuery, StatsResponse } from "@/api/types";
import { getUrlByAddress } from "@/api/urls";
import { API_BASE_URL, API_V1 } from "@/lib/constants";
import { ApiError } from "@/lib/errors";
import { statsResponseSchema } from "@/schemas/api";
import { authModeStorage } from "@/lib/storage";
import { publicStatsResponseSchema, statsResponseSchema } from "@/schemas/api";

/**
* Get stats via v1 API (works for v2/new URLs only).
* Thrown when per-link stats exist behind a gate we can't pass:
* the code is unknown, the owner made stats private (404), or the
* link is password protected (401 password_required). The UI treats
* all of these as a single "stats unavailable" state.
*/
export function getStatsV1(query: StatsQuery = {}): Promise<StatsResponse> {
export class StatsUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "StatsUnavailableError";
}
}

/**
* Account-wide analytics. GET /api/v1/stats — auth required.
*/
export function getAccountStats(query: StatsQuery = {}): Promise<StatsResponse> {
return request(
`${API_V1}/stats`,
{ params: query as Record<string, string | number | boolean | undefined> },
Expand All @@ -16,131 +31,80 @@ export function getStatsV1(query: StatsQuery = {}): Promise<StatsResponse> {
}

/**
* V0 stats response shape (legacy embedded analytics).
*/
export interface V0StatsResponse {
_id: string;
short_code: string;
url: string;
"total-clicks": number;
total_unique_clicks: number;
"creation-date": string;
"last-click": string | null;
"last-click-browser": string | null;
"last-click-os": string | null;
average_daily_clicks: number;
average_weekly_clicks: number;
average_monthly_clicks: number;
average_redirection_time: number;
counter: Record<string, number>;
unique_counter: Record<string, number>;
browser: Record<string, number>;
unique_browser: Record<string, number>;
os_name: Record<string, number>;
unique_os_name: Record<string, number>;
country: Record<string, number>;
unique_country: Record<string, number>;
referrer: Record<string, number>;
unique_referrer: Record<string, number>;
}

/**
* Get stats via v0 API (works for legacy/v0 URLs).
* POST /stats/{shortCode} with optional password.
* Stats for one link the signed-in user owns.
* GET /api/v1/stats/links/{urlId} — auth required, 404 for foreign/unknown ids.
*/
export async function getStatsV0(shortCode: string, password?: string): Promise<V0StatsResponse> {
const url = `${API_BASE_URL}/stats/${shortCode}`;
const headers: Record<string, string> = { [CLIENT_HEADER]: CLIENT_HEADER_VALUE };

const body = password ? new URLSearchParams({ password }) : undefined;
if (body) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
}

const res = await fetch(url, {
method: "POST",
headers,
body: body?.toString(),
});

if (!res.ok) {
throw new Error(`Stats request failed: ${res.statusText}`);
}

return res.json();
export function getLinkStats(urlId: string, query: StatsQuery = {}): Promise<StatsResponse> {
return request(
`${API_V1}/stats/links/${urlId}`,
{ params: query as Record<string, string | number | boolean | undefined> },
statsResponseSchema,
);
}

/**
* Convert v0 stats to the same shape the UI expects.
* Public per-link stats. GET /api/v1/public/stats/{shortCode} — no auth.
* The envelope is {generation, link, stats}; the inner stats object is the
* same wire shape as the authed endpoints, so we unwrap it here.
*/
function v0ToStatsResponse(v0: V0StatsResponse): StatsResponse {
// Convert Record<string, number> breakdowns to array format
const toArray = (obj: Record<string, number>, nameKey: string) =>
Object.entries(obj)
.sort(([, a], [, b]) => b - a)
.map(([name, clicks]) => ({ [nameKey]: name, clicks }));

return {
scope: "anon",
filters: {},
group_by: [],
timezone: "UTC",
time_range: { start_date: v0["creation-date"], end_date: null },
summary: {
total_clicks: v0["total-clicks"],
unique_clicks: v0.total_unique_clicks,
first_click: v0["creation-date"],
last_click: v0["last-click"],
avg_redirection_time: v0.average_redirection_time,
},
metrics: {
browser: toArray(v0.browser, "browser"),
os: toArray(v0.os_name, "os"),
country: toArray(v0.country, "country"),
referrer: toArray(v0.referrer, "referrer"),
clicks_over_time: Object.entries(v0.counter)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, clicks]) => ({ date, clicks })),
export async function getPublicStats(
shortCode: string,
query: PublicStatsQuery = {},
): Promise<StatsResponse> {
const { stats } = await request(
`${API_V1}/public/stats/${encodeURIComponent(shortCode)}`,
{
params: query as Record<string, string | number | boolean | undefined>,
noAuth: true,
},
computed_metrics: {
// Match v1 format: rates are already percentages (e.g. 6.25 = 6.25%)
unique_click_rate:
v0["total-clicks"] > 0
? Math.round((v0.total_unique_clicks / v0["total-clicks"]) * 10000) / 100
: 0,
repeat_click_rate:
v0["total-clicks"] > 0
? Math.round(
((v0["total-clicks"] - v0.total_unique_clicks) / v0["total-clicks"]) * 10000,
) / 100
: 0,
average_clicks_per_visitor:
v0.total_unique_clicks > 0
? Math.round((v0["total-clicks"] / v0.total_unique_clicks) * 100) / 100
: 0,
},
};
publicStatsResponseSchema,
);
return stats;
}

/**
* Get stats with automatic v1 -> v0 fallback.
* Tries v1 API first (for v2 URLs), falls back to v0 (for legacy URLs).
* Get stats for a short code, picking the right surface at runtime:
*
* 1. Signed in → resolve the code to an owned url id via
* GET /urls/{domain}/{alias} and use the per-link authed endpoint.
* 2. Resolution 404s (not our link) or we're anonymous → fall back to
* the public stats endpoint.
*
* Public 404 (unknown code or private stats) and 401 (password
* protected) both surface as StatsUnavailableError.
*/
export async function getStats(query: StatsQuery = {}): Promise<StatsResponse> {
// If querying a specific short code, use scope=anon, try v1 first, fall back to v0
if (query.short_code) {
export async function getUrlStats(
shortCode: string,
query: StatsQuery = {},
): Promise<StatsResponse> {
const mode = await authModeStorage.getValue();

if (mode === "jwt" || mode === "apikey") {
try {
return await getStatsV1({ ...query, scope: query.scope ?? "anon" });
const url = await getUrlByAddress(new URL(API_BASE_URL).hostname, shortCode);
return await getLinkStats(url.id, query);
} catch (e) {
// v1 404 means it's a legacy URL — fall back to v0 endpoint
if (e instanceof ApiError && e.isNotFound) {
const v0 = await getStatsV0(query.short_code);
return v0ToStatsResponse(v0);
}
throw e;
// 404 means the link isn't in this account (foreign or unknown) —
// the public surface is the only remaining read path.
if (!(e instanceof ApiError && e.isNotFound)) throw e;
}
}

// No specific short code — use v1 API only
return getStatsV1(query);
try {
return await getPublicStats(shortCode, {
start_date: query.start_date,
end_date: query.end_date,
timezone: query.timezone,
});
} catch (e) {
if (e instanceof ApiError && e.isUnauthorized) {
throw new StatsUnavailableError("This link's stats are password protected.");
}
if (e instanceof ApiError && e.isNotFound) {
throw new StatsUnavailableError(
"Stats aren't available for this link. It may not exist, or its owner made stats private.",
);
}
throw e;
}
}
30 changes: 27 additions & 3 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,11 @@ export interface ListUrlsQuery {

// ── Stats ────────────────────────────────────────────────────

/**
* Query for the authed stats endpoints: GET /api/v1/stats (account)
* and GET /api/v1/stats/links/{url_id} (one owned link).
*/
export interface StatsQuery {
scope?: "all" | "anon";
short_code?: string;
start_date?: string;
end_date?: string;
group_by?: string;
Expand All @@ -204,12 +206,23 @@ export interface ExportQuery extends StatsQuery {
format: "csv" | "xlsx" | "json" | "xml";
}

/**
* Query for GET /api/v1/public/stats/{short_code} — the public endpoint
* takes a time window only (no grouping/metric selection).
*/
export interface PublicStatsQuery {
start_date?: string;
end_date?: string;
timezone?: string;
}

export interface StatsSummary {
total_clicks: number;
unique_clicks: number;
first_click: string | null;
last_click: string | null;
avg_redirection_time: number;
// null = no measurement (e.g. zero clicks in range), never 0
avg_redirection_time: number | null;
}

export interface StatsTimeRange {
Expand Down Expand Up @@ -246,6 +259,17 @@ export interface StatsResponse {
computed_metrics?: ComputedMetrics | null;
}

/**
* Envelope for GET /api/v1/public/stats/{short_code}. `link` is the
* frozen public-facts wire — kept loose since we only consume `stats`,
* which matches the authed stats wire.
*/
export interface PublicStatsResponse {
generation: string;
link: Record<string, unknown>;
stats: StatsResponse;
}

// ── API Keys ─────────────────────────────────────────────────

export type ApiKeyScope =
Expand Down
14 changes: 14 additions & 0 deletions src/api/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import type {
UpdateUrlRequest,
UpdateUrlResponse,
UpdateUrlStatusRequest,
UrlListItem,
UrlListResponse,
} from "@/api/types";
import { API_V1 } from "@/lib/constants";
import {
deleteUrlResponseSchema,
updateUrlResponseSchema,
urlListItemSchema,
urlListResponseSchema,
} from "@/schemas/api";

Expand All @@ -22,6 +24,18 @@ export function listUrls(query: ListUrlsQuery = {}): Promise<UrlListResponse> {
);
}

/**
* Resolve an owned URL by its natural key (domain + alias).
* 404 covers both unknown aliases and links owned by someone else.
*/
export function getUrlByAddress(domain: string, alias: string): Promise<UrlListItem> {
return request(
`${API_V1}/urls/${encodeURIComponent(domain)}/${encodeURIComponent(alias)}`,
{},
urlListItemSchema,
);
}

export function updateUrl(urlId: string, data: UpdateUrlRequest): Promise<UpdateUrlResponse> {
return request(
`${API_V1}/urls/${urlId}`,
Expand Down
26 changes: 14 additions & 12 deletions src/components/sidepanel/AnalyticsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { StatsUnavailableError } from "@/api/stats";
import type { StatsResponse } from "@/api/types";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -23,7 +24,7 @@ import {
} from "@/components/ui/chart";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStats } from "@/hooks/use-stats";
import { useAccountStats, useUrlStats } from "@/hooks/use-stats";
import { extractShortCode } from "@/lib/url-utils";
import { useAuthStore } from "@/stores/auth";

Expand Down Expand Up @@ -61,8 +62,7 @@ export function AnalyticsTab() {
// ── Account-Level Analytics ──────────────────────────────────

function AccountAnalytics() {
const { data, isLoading, error } = useStats({
scope: "all",
const { data, isLoading, error } = useAccountStats({
group_by: "time,browser,os,country,referrer,short_code",
metrics: "clicks,unique_clicks",
});
Expand All @@ -80,14 +80,11 @@ function UrlAnalytics() {
const [shortCode, setShortCode] = useState("");
const [activeCode, setActiveCode] = useState<string | undefined>();

const { data, isLoading, error } = useStats(
activeCode
? {
short_code: activeCode,
group_by: "time,browser,os,country,referrer",
metrics: "clicks,unique_clicks",
}
: {},
// Grouping/metric selection only applies on the authed per-link path;
// the public endpoint returns its fixed dimension set regardless.
const { data, isLoading, error } = useUrlStats(
activeCode ?? "",
{ group_by: "time,browser,os,country,referrer", metrics: "clicks,unique_clicks" },
!!activeCode,
);

Expand Down Expand Up @@ -119,7 +116,12 @@ function UrlAnalytics() {
</Button>
</form>
{isLoading && <SkeletonStats />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{error &&
(error instanceof StatsUnavailableError ? (
<p className="text-sm text-muted-foreground">{error.message}</p>
) : (
<p className="text-sm text-destructive">{error.message}</p>
))}
{data && <StatsDisplay data={data} title={`spoo.me/${activeCode}`} />}
{!activeCode && !isLoading && (
<div className="py-8 text-center">
Expand Down
Loading
Loading