Skip to content

Commit 36168ed

Browse files
authored
Merge pull request #5 from spoo-me/feat/stats-endpoints-migration
feat: rework per-link stats onto the new analytics surface
2 parents 1bfd643 + 0068c99 commit 36168ed

6 files changed

Lines changed: 161 additions & 139 deletions

File tree

src/api/stats.ts

Lines changed: 81 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
11
import { request } from "@/api/client";
2-
import type { StatsQuery, StatsResponse } from "@/api/types";
3-
import { API_BASE_URL, API_V1, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants";
2+
import type { PublicStatsQuery, StatsQuery, StatsResponse } from "@/api/types";
3+
import { getUrlByAddress } from "@/api/urls";
4+
import { API_BASE_URL, API_V1 } from "@/lib/constants";
45
import { ApiError } from "@/lib/errors";
5-
import { statsResponseSchema } from "@/schemas/api";
6+
import { authModeStorage } from "@/lib/storage";
7+
import { publicStatsResponseSchema, statsResponseSchema } from "@/schemas/api";
68

79
/**
8-
* Get stats via v1 API (works for v2/new URLs only).
10+
* Thrown when per-link stats exist behind a gate we can't pass:
11+
* the code is unknown, the owner made stats private (404), or the
12+
* link is password protected (401 password_required). The UI treats
13+
* all of these as a single "stats unavailable" state.
914
*/
10-
export function getStatsV1(query: StatsQuery = {}): Promise<StatsResponse> {
15+
export class StatsUnavailableError extends Error {
16+
constructor(message: string) {
17+
super(message);
18+
this.name = "StatsUnavailableError";
19+
}
20+
}
21+
22+
/**
23+
* Account-wide analytics. GET /api/v1/stats — auth required.
24+
*/
25+
export function getAccountStats(query: StatsQuery = {}): Promise<StatsResponse> {
1126
return request(
1227
`${API_V1}/stats`,
1328
{ params: query as Record<string, string | number | boolean | undefined> },
@@ -16,131 +31,80 @@ export function getStatsV1(query: StatsQuery = {}): Promise<StatsResponse> {
1631
}
1732

1833
/**
19-
* V0 stats response shape (legacy embedded analytics).
20-
*/
21-
export interface V0StatsResponse {
22-
_id: string;
23-
short_code: string;
24-
url: string;
25-
"total-clicks": number;
26-
total_unique_clicks: number;
27-
"creation-date": string;
28-
"last-click": string | null;
29-
"last-click-browser": string | null;
30-
"last-click-os": string | null;
31-
average_daily_clicks: number;
32-
average_weekly_clicks: number;
33-
average_monthly_clicks: number;
34-
average_redirection_time: number;
35-
counter: Record<string, number>;
36-
unique_counter: Record<string, number>;
37-
browser: Record<string, number>;
38-
unique_browser: Record<string, number>;
39-
os_name: Record<string, number>;
40-
unique_os_name: Record<string, number>;
41-
country: Record<string, number>;
42-
unique_country: Record<string, number>;
43-
referrer: Record<string, number>;
44-
unique_referrer: Record<string, number>;
45-
}
46-
47-
/**
48-
* Get stats via v0 API (works for legacy/v0 URLs).
49-
* POST /stats/{shortCode} with optional password.
34+
* Stats for one link the signed-in user owns.
35+
* GET /api/v1/stats/links/{urlId} — auth required, 404 for foreign/unknown ids.
5036
*/
51-
export async function getStatsV0(shortCode: string, password?: string): Promise<V0StatsResponse> {
52-
const url = `${API_BASE_URL}/stats/${shortCode}`;
53-
const headers: Record<string, string> = { [CLIENT_HEADER]: CLIENT_HEADER_VALUE };
54-
55-
const body = password ? new URLSearchParams({ password }) : undefined;
56-
if (body) {
57-
headers["Content-Type"] = "application/x-www-form-urlencoded";
58-
}
59-
60-
const res = await fetch(url, {
61-
method: "POST",
62-
headers,
63-
body: body?.toString(),
64-
});
65-
66-
if (!res.ok) {
67-
throw new Error(`Stats request failed: ${res.statusText}`);
68-
}
69-
70-
return res.json();
37+
export function getLinkStats(urlId: string, query: StatsQuery = {}): Promise<StatsResponse> {
38+
return request(
39+
`${API_V1}/stats/links/${urlId}`,
40+
{ params: query as Record<string, string | number | boolean | undefined> },
41+
statsResponseSchema,
42+
);
7143
}
7244

7345
/**
74-
* Convert v0 stats to the same shape the UI expects.
46+
* Public per-link stats. GET /api/v1/public/stats/{shortCode} — no auth.
47+
* The envelope is {generation, link, stats}; the inner stats object is the
48+
* same wire shape as the authed endpoints, so we unwrap it here.
7549
*/
76-
function v0ToStatsResponse(v0: V0StatsResponse): StatsResponse {
77-
// Convert Record<string, number> breakdowns to array format
78-
const toArray = (obj: Record<string, number>, nameKey: string) =>
79-
Object.entries(obj)
80-
.sort(([, a], [, b]) => b - a)
81-
.map(([name, clicks]) => ({ [nameKey]: name, clicks }));
82-
83-
return {
84-
scope: "anon",
85-
filters: {},
86-
group_by: [],
87-
timezone: "UTC",
88-
time_range: { start_date: v0["creation-date"], end_date: null },
89-
summary: {
90-
total_clicks: v0["total-clicks"],
91-
unique_clicks: v0.total_unique_clicks,
92-
first_click: v0["creation-date"],
93-
last_click: v0["last-click"],
94-
avg_redirection_time: v0.average_redirection_time,
95-
},
96-
metrics: {
97-
browser: toArray(v0.browser, "browser"),
98-
os: toArray(v0.os_name, "os"),
99-
country: toArray(v0.country, "country"),
100-
referrer: toArray(v0.referrer, "referrer"),
101-
clicks_over_time: Object.entries(v0.counter)
102-
.sort(([a], [b]) => a.localeCompare(b))
103-
.map(([date, clicks]) => ({ date, clicks })),
50+
export async function getPublicStats(
51+
shortCode: string,
52+
query: PublicStatsQuery = {},
53+
): Promise<StatsResponse> {
54+
const { stats } = await request(
55+
`${API_V1}/public/stats/${encodeURIComponent(shortCode)}`,
56+
{
57+
params: query as Record<string, string | number | boolean | undefined>,
58+
noAuth: true,
10459
},
105-
computed_metrics: {
106-
// Match v1 format: rates are already percentages (e.g. 6.25 = 6.25%)
107-
unique_click_rate:
108-
v0["total-clicks"] > 0
109-
? Math.round((v0.total_unique_clicks / v0["total-clicks"]) * 10000) / 100
110-
: 0,
111-
repeat_click_rate:
112-
v0["total-clicks"] > 0
113-
? Math.round(
114-
((v0["total-clicks"] - v0.total_unique_clicks) / v0["total-clicks"]) * 10000,
115-
) / 100
116-
: 0,
117-
average_clicks_per_visitor:
118-
v0.total_unique_clicks > 0
119-
? Math.round((v0["total-clicks"] / v0.total_unique_clicks) * 100) / 100
120-
: 0,
121-
},
122-
};
60+
publicStatsResponseSchema,
61+
);
62+
return stats;
12363
}
12464

12565
/**
126-
* Get stats with automatic v1 -> v0 fallback.
127-
* Tries v1 API first (for v2 URLs), falls back to v0 (for legacy URLs).
66+
* Get stats for a short code, picking the right surface at runtime:
67+
*
68+
* 1. Signed in → resolve the code to an owned url id via
69+
* GET /urls/{domain}/{alias} and use the per-link authed endpoint.
70+
* 2. Resolution 404s (not our link) or we're anonymous → fall back to
71+
* the public stats endpoint.
72+
*
73+
* Public 404 (unknown code or private stats) and 401 (password
74+
* protected) both surface as StatsUnavailableError.
12875
*/
129-
export async function getStats(query: StatsQuery = {}): Promise<StatsResponse> {
130-
// If querying a specific short code, use scope=anon, try v1 first, fall back to v0
131-
if (query.short_code) {
76+
export async function getUrlStats(
77+
shortCode: string,
78+
query: StatsQuery = {},
79+
): Promise<StatsResponse> {
80+
const mode = await authModeStorage.getValue();
81+
82+
if (mode === "jwt" || mode === "apikey") {
13283
try {
133-
return await getStatsV1({ ...query, scope: query.scope ?? "anon" });
84+
const url = await getUrlByAddress(new URL(API_BASE_URL).hostname, shortCode);
85+
return await getLinkStats(url.id, query);
13486
} catch (e) {
135-
// v1 404 means it's a legacy URL — fall back to v0 endpoint
136-
if (e instanceof ApiError && e.isNotFound) {
137-
const v0 = await getStatsV0(query.short_code);
138-
return v0ToStatsResponse(v0);
139-
}
140-
throw e;
87+
// 404 means the link isn't in this account (foreign or unknown) —
88+
// the public surface is the only remaining read path.
89+
if (!(e instanceof ApiError && e.isNotFound)) throw e;
14190
}
14291
}
14392

144-
// No specific short code — use v1 API only
145-
return getStatsV1(query);
93+
try {
94+
return await getPublicStats(shortCode, {
95+
start_date: query.start_date,
96+
end_date: query.end_date,
97+
timezone: query.timezone,
98+
});
99+
} catch (e) {
100+
if (e instanceof ApiError && e.isUnauthorized) {
101+
throw new StatsUnavailableError("This link's stats are password protected.");
102+
}
103+
if (e instanceof ApiError && e.isNotFound) {
104+
throw new StatsUnavailableError(
105+
"Stats aren't available for this link. It may not exist, or its owner made stats private.",
106+
);
107+
}
108+
throw e;
109+
}
146110
}

src/api/types.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,11 @@ export interface ListUrlsQuery {
184184

185185
// ── Stats ────────────────────────────────────────────────────
186186

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

209+
/**
210+
* Query for GET /api/v1/public/stats/{short_code} — the public endpoint
211+
* takes a time window only (no grouping/metric selection).
212+
*/
213+
export interface PublicStatsQuery {
214+
start_date?: string;
215+
end_date?: string;
216+
timezone?: string;
217+
}
218+
207219
export interface StatsSummary {
208220
total_clicks: number;
209221
unique_clicks: number;
210222
first_click: string | null;
211223
last_click: string | null;
212-
avg_redirection_time: number;
224+
// null = no measurement (e.g. zero clicks in range), never 0
225+
avg_redirection_time: number | null;
213226
}
214227

215228
export interface StatsTimeRange {
@@ -246,6 +259,17 @@ export interface StatsResponse {
246259
computed_metrics?: ComputedMetrics | null;
247260
}
248261

262+
/**
263+
* Envelope for GET /api/v1/public/stats/{short_code}. `link` is the
264+
* frozen public-facts wire — kept loose since we only consume `stats`,
265+
* which matches the authed stats wire.
266+
*/
267+
export interface PublicStatsResponse {
268+
generation: string;
269+
link: Record<string, unknown>;
270+
stats: StatsResponse;
271+
}
272+
249273
// ── API Keys ─────────────────────────────────────────────────
250274

251275
export type ApiKeyScope =

src/api/urls.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import type {
55
UpdateUrlRequest,
66
UpdateUrlResponse,
77
UpdateUrlStatusRequest,
8+
UrlListItem,
89
UrlListResponse,
910
} from "@/api/types";
1011
import { API_V1 } from "@/lib/constants";
1112
import {
1213
deleteUrlResponseSchema,
1314
updateUrlResponseSchema,
15+
urlListItemSchema,
1416
urlListResponseSchema,
1517
} from "@/schemas/api";
1618

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

27+
/**
28+
* Resolve an owned URL by its natural key (domain + alias).
29+
* 404 covers both unknown aliases and links owned by someone else.
30+
*/
31+
export function getUrlByAddress(domain: string, alias: string): Promise<UrlListItem> {
32+
return request(
33+
`${API_V1}/urls/${encodeURIComponent(domain)}/${encodeURIComponent(alias)}`,
34+
{},
35+
urlListItemSchema,
36+
);
37+
}
38+
2539
export function updateUrl(urlId: string, data: UpdateUrlRequest): Promise<UpdateUrlResponse> {
2640
return request(
2741
`${API_V1}/urls/${urlId}`,

src/components/sidepanel/AnalyticsTab.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
XAxis,
1414
YAxis,
1515
} from "recharts";
16+
import { StatsUnavailableError } from "@/api/stats";
1617
import type { StatsResponse } from "@/api/types";
1718
import { Button } from "@/components/ui/button";
1819
import {
@@ -23,7 +24,7 @@ import {
2324
} from "@/components/ui/chart";
2425
import { Input } from "@/components/ui/input";
2526
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
26-
import { useStats } from "@/hooks/use-stats";
27+
import { useAccountStats, useUrlStats } from "@/hooks/use-stats";
2728
import { extractShortCode } from "@/lib/url-utils";
2829
import { useAuthStore } from "@/stores/auth";
2930

@@ -61,8 +62,7 @@ export function AnalyticsTab() {
6162
// ── Account-Level Analytics ──────────────────────────────────
6263

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

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

@@ -119,7 +116,12 @@ function UrlAnalytics() {
119116
</Button>
120117
</form>
121118
{isLoading && <SkeletonStats />}
122-
{error && <p className="text-sm text-destructive">{error.message}</p>}
119+
{error &&
120+
(error instanceof StatsUnavailableError ? (
121+
<p className="text-sm text-muted-foreground">{error.message}</p>
122+
) : (
123+
<p className="text-sm text-destructive">{error.message}</p>
124+
))}
123125
{data && <StatsDisplay data={data} title={`spoo.me/${activeCode}`} />}
124126
{!activeCode && !isLoading && (
125127
<div className="py-8 text-center">

0 commit comments

Comments
 (0)