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
2 changes: 1 addition & 1 deletion src/__tests__/unit/api-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect } from "vitest";
import { TIMELINE_RANGES } from "../../constants";
import {
CustomSlugStringSchema,
BundleAccentSchema,
BUNDLE_ACCENTS,
TIMELINE_RANGES,
CreateLinkBodySchema,
UpdateLinkBodySchema,
} from "../../api/schemas";
Expand Down
6 changes: 3 additions & 3 deletions src/api/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
// SPDX-License-Identifier: Apache-2.0

import { Env, TimelineRange } from "../types";
import { DEFAULT_TIMELINE_RANGE, TIMELINE_RANGES } from "../constants";
import {
getDashboardStats,
getLinkAnalytics,
getLinkTimeline,
} from "../services/link-management";
import { resolveClickFilters } from "../services/admin-management";
import { fromServiceResult } from "./response";
import { TIMELINE_RANGES } from "./schemas";

const VALID_RANGES = new Set<TimelineRange>(TIMELINE_RANGES);

Expand All @@ -18,7 +18,7 @@ function parseRange(rangeParam: string | null | undefined): TimelineRange | unde
}

export async function handleDashboardStats(env: Env, identity: string, rangeParam?: string | null): Promise<Response> {
const range: TimelineRange = parseRange(rangeParam) ?? "30d";
const range: TimelineRange = parseRange(rangeParam) ?? DEFAULT_TIMELINE_RANGE;
return fromServiceResult(await getDashboardStats(env, range, identity));
}

Expand All @@ -44,7 +44,7 @@ export async function handlePublicLinkAnalytics(env: Env, linkId: number, rangeP
}

export async function handleAdminLinkTimeline(env: Env, identity: string, linkId: number, rangeParam?: string | null): Promise<Response> {
const range: TimelineRange = parseRange(rangeParam) ?? "30d";
const range: TimelineRange = parseRange(rangeParam) ?? DEFAULT_TIMELINE_RANGE;
const filters = await resolveClickFilters(env, identity);
return fromServiceResult(await getLinkTimeline(env, linkId, range, filters));
}
Expand Down
10 changes: 5 additions & 5 deletions src/api/bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ import {
IdParamSchema,
LinkSchema,
RangeQuerySchema,
TIMELINE_RANGES,
UpdateBundleBodySchema,
paramHook,
} from "./schemas";
import { DEFAULT_TIMELINE_RANGE, TIMELINE_RANGES } from "../constants";
const VALID_RANGES = new Set<TimelineRange>(TIMELINE_RANGES);

function parseRange(raw: string | undefined, fallback: TimelineRange = "30d"): TimelineRange {
function parseRange(raw: string | undefined, fallback: TimelineRange = DEFAULT_TIMELINE_RANGE): TimelineRange {
return VALID_RANGES.has(raw as TimelineRange) ? (raw as TimelineRange) : fallback;
}

Expand Down Expand Up @@ -90,11 +90,11 @@ export async function handleDeleteBundle(env: Env, id: number, identity: string)
}

/**
* Admin-side: applies the viewer's filter preferences and falls back to "30d"
* when no range is provided.
* Admin-side: applies the viewer's filter preferences and falls back to the
* default timeline range when no range is provided.
*/
export async function handleAdminBundleAnalytics(env: Env, id: number, rangeParam: string | undefined, identity: string): Promise<Response> {
const range = parseRange(rangeParam, "30d");
const range = parseRange(rangeParam, DEFAULT_TIMELINE_RANGE);
const filters = await resolveClickFilters(env, identity);
return fromServiceResult(await getBundleAnalytics(env, id, range, identity, { filters }));
}
Expand Down
6 changes: 2 additions & 4 deletions src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

import { z } from "@hono/zod-openapi";
import type { Hook } from "@hono/zod-openapi";
import { MIN_SLUG_LENGTH, MAX_SLUG_LENGTH, TIMELINE_RANGES } from "../constants";
import { formatZodError } from "./response";
import { MIN_SLUG_LENGTH, MAX_SLUG_LENGTH } from "../constants";

// ---- Common ----

Expand All @@ -13,8 +13,6 @@ export const ErrorResponseSchema = z
.strict()
.openapi("ErrorResponse", { description: "Error response with a single human-readable message." });

export const TIMELINE_RANGES = ["24h", "7d", "30d", "90d", "1y", "all"] as const;

