From a6c6640b795be30c28b9be2fc536e4d6e54b4bad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 05:29:28 +0000 Subject: [PATCH 1/3] schemas, admin: derive remaining range arrays from TIMELINE_RANGES TimelineDataSchema and admin-management's VALID_RANGES still hand-wrote the same range tuple defined as TIMELINE_RANGES in api/schemas.ts. Both now derive from the canonical constant so adding or removing a range is a single-file edit and the OpenAPI shape can't drift from the runtime validator. Closes #5 --- src/api/schemas.ts | 2 +- src/services/admin-management.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 1f4e466..f1c81bb 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -202,7 +202,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/services/admin-management.ts b/src/services/admin-management.ts index 467a360..6edbe61 100644 --- a/src/services/admin-management.ts +++ b/src/services/admin-management.ts @@ -4,15 +4,15 @@ import { ApiKeyRepository, SettingRepository } from "../db"; import type { ApiKeyRow, ClickFilters } from "../db"; import { DEFAULT_SLUG_LENGTH } from "../constants"; +import { TIMELINE_RANGES } from "../api/schemas"; 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"; function isValidRange(v: unknown): v is TimelineRange { - return typeof v === "string" && (VALID_RANGES as string[]).includes(v); + return typeof v === "string" && (TIMELINE_RANGES as readonly string[]).includes(v); } export type { ServiceResult }; @@ -147,7 +147,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) { From db957a65be7586fed3af3728ca82f2289a15f0d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 05:47:40 +0000 Subject: [PATCH 2/3] constants: hoist TIMELINE_RANGES out of api/schemas Service-layer code (admin-management) shouldn't reach into the API module to read its constants. Moves TIMELINE_RANGES to src/constants.ts alongside the other runtime constants and derives TimelineRange from it in src/types.ts so the type and tuple stay in lockstep. schemas.ts, mcp/server.ts, admin-management.ts, and the schema test now import from constants. admin-management's membership check switches to a Set keyed by TimelineRange so the only cast is the narrow one on the unknown input after typeof. OpenAPI output is byte-identical (spec hash unchanged). --- src/__tests__/unit/api-schemas.test.ts | 2 +- src/api/schemas.ts | 3 +-- src/constants.ts | 2 ++ src/mcp/server.ts | 2 +- src/services/admin-management.ts | 6 +++--- src/types.ts | 4 +++- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/__tests__/unit/api-schemas.test.ts b/src/__tests__/unit/api-schemas.test.ts index 0eb2089..86b4e50 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/schemas.ts b/src/api/schemas.ts index f1c81bb..8c8e2e9 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -3,6 +3,7 @@ import { z } from "@hono/zod-openapi"; import type { Hook } from "@hono/zod-openapi"; +import { TIMELINE_RANGES } from "../constants"; import { formatZodError } from "./response"; // ---- Common ---- @@ -12,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() diff --git a/src/constants.ts b/src/constants.ts index 130205f..359243f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,3 +3,5 @@ export const MIN_SLUG_LENGTH = 3; export const DEFAULT_SLUG_LENGTH = MIN_SLUG_LENGTH; + +export const TIMELINE_RANGES = ["24h", "7d", "30d", "90d", "1y", "all"] as const; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ec3e1b5..d28c1c8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -56,11 +56,11 @@ import { compareLinkStats, } from "../services/analytics"; import type { TimelineRange } from "../types"; +import { TIMELINE_RANGES } from "../constants"; import { renderQrSvg } from "../qr"; import { BUNDLE_ACCENTS, CustomSlugStringSchema, - TIMELINE_RANGES, } from "../api/schemas"; import pkg from "../../package.json"; diff --git a/src/services/admin-management.ts b/src/services/admin-management.ts index 6edbe61..07117aa 100644 --- a/src/services/admin-management.ts +++ b/src/services/admin-management.ts @@ -3,16 +3,16 @@ import { ApiKeyRepository, SettingRepository } from "../db"; import type { ApiKeyRow, ClickFilters } from "../db"; -import { DEFAULT_SLUG_LENGTH } from "../constants"; -import { TIMELINE_RANGES } from "../api/schemas"; +import { DEFAULT_SLUG_LENGTH, TIMELINE_RANGES } from "../constants"; import { validateSlugLength } from "../slugs"; import { Env, TimelineRange } from "../types"; import { ServiceResult, ok, fail } from "./result"; const DEFAULT_RANGE: TimelineRange = "30d"; +const VALID_RANGES = new Set(TIMELINE_RANGES); function isValidRange(v: unknown): v is TimelineRange { - return typeof v === "string" && (TIMELINE_RANGES as readonly string[]).includes(v); + return typeof v === "string" && VALID_RANGES.has(v as TimelineRange); } export type { ServiceResult }; diff --git a/src/types.ts b/src/types.ts index 110d7c8..82f7dbe 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 { 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; From 1740f291d5d2406fac9bd88bf5b17e0344f7ef37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 06:06:11 +0000 Subject: [PATCH 3/3] constants: export DEFAULT_TIMELINE_RANGE; types-only TIMELINE_RANGES import Two follow-ups from review on PR #7: types.ts: switch to `import type { TIMELINE_RANGES }` so deriving TimelineRange from the tuple does not emit a runtime require. The name is only referenced inside `(typeof X)[number]` so a type-only import is sufficient and avoids dragging the constants module into any consumer that only wants types. constants.ts: export DEFAULT_TIMELINE_RANGE = "30d", typed against (typeof TIMELINE_RANGES)[number] so the literal is checked against the tuple at compile time. admin-management drops its local DEFAULT_RANGE in favour of it; analytics.ts and bundles.ts replace their `?? "30d"` fallbacks with the same constant so the default is canonical instead of restated per file. OpenAPI output unchanged (spec hash stable). --- src/api/analytics.ts | 6 +++--- src/api/bundles.ts | 10 +++++----- src/constants.ts | 1 + src/services/admin-management.ts | 7 +++---- src/types.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/api/analytics.ts b/src/api/analytics.ts index f078987..400531b 100644 --- a/src/api/analytics.ts +++ b/src/api/analytics.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Env, TimelineRange } from "../types"; -import { TIMELINE_RANGES } from "../constants"; +import { DEFAULT_TIMELINE_RANGE, TIMELINE_RANGES } from "../constants"; import { getDashboardStats, getLinkAnalytics, @@ -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 2ffff23..1e585d7 100644 --- a/src/api/bundles.ts +++ b/src/api/bundles.ts @@ -35,10 +35,10 @@ import { UpdateBundleBodySchema, paramHook, } from "./schemas"; -import { TIMELINE_RANGES } from "../constants"; +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/constants.ts b/src/constants.ts index 7351a4d..8802845 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,6 +6,7 @@ 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; diff --git a/src/services/admin-management.ts b/src/services/admin-management.ts index 07117aa..f386061 100644 --- a/src/services/admin-management.ts +++ b/src/services/admin-management.ts @@ -3,12 +3,11 @@ import { ApiKeyRepository, SettingRepository } from "../db"; import type { ApiKeyRow, ClickFilters } from "../db"; -import { DEFAULT_SLUG_LENGTH, TIMELINE_RANGES } 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 DEFAULT_RANGE: TimelineRange = "30d"; const VALID_RANGES = new Set(TIMELINE_RANGES); function isValidRange(v: unknown): v is TimelineRange { @@ -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), }); @@ -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 82f7dbe..f7a1a63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ // Copyright 2026 Oddbit (https://oddbit.id) // SPDX-License-Identifier: Apache-2.0 -import { TIMELINE_RANGES } from "./constants"; +import type { TIMELINE_RANGES } from "./constants"; export interface Env { DB: D1Database;