diff --git a/src/__tests__/unit/api-schemas.test.ts b/src/__tests__/unit/api-schemas.test.ts index 8484ce1..96dd264 100644 --- a/src/__tests__/unit/api-schemas.test.ts +++ b/src/__tests__/unit/api-schemas.test.ts @@ -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"; diff --git a/src/api/analytics.ts b/src/api/analytics.ts index 4af4a35..400531b 100644 --- a/src/api/analytics.ts +++ b/src/api/analytics.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Env, TimelineRange } from "../types"; +import { DEFAULT_TIMELINE_RANGE, TIMELINE_RANGES } from "../constants"; import { getDashboardStats, getLinkAnalytics, @@ -9,7 +10,6 @@ import { } from "../services/link-management"; import { resolveClickFilters } from "../services/admin-management"; import { fromServiceResult } from "./response"; -import { TIMELINE_RANGES } from "./schemas"; const VALID_RANGES = new Set(TIMELINE_RANGES); @@ -18,7 +18,7 @@ function parseRange(rangeParam: string | null | undefined): TimelineRange | unde } export async function handleDashboardStats(env: Env, identity: string, rangeParam?: string | null): Promise { - const range: TimelineRange = parseRange(rangeParam) ?? "30d"; + const range: TimelineRange = parseRange(rangeParam) ?? DEFAULT_TIMELINE_RANGE; return fromServiceResult(await getDashboardStats(env, range, identity)); } @@ -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 { - 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)); } diff --git a/src/api/bundles.ts b/src/api/bundles.ts index be5acf6..1e585d7 100644 --- a/src/api/bundles.ts +++ b/src/api/bundles.ts @@ -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(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; } @@ -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 { - 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 })); } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c3e8316..d2d784c 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -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 ---- @@ -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() @@ -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(), diff --git a/src/constants.ts b/src/constants.ts index edfb7a6..8802845 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4fba5f9..249ea3d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 = { diff --git a/src/services/admin-management.ts b/src/services/admin-management.ts index 467a360..f386061 100644 --- a/src/services/admin-management.ts +++ b/src/services/admin-management.ts @@ -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(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 }; @@ -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), }); @@ -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) { @@ -194,5 +193,5 @@ export async function resolveMcpRange( ): Promise { 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; } diff --git a/src/types.ts b/src/types.ts index 110d7c8..f7a1a63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; @@ -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]; export interface TimelineBucket { label: string;