export const RangeQuerySchema = z
.object({
range: z.enum(TIMELINE_RANGES).optional()
Expand Down Expand Up @@ -203,7 +201,7 @@ export const TimelineBucketSchema = z

export const TimelineDataSchema = z
.object({
range: z.enum(["24h", "7d", "30d", "90d", "1y", "all"]),
range: z.enum(TIMELINE_RANGES),
buckets: z.array(TimelineBucketSchema),
summary: z.object({
last_24h: z.number().int().nonnegative(),
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export const MIN_SLUG_LENGTH = 3;
export const MAX_SLUG_LENGTH = 128;
export const DEFAULT_SLUG_LENGTH = MIN_SLUG_LENGTH;

export const TIMELINE_RANGES = ["24h", "7d", "30d", "90d", "1y", "all"] as const;
export const DEFAULT_TIMELINE_RANGE: (typeof TIMELINE_RANGES)[number] = "30d";

export const MIN_QR_SIZE = 1;
export const MAX_QR_SIZE = 2048;
export const DEFAULT_QR_SIZE = 220;
3 changes: 1 addition & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,12 @@ import {
compareLinkStats,
} from "../services/analytics";
import type { TimelineRange } from "../types";
import { MIN_SLUG_LENGTH, MAX_SLUG_LENGTH, DEFAULT_SLUG_LENGTH, TIMELINE_RANGES } from "../constants";
import { renderQrSvg } from "../qr";
import {
BUNDLE_ACCENTS,
CustomSlugStringSchema,
TIMELINE_RANGES,
} from "../api/schemas";
import { MIN_SLUG_LENGTH, MAX_SLUG_LENGTH, DEFAULT_SLUG_LENGTH } from "../constants";
import pkg from "../../package.json";

type ToolResult = {
Expand Down
13 changes: 6 additions & 7 deletions src/services/admin-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@

import { ApiKeyRepository, SettingRepository } from "../db";
import type { ApiKeyRow, ClickFilters } from "../db";
import { DEFAULT_SLUG_LENGTH } from "../constants";
import { DEFAULT_SLUG_LENGTH, DEFAULT_TIMELINE_RANGE, TIMELINE_RANGES } from "../constants";
import { validateSlugLength } from "../slugs";
import { Env, TimelineRange } from "../types";
import { ServiceResult, ok, fail } from "./result";

const VALID_RANGES: TimelineRange[] = ["24h", "7d", "30d", "90d", "1y", "all"];
const DEFAULT_RANGE: TimelineRange = "30d";
const VALID_RANGES = new Set<TimelineRange>(TIMELINE_RANGES);

function isValidRange(v: unknown): v is TimelineRange {
return typeof v === "string" && (VALID_RANGES as string[]).includes(v);
return typeof v === "string" && VALID_RANGES.has(v as TimelineRange);
}

export type { ServiceResult };
Expand Down Expand Up @@ -112,7 +111,7 @@ export async function getAppSettings(
slug_default_length: parseInt(slugLength ?? String(DEFAULT_SLUG_LENGTH), 10),
theme: theme ?? null,
lang: lang ?? null,
default_range: isValidRange(defaultRange) ? defaultRange : DEFAULT_RANGE,
default_range: isValidRange(defaultRange) ? defaultRange : DEFAULT_TIMELINE_RANGE,
filter_bots: parseBoolSetting(filterBots, true),
filter_self_referrers: parseBoolSetting(filterSelfReferrers, true),
});
Expand Down Expand Up @@ -147,7 +146,7 @@ export async function updateAppSettings(
} else if (isValidRange(body.default_range)) {
await SettingRepository.set(env.DB, identity, "default_range", body.default_range);
} else {
return fail(400, `default_range must be one of: ${VALID_RANGES.join(", ")}`);
return fail(400, `default_range must be one of: ${TIMELINE_RANGES.join(", ")}`);
}
}
if (body.filter_bots !== undefined) {
Expand Down Expand Up @@ -194,5 +193,5 @@ export async function resolveMcpRange(
): Promise<TimelineRange> {
if (requested) return requested;
const result = await getAppSettings(env, identity);
return result.ok ? result.data.default_range : DEFAULT_RANGE;
return result.ok ? result.data.default_range : DEFAULT_TIMELINE_RANGE;
}
4 changes: 3 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright 2026 Oddbit (https://oddbit.id)
// SPDX-License-Identifier: Apache-2.0

import type { TIMELINE_RANGES } from "./constants";

export interface Env {
DB: D1Database;
SLUG_KV?: KVNamespace;
Expand Down Expand Up @@ -102,7 +104,7 @@ export interface ClickStats {
num_browsers: number;
}

export type TimelineRange = "24h" | "7d" | "30d" | "90d" | "1y" | "all";
export type TimelineRange = (typeof TIMELINE_RANGES)[number];
Comment thread
DennisAlund marked this conversation as resolved.

export interface TimelineBucket {
label: string;
Expand Down