diff --git a/src/actions/statistics.ts b/src/actions/statistics.ts index 6bf3677ea..a8f647207 100644 --- a/src/actions/statistics.ts +++ b/src/actions/statistics.ts @@ -24,6 +24,11 @@ import type { ActionResult } from "./types"; */ const createDataKey = (prefix: string, id: number): string => `${prefix}-${id}`; +function serializeChartBucketDate(value: string | Date): string { + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toISOString(); +} + /** * 获取用户统计数据,用于图表展示 */ @@ -99,16 +104,7 @@ export async function getUserStatistics( const dataByDate = new Map(); statsData.forEach((row) => { - // 根据分辨率格式化日期 - let dateStr: string; - if (rangeConfig.resolution === "hour") { - // 小时分辨率:显示为 "HH:mm" 格式 - const hour = new Date(row.date); - dateStr = hour.toISOString(); - } else { - // 天分辨率:显示为 "YYYY-MM-DD" 格式 - dateStr = new Date(row.date).toISOString().split("T")[0]; - } + const dateStr = serializeChartBucketDate(row.date); if (!dataByDate.has(dateStr)) { dataByDate.set(dateStr, { diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index b1cac1f48..9bedfcbeb 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -8,6 +8,11 @@ import { invalidateSystemSettingsCache } from "@/lib/config"; import { logger } from "@/lib/logger"; import { publishCurrentPublicStatusConfigProjection } from "@/lib/public-status/config-publisher"; import { schedulePublicStatusRebuild } from "@/lib/public-status/rebuild-hints"; +import { + invalidateAllLeaderboardCaches, + invalidateAllOverviewCaches, + invalidateAllStatisticsCaches, +} from "@/lib/redis"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; import { getSystemSettings, updateSystemSettings } from "@/repository/system-config"; @@ -151,6 +156,18 @@ export async function saveSystemSettings(formData: { ); invalidateProviderSelectorSystemSettingsCache(); + if (validated.timezone !== undefined) { + await Promise.all([ + invalidateAllOverviewCaches(), + invalidateAllStatisticsCaches(), + invalidateAllLeaderboardCaches(), + ]).catch((error) => { + logger.warn("[SystemSettings] Failed to invalidate timezone-sensitive dashboard caches", { + error, + }); + }); + } + const shouldRepublishPublicStatusProjection = validated.siteTitle !== undefined || validated.timezone !== undefined || diff --git a/src/app/[locale]/dashboard/leaderboard/_components/date-range-picker.tsx b/src/app/[locale]/dashboard/leaderboard/_components/date-range-picker.tsx index c043d4abd..f46e729bd 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/date-range-picker.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/date-range-picker.tsx @@ -1,8 +1,9 @@ "use client"; import { addDays, endOfMonth, endOfWeek, format, startOfMonth, startOfWeek } from "date-fns"; +import { formatInTimeZone, toZonedTime } from "date-fns-tz"; import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; import type { DateRange } from "react-day-picker"; import { Button } from "@/components/ui/button"; @@ -28,6 +29,10 @@ function formatDate(date: Date): string { return format(date, "yyyy-MM-dd"); } +function formatDateInSystemTimeZone(date: Date, timeZone: string): string { + return formatInTimeZone(date, timeZone, "yyyy-MM-dd"); +} + function parseDate(dateStr: string): Date { // Parse as local date to avoid timezone issues // new Date("YYYY-MM-DD") parses as UTC, which causes off-by-one errors in different timezones @@ -37,8 +42,10 @@ function parseDate(dateStr: string): Date { function getDateRangeForPeriod( period: QuickPeriod, - baseDate: Date = new Date() + timeZone: string, + now: Date = new Date() ): { startDate: string; endDate: string } { + const baseDate = toZonedTime(now, timeZone); switch (period) { case "daily": return { startDate: formatDate(baseDate), endDate: formatDate(baseDate) }; @@ -53,7 +60,7 @@ function getDateRangeForPeriod( return { startDate: formatDate(start), endDate: formatDate(end) }; } default: - return { startDate: "2020-01-01", endDate: formatDate(new Date()) }; + return { startDate: "2020-01-01", endDate: formatDateInSystemTimeZone(now, timeZone) }; } } @@ -74,17 +81,19 @@ function shiftDateRange( export function DateRangePicker({ period, dateRange, onPeriodChange }: DateRangePickerProps) { const t = useTranslations("dashboard.leaderboard"); + const timeZone = useTimeZone() ?? "UTC"; const [calendarOpen, setCalendarOpen] = useState(false); + const today = useMemo(() => formatDateInSystemTimeZone(new Date(), timeZone), [timeZone]); const currentRange = useMemo(() => { if (period === "custom" && dateRange) { return dateRange; } if (period !== "custom" && QUICK_PERIODS.includes(period as QuickPeriod)) { - return getDateRangeForPeriod(period as QuickPeriod); + return getDateRangeForPeriod(period as QuickPeriod, timeZone); } - return getDateRangeForPeriod("daily"); - }, [period, dateRange]); + return getDateRangeForPeriod("daily", timeZone); + }, [period, dateRange, timeZone]); const selectedRange: DateRange = useMemo(() => { return { @@ -198,7 +207,7 @@ export function DateRangePicker({ period, dateRange, onPeriodChange }: DateRange selected={selectedRange} onSelect={handleDateRangeSelect} numberOfMonths={2} - disabled={{ after: new Date() }} + disabled={{ after: parseDate(today) }} /> @@ -207,7 +216,7 @@ export function DateRangePicker({ period, dateRange, onPeriodChange }: DateRange variant="outline" size="icon-sm" onClick={() => handleNavigate("next")} - disabled={period === "allTime" || currentRange.endDate >= formatDate(new Date())} + disabled={period === "allTime" || currentRange.endDate >= today} title={t("dateRange.nextPeriod")} > diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts index e4e301405..e6ea9b9ab 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts @@ -1,3 +1,6 @@ +import { format, subDays } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; + export type TimeRangePreset = "today" | "7days" | "30days" | "thisMonth"; export interface UserInsightsFilters { @@ -17,33 +20,38 @@ export const DEFAULT_FILTERS: UserInsightsFilters = { export function resolveTimePresetDates(preset: TimeRangePreset): { startDate?: string; endDate?: string; +}; +export function resolveTimePresetDates( + preset: TimeRangePreset, + timeZone: string | undefined, + now?: Date +): { + startDate?: string; + endDate?: string; +}; +export function resolveTimePresetDates( + preset: TimeRangePreset, + timeZone?: string, + now: Date = new Date() +): { + startDate?: string; + endDate?: string; } { - const now = new Date(); - const yyyy = now.getFullYear(); - const mm = String(now.getMonth() + 1).padStart(2, "0"); - const dd = String(now.getDate()).padStart(2, "0"); - const today = `${yyyy}-${mm}-${dd}`; + const baseDate = timeZone ? toZonedTime(now, timeZone) : now; + const today = format(baseDate, "yyyy-MM-dd"); switch (preset) { case "today": return { startDate: today, endDate: today }; case "7days": { - const start = new Date(now); - start.setDate(start.getDate() - 6); - const sy = start.getFullYear(); - const sm = String(start.getMonth() + 1).padStart(2, "0"); - const sd = String(start.getDate()).padStart(2, "0"); - return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; + const start = subDays(baseDate, 6); + return { startDate: format(start, "yyyy-MM-dd"), endDate: today }; } case "30days": { - const start = new Date(now); - start.setDate(start.getDate() - 29); - const sy = start.getFullYear(); - const sm = String(start.getMonth() + 1).padStart(2, "0"); - const sd = String(start.getDate()).padStart(2, "0"); - return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; + const start = subDays(baseDate, 29); + return { startDate: format(start, "yyyy-MM-dd"), endDate: today }; } case "thisMonth": - return { startDate: `${yyyy}-${mm}-01`, endDate: today }; + return { startDate: format(baseDate, "yyyy-MM-01"), endDate: today }; } } diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx index 06ed8d215..53336aced 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx @@ -1,7 +1,7 @@ "use client"; import { ArrowLeft } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { useRouter } from "@/i18n/routing"; @@ -19,10 +19,11 @@ interface UserInsightsViewProps { export function UserInsightsView({ userId, userName }: UserInsightsViewProps) { const t = useTranslations("dashboard.leaderboard.userInsights"); + const timeZone = useTimeZone() ?? "UTC"; const router = useRouter(); const [filters, setFilters] = useState(DEFAULT_FILTERS); - const { startDate, endDate } = resolveTimePresetDates(filters.timeRange); + const { startDate, endDate } = resolveTimePresetDates(filters.timeRange, timeZone); return (
diff --git a/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx b/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx index fe1c73026..83b9375bc 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; import { X } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo } from "react"; @@ -15,6 +16,7 @@ interface ActiveFiltersDisplayProps { onClearAll: () => void; displayNames: FilterDisplayNames; isAdmin: boolean; + serverTimeZone?: string; className?: string; } @@ -30,6 +32,7 @@ export function ActiveFiltersDisplay({ onClearAll, displayNames, isAdmin, + serverTimeZone, className, }: ActiveFiltersDisplayProps) { const t = useTranslations("dashboard.logs.filters"); @@ -81,8 +84,12 @@ export function ActiveFiltersDisplay({ // Date range filter if (filters.startTime && filters.endTime) { - const startDate = format(new Date(filters.startTime), "MM/dd"); - const endDate = format(new Date(filters.endTime - 1000), "MM/dd"); + const startDate = serverTimeZone + ? formatInTimeZone(new Date(filters.startTime), serverTimeZone, "MM/dd") + : format(new Date(filters.startTime), "MM/dd"); + const endDate = serverTimeZone + ? formatInTimeZone(new Date(filters.endTime - 1000), serverTimeZone, "MM/dd") + : format(new Date(filters.endTime - 1000), "MM/dd"); result.push({ key: "startTime", label: t("dateRange"), @@ -133,7 +140,7 @@ export function ActiveFiltersDisplay({ } return result; - }, [filters, displayNames, isAdmin, t]); + }, [filters, displayNames, isAdmin, serverTimeZone, t]); if (activeFilters.length === 0) { return null; diff --git a/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx b/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx index 5bf10791c..dcb3b96e5 100644 --- a/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx +++ b/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx @@ -87,6 +87,10 @@ export function LogsDateRangePicker({ const [calendarOpen, setCalendarOpen] = useState(false); const hasDateRange = Boolean(startDate && endDate); + const today = useMemo( + () => getDateRangeForPeriod("today", serverTimeZone).endDate, + [serverTimeZone] + ); const activeQuickPeriod = useMemo(() => { return detectQuickPeriod(startDate, endDate, serverTimeZone); @@ -212,7 +216,7 @@ export function LogsDateRangePicker({ selected={selectedRange} onSelect={handleDateRangeSelect} numberOfMonths={2} - disabled={{ after: new Date() }} + disabled={{ after: parseDate(today) }} /> {hasDateRange && (
@@ -228,7 +232,7 @@ export function LogsDateRangePicker({ variant="outline" size="icon-sm" onClick={() => handleNavigate("next")} - disabled={!hasDateRange || (endDate !== undefined && endDate >= formatDate(new Date()))} + disabled={!hasDateRange || (endDate !== undefined && endDate >= today)} title={t("leaderboard.dateRange.nextPeriod")} > diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index dd515b41a..c08f57a4d 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -350,6 +350,7 @@ export function UsageLogsFilters({ onClearAll={handleReset} displayNames={displayNames} isAdmin={isAdmin} + serverTimeZone={serverTimeZone} /> {/* Filter Sections */} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 8485b0b7d..46f842c00 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -379,6 +379,7 @@ function UsageLogsViewContent({ autoRefreshEnabled={!isFullscreenOpen && isAutoRefresh} autoRefreshIntervalMs={logsRefreshIntervalMs ?? 5000} hiddenColumns={hiddenColumns} + serverTimeZone={serverTimeZone} />
@@ -469,6 +470,7 @@ function UsageLogsViewContent({ hideScrollToTop={true} hiddenColumns={hideProviderColumn ? ["provider"] : undefined} bodyClassName="h-[calc(var(--cch-viewport-height,100vh)_-_56px_-_32px_-_40px)]" + serverTimeZone={serverTimeZone} /> diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 3ac76edd7..d987e2b8f 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -124,7 +124,7 @@ export function VirtualizedLogsTable({ hideScrollToTop = false, hiddenColumns, bodyClassName, - serverTimeZone: _serverTimeZone, + serverTimeZone, fetchFn, queryKeyPrefix = "usage-logs-batch", disableDetailDialog = false, @@ -717,7 +717,12 @@ export function VirtualizedLogsTable({ > {/* Time */}
- +
{/* User */} diff --git a/src/app/[locale]/dashboard/logs/_utils/time-range.ts b/src/app/[locale]/dashboard/logs/_utils/time-range.ts index bee2bedcf..f430e5bdb 100644 --- a/src/app/[locale]/dashboard/logs/_utils/time-range.ts +++ b/src/app/[locale]/dashboard/logs/_utils/time-range.ts @@ -1,5 +1,5 @@ import { format, subDays } from "date-fns"; -import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; export interface ClockParts { hours: number; @@ -57,46 +57,41 @@ export function inclusiveEndTimestampFromExclusive(endExclusiveTimestamp: number export type QuickPeriod = "today" | "yesterday" | "last7days" | "last30days"; -function formatDateInTimeZone(date: Date, timeZone?: string): string { - if (timeZone) { - return formatInTimeZone(date, timeZone, "yyyy-MM-dd"); - } - return format(date, "yyyy-MM-dd"); -} - export function getQuickDateRange( period: QuickPeriod, timeZone?: string, now: Date = new Date() ): { startDate: string; endDate: string } { const baseDate = timeZone ? toZonedTime(now, timeZone) : now; + const formatDate = (date: Date) => format(date, "yyyy-MM-dd"); + switch (period) { case "today": return { - startDate: formatDateInTimeZone(baseDate, timeZone), - endDate: formatDateInTimeZone(baseDate, timeZone), + startDate: formatDate(baseDate), + endDate: formatDate(baseDate), }; case "yesterday": { const yesterday = subDays(baseDate, 1); return { - startDate: formatDateInTimeZone(yesterday, timeZone), - endDate: formatDateInTimeZone(yesterday, timeZone), + startDate: formatDate(yesterday), + endDate: formatDate(yesterday), }; } case "last7days": return { - startDate: formatDateInTimeZone(subDays(baseDate, 6), timeZone), - endDate: formatDateInTimeZone(baseDate, timeZone), + startDate: formatDate(subDays(baseDate, 6)), + endDate: formatDate(baseDate), }; case "last30days": return { - startDate: formatDateInTimeZone(subDays(baseDate, 29), timeZone), - endDate: formatDateInTimeZone(baseDate, timeZone), + startDate: formatDate(subDays(baseDate, 29)), + endDate: formatDate(baseDate), }; default: return { - startDate: formatDateInTimeZone(baseDate, timeZone), - endDate: formatDateInTimeZone(baseDate, timeZone), + startDate: formatDate(baseDate), + endDate: formatDate(baseDate), }; } } diff --git a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx index f9999ccc3..cb9a18213 100644 --- a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx +++ b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx @@ -1,8 +1,8 @@ "use client"; -import { format } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; import { BarChart3, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import { ModelBreakdownColumn } from "@/components/analytics/model-breakdown-column"; import { Button } from "@/components/ui/button"; @@ -20,20 +20,28 @@ interface StatisticsSummaryCardProps { serverTimeZone?: string; } +function getDefaultDateRange(timeZone: string): { startDate: string; endDate: string } { + const today = formatInTimeZone(new Date(), timeZone, "yyyy-MM-dd"); + return { startDate: today, endDate: today }; +} + export function StatisticsSummaryCard({ className, autoRefreshSeconds = 30, serverTimeZone, }: StatisticsSummaryCardProps) { const t = useTranslations("myUsage.stats"); + const providerTimeZone = useTimeZone() ?? "UTC"; + const effectiveTimeZone = serverTimeZone ?? providerTimeZone; const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [dateRange, setDateRange] = useState<{ startDate?: string; endDate?: string }>(() => { - const today = format(new Date(), "yyyy-MM-dd"); - return { startDate: today, endDate: today }; - }); + const [dateRange, setDateRange] = useState<{ startDate?: string; endDate?: string }>(() => + getDefaultDateRange(effectiveTimeZone) + ); const intervalRef = useRef(null); + const autoDateRangeRef = useRef(true); + const previousTimeZoneRef = useRef(effectiveTimeZone); const loadStats = useCallback(async () => { const result = await getMyStatsSummary({ @@ -51,6 +59,13 @@ export function StatisticsSummaryCard({ loadStats().finally(() => setLoading(false)); }, [loadStats]); + useEffect(() => { + if (previousTimeZoneRef.current === effectiveTimeZone) return; + previousTimeZoneRef.current = effectiveTimeZone; + if (!autoDateRangeRef.current) return; + setDateRange(getDefaultDateRange(effectiveTimeZone)); + }, [effectiveTimeZone]); + // Auto-refresh with visibility change handling useEffect(() => { const POLL_INTERVAL = autoRefreshSeconds * 1000; @@ -96,6 +111,7 @@ export function StatisticsSummaryCard({ }, [loadStats]); const handleDateRangeChange = useCallback((range: { startDate?: string; endDate?: string }) => { + autoDateRangeRef.current = false; setDateRange(range); }, []); diff --git a/src/app/[locale]/my-usage/_components/usage-logs-section.tsx b/src/app/[locale]/my-usage/_components/usage-logs-section.tsx index 66a854518..99db76b61 100644 --- a/src/app/[locale]/my-usage/_components/usage-logs-section.tsx +++ b/src/app/[locale]/my-usage/_components/usage-logs-section.tsx @@ -341,6 +341,7 @@ export function UsageLogsSection({ ipLookupMode="my-usage" autoRefreshEnabled={!!autoRefreshSeconds} autoRefreshIntervalMs={autoRefreshSeconds ? autoRefreshSeconds * 1000 : undefined} + serverTimeZone={serverTimeZone} /> diff --git a/src/app/[locale]/my-usage/_components/usage-logs-table.tsx b/src/app/[locale]/my-usage/_components/usage-logs-table.tsx index a45ea6170..be66255c3 100644 --- a/src/app/[locale]/my-usage/_components/usage-logs-table.tsx +++ b/src/app/[locale]/my-usage/_components/usage-logs-table.tsx @@ -28,6 +28,7 @@ interface UsageLogsTableProps { onLoadMore?: () => void; resetScrollKey?: unknown; onHistoryBrowsingChange?: (isBrowsingHistory: boolean) => void; + serverTimeZone?: string; } export function UsageLogsTable({ @@ -41,11 +42,13 @@ export function UsageLogsTable({ onLoadMore, resetScrollKey, onHistoryBrowsingChange, + serverTimeZone, }: UsageLogsTableProps) { const t = useTranslations("myUsage.logs"); const tCommon = useTranslations("common"); const tDashboard = useTranslations("dashboard"); - const timeZone = useTimeZone() ?? "UTC"; + const providerTimeZone = useTimeZone() ?? "UTC"; + const timeZone = serverTimeZone ?? providerTimeZone; const resolvedResetKey = useMemo(() => JSON.stringify(resetScrollKey ?? null), [resetScrollKey]); const previousResetKeyRef = useRef(resolvedResetKey); diff --git a/src/app/api/admin/system-config/route.ts b/src/app/api/admin/system-config/route.ts index 93cc54a68..5d06b6e2a 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -2,6 +2,11 @@ import { z } from "zod"; import { getSession } from "@/lib/auth"; import { invalidateSystemSettingsCache } from "@/lib/config"; import { logger } from "@/lib/logger"; +import { + invalidateAllLeaderboardCaches, + invalidateAllOverviewCaches, + invalidateAllStatisticsCaches, +} from "@/lib/redis"; import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; import { getSystemSettings, updateSystemSettings } from "@/repository/system-config"; @@ -99,6 +104,17 @@ export async function POST(req: Request) { "@/app/v1/_lib/proxy/provider-selector-settings-cache" ); invalidateProviderSelectorSystemSettingsCache(); + if (validated.timezone !== undefined) { + await Promise.all([ + invalidateAllOverviewCaches(), + invalidateAllStatisticsCaches(), + invalidateAllLeaderboardCaches(), + ]).catch((error) => { + logger.warn("[SystemSettings] Failed to invalidate timezone-sensitive dashboard caches", { + error, + }); + }); + } return Response.json(updated); } catch (error) { diff --git a/src/components/ui/relative-time.tsx b/src/components/ui/relative-time.tsx index 207116c55..aa76d03b0 100644 --- a/src/components/ui/relative-time.tsx +++ b/src/components/ui/relative-time.tsx @@ -14,6 +14,7 @@ interface RelativeTimeProps { autoUpdate?: boolean; updateInterval?: number; format?: "full" | "short"; + timeZone?: string; } /** @@ -32,11 +33,13 @@ export function RelativeTime({ autoUpdate = true, updateInterval = 10000, // 默认每 10 秒更新 format = "full", + timeZone: timeZoneOverride, }: RelativeTimeProps) { const [timeAgo, setTimeAgo] = useState(fallback); const [mounted, setMounted] = useState(false); const locale = useLocale(); - const timeZone = useTimeZone() ?? "UTC"; + const providerTimeZone = useTimeZone(); + const timeZone = timeZoneOverride ?? providerTimeZone ?? "UTC"; const tShort = useTranslations("common.relativeTimeShort"); // Format short distance with i18n diff --git a/src/lib/redis/index.ts b/src/lib/redis/index.ts index 1ab0b9213..7b249ac8c 100644 --- a/src/lib/redis/index.ts +++ b/src/lib/redis/index.ts @@ -1,8 +1,20 @@ import "server-only"; export { closeRedis, getRedisClient } from "./client"; -export { getLeaderboardWithCache, invalidateLeaderboardCache } from "./leaderboard-cache"; -export { getOverviewWithCache, invalidateOverviewCache } from "./overview-cache"; +export { + getLeaderboardWithCache, + invalidateAllLeaderboardCaches, + invalidateLeaderboardCache, +} from "./leaderboard-cache"; +export { + getOverviewWithCache, + invalidateAllOverviewCaches, + invalidateOverviewCache, +} from "./overview-cache"; export { scanPattern } from "./scan-helper"; export { getActiveConcurrentSessions } from "./session-stats"; -export { getStatisticsWithCache, invalidateStatisticsCache } from "./statistics-cache"; +export { + getStatisticsWithCache, + invalidateAllStatisticsCaches, + invalidateStatisticsCache, +} from "./statistics-cache"; diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 214dccb96..05c4c06c8 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -38,6 +38,7 @@ import { } from "@/repository/leaderboard"; import type { ProviderType } from "@/types/provider"; import { getRedisClient } from "./client"; +import { scanPattern } from "./scan-helper"; export type { DateRangeParams, LeaderboardPeriod }; export type LeaderboardScope = @@ -95,22 +96,22 @@ function buildCacheKey( if (period === "custom" && dateRange) { // leaderboard:{scope}:custom:2025-01-01_2025-01-15:USD - return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "daily") { // leaderboard:{scope}:daily:2025-01-15:USD const dateStr = formatInTimeZone(now, timezone, "yyyy-MM-dd"); - return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:daily:${dateStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "weekly") { // leaderboard:{scope}:weekly:2025-W03:USD (ISO week) const weekStr = formatInTimeZone(now, timezone, "yyyy-'W'ww"); - return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:weekly:${weekStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "monthly") { // leaderboard:{scope}:monthly:2025-01:USD const monthStr = formatInTimeZone(now, timezone, "yyyy-MM"); - return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:monthly:${monthStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else { // allTime: leaderboard:{scope}:allTime:USD (no date component) - return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:allTime:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } } @@ -391,3 +392,20 @@ export async function invalidateLeaderboardCache( logger.error("[LeaderboardCache] Failed to invalidate cache", { period, scope, error }); } } + +export async function invalidateAllLeaderboardCaches(): Promise { + const redis = getRedisClient(); + if (!redis) { + return; + } + + try { + const keys = await scanPattern(redis, "leaderboard:*"); + if (keys.length > 0) { + await redis.del(...keys); + } + logger.info("[LeaderboardCache] All caches invalidated", { deletedCount: keys.length }); + } catch (error) { + logger.error("[LeaderboardCache] Failed to invalidate all caches", { error }); + } +} diff --git a/src/lib/redis/overview-cache.ts b/src/lib/redis/overview-cache.ts index 2226284e7..0720a7caa 100644 --- a/src/lib/redis/overview-cache.ts +++ b/src/lib/redis/overview-cache.ts @@ -1,16 +1,21 @@ import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { getOverviewMetricsWithComparison, type OverviewMetricsWithComparison, } from "@/repository/overview"; +import { buildOverviewCacheKey } from "@/types/dashboard-cache"; import { getRedisClient } from "./client"; +import { scanPattern } from "./scan-helper"; const CACHE_TTL = 10; const LOCK_TTL = 5; const LOCK_WAIT_MS = 100; -function buildCacheKey(userId?: number): string { - return userId !== undefined ? `overview:user:${userId}` : "overview:global"; +function buildCacheKey(userId: number | undefined, timezone: string): string { + return userId !== undefined + ? buildOverviewCacheKey("user", userId, timezone) + : buildOverviewCacheKey("global", timezone); } /** @@ -22,7 +27,8 @@ export async function getOverviewWithCache( userId?: number ): Promise { const redis = getRedisClient(); - const cacheKey = buildCacheKey(userId); + const timezone = await resolveSystemTimezone(); + const cacheKey = buildCacheKey(userId, timezone); const lockKey = `${cacheKey}:lock`; if (!redis) { @@ -84,11 +90,29 @@ export async function invalidateOverviewCache(userId?: number): Promise { const redis = getRedisClient(); if (!redis) return; - const cacheKey = buildCacheKey(userId); + const scopePattern = userId !== undefined ? `overview:user:${userId}` : "overview:global"; + const pattern = `${scopePattern}:tz:*`; try { - await redis.del(cacheKey); - logger.info("[OverviewCache] Cache invalidated", { userId, cacheKey }); + const matchedKeys = await scanPattern(redis, pattern); + const keysToDelete = [...matchedKeys, scopePattern]; + await redis.del(...keysToDelete); + logger.info("[OverviewCache] Cache invalidated", { userId, keysToDelete }); } catch (error) { logger.error("[OverviewCache] Failed to invalidate cache", { userId, error }); } } + +export async function invalidateAllOverviewCaches(): Promise { + const redis = getRedisClient(); + if (!redis) return; + + try { + const keys = await scanPattern(redis, "overview:*"); + if (keys.length > 0) { + await redis.del(...keys); + } + logger.info("[OverviewCache] All caches invalidated", { deletedCount: keys.length }); + } catch (error) { + logger.error("[OverviewCache] Failed to invalidate all caches", { error }); + } +} diff --git a/src/lib/redis/statistics-cache.ts b/src/lib/redis/statistics-cache.ts index ab58810cc..c7e069fa3 100644 --- a/src/lib/redis/statistics-cache.ts +++ b/src/lib/redis/statistics-cache.ts @@ -1,4 +1,5 @@ import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { getKeyStatisticsFromDB, getMixedStatisticsFromDB, @@ -26,6 +27,7 @@ function sleep(ms: number): Promise { async function queryDatabase( timeRange: TimeRange, mode: "users" | "keys" | "mixed", + timezone: string, userId?: number ): Promise { if ((mode === "keys" || mode === "mixed") && userId === undefined) { @@ -33,11 +35,11 @@ async function queryDatabase( } switch (mode) { case "users": - return await getUserStatisticsFromDB(timeRange); + return await getUserStatisticsFromDB(timeRange, timezone); case "keys": - return await getKeyStatisticsFromDB(userId!, timeRange); + return await getKeyStatisticsFromDB(userId!, timeRange, timezone); case "mixed": - return await getMixedStatisticsFromDB(userId!, timeRange); + return await getMixedStatisticsFromDB(userId!, timeRange, timezone); } } @@ -56,6 +58,7 @@ export async function getStatisticsWithCache( userId?: number ): Promise { const redis = getRedisClient(); + const timezone = await resolveSystemTimezone(); if (!redis) { logger.warn("[StatisticsCache] Redis not available, fallback to direct query", { @@ -63,10 +66,10 @@ export async function getStatisticsWithCache( mode, userId, }); - return await queryDatabase(timeRange, mode, userId); + return await queryDatabase(timeRange, mode, timezone, userId); } - const cacheKey = buildStatisticsCacheKey(timeRange, mode, userId); + const cacheKey = buildStatisticsCacheKey(timeRange, mode, userId, timezone); const lockKey = `${cacheKey}:lock`; let locked = false; @@ -87,7 +90,7 @@ export async function getStatisticsWithCache( if (locked) { logger.debug("[StatisticsCache] Acquired lock, computing", { timeRange, mode, lockKey }); - data = await queryDatabase(timeRange, mode, userId); + data = await queryDatabase(timeRange, mode, timezone, userId); try { await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data)); @@ -125,14 +128,14 @@ export async function getStatisticsWithCache( // Retry timeout - fallback to direct DB logger.warn("[StatisticsCache] Retry timeout, fallback to direct query", { timeRange, mode }); - return await queryDatabase(timeRange, mode, userId); + return await queryDatabase(timeRange, mode, timezone, userId); } catch (error) { logger.error("[StatisticsCache] Redis error, fallback to direct query", { timeRange, mode, error, }); - return data ?? (await queryDatabase(timeRange, mode, userId)); + return data ?? (await queryDatabase(timeRange, mode, timezone, userId)); } finally { if (locked) { await redis @@ -164,22 +167,47 @@ export async function invalidateStatisticsCache( try { if (timeRange) { const modes = ["users", "keys", "mixed"] as const; - const keysToDelete = modes.map((m) => buildStatisticsCacheKey(timeRange, m, userId)); - await redis.del(...keysToDelete); + const pattern = `statistics:${timeRange}:*:${scope}:tz:*`; + const legacyKeysToDelete = modes.map((m) => `statistics:${timeRange}:${m}:${scope}`); + const matchedKeys = await scanPattern(redis, pattern); + const keysToDelete = [...matchedKeys, ...legacyKeysToDelete]; + if (keysToDelete.length > 0) { + await redis.del(...keysToDelete); + } logger.info("[StatisticsCache] Cache invalidated", { timeRange, scope, keysToDelete }); } else { - const pattern = `statistics:*:*:${scope}`; + const pattern = `statistics:*:*:${scope}:tz:*`; + const legacyPattern = `statistics:*:*:${scope}`; const matchedKeys = await scanPattern(redis, pattern); - if (matchedKeys.length > 0) { - await redis.del(...matchedKeys); + const legacyMatchedKeys = await scanPattern(redis, legacyPattern); + const keysToDelete = [...new Set([...matchedKeys, ...legacyMatchedKeys])]; + if (keysToDelete.length > 0) { + await redis.del(...keysToDelete); } logger.info("[StatisticsCache] Cache invalidated (all timeRanges)", { scope, pattern, - deletedCount: matchedKeys.length, + deletedCount: keysToDelete.length, }); } } catch (error) { logger.error("[StatisticsCache] Failed to invalidate cache", { timeRange, scope, error }); } } + +export async function invalidateAllStatisticsCaches(): Promise { + const redis = getRedisClient(); + if (!redis) { + return; + } + + try { + const keys = await scanPattern(redis, "statistics:*"); + if (keys.length > 0) { + await redis.del(...keys); + } + logger.info("[StatisticsCache] All caches invalidated", { deletedCount: keys.length }); + } catch (error) { + logger.error("[StatisticsCache] Failed to invalidate all caches", { error }); + } +} diff --git a/src/repository/admin-user-insights.ts b/src/repository/admin-user-insights.ts index fcb9d70e0..e01d4a649 100644 --- a/src/repository/admin-user-insights.ts +++ b/src/repository/admin-user-insights.ts @@ -4,6 +4,7 @@ import { and, avg, count, desc, eq, gte, lt, sql, sum } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { providers, usageLedger } from "@/drizzle/schema"; import { Decimal, toCostDecimal } from "@/lib/utils/currency"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { getSystemSettings } from "./system-config"; @@ -39,6 +40,26 @@ export interface AdminUserProviderBreakdownItem { cacheReadTokens: number; } +async function buildSystemTimezoneDateConditions(startDate?: string, endDate?: string) { + const timezone = await resolveSystemTimezone(); + const conditions = []; + + if (startDate) { + conditions.push(gte(usageLedger.createdAt, sql`(${startDate}::date AT TIME ZONE ${timezone})`)); + } + + if (endDate) { + conditions.push( + lt( + usageLedger.createdAt, + sql`((${endDate}::date + INTERVAL '1 day') AT TIME ZONE ${timezone})` + ) + ); + } + + return conditions; +} + /** * Get overview metrics for a specific user within a date range. */ @@ -47,15 +68,11 @@ export async function getUserOverviewMetrics( startDate?: string, endDate?: string ): Promise { - const conditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.userId, userId)]; - - if (startDate) { - conditions.push(gte(usageLedger.createdAt, sql`${startDate}::date`)); - } - - if (endDate) { - conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); - } + const conditions = [ + LEDGER_BILLING_CONDITION, + eq(usageLedger.userId, userId), + ...(await buildSystemTimezoneDateConditions(startDate, endDate)), + ]; const [result] = await db .select({ @@ -102,15 +119,11 @@ export async function getUserModelBreakdown( : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; - const conditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.userId, userId)]; - - if (startDate) { - conditions.push(gte(usageLedger.createdAt, sql`${startDate}::date`)); - } - - if (endDate) { - conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); - } + const conditions = [ + LEDGER_BILLING_CONDITION, + eq(usageLedger.userId, userId), + ...(await buildSystemTimezoneDateConditions(startDate, endDate)), + ]; if (filters?.keyId) { conditions.push( @@ -150,15 +163,11 @@ export async function getUserProviderBreakdown( endDate?: string, filters?: { keyId?: number; model?: string } ): Promise { - const conditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.userId, userId)]; - - if (startDate) { - conditions.push(gte(usageLedger.createdAt, sql`${startDate}::date`)); - } - - if (endDate) { - conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); - } + const conditions = [ + LEDGER_BILLING_CONDITION, + eq(usageLedger.userId, userId), + ...(await buildSystemTimezoneDateConditions(startDate, endDate)), + ]; if (filters?.keyId) { conditions.push( diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 3ac460277..22273404a 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -1,5 +1,6 @@ import "server-only"; +import { fromZonedTime } from "date-fns-tz"; import type { SQL } from "drizzle-orm"; import { and, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; @@ -137,9 +138,31 @@ function getTimeRangeSqlConfig(timeRange: TimeRange, timezone: string): SqlTimeR } } -function normalizeBucketDate(value: TimeBucketValue): Date | null { +function formatLocalDateTime(value: Date): string { + const year = value.getFullYear(); + const month = `${value.getMonth() + 1}`.padStart(2, "0"); + const day = `${value.getDate()}`.padStart(2, "0"); + const hours = `${value.getHours()}`.padStart(2, "0"); + const minutes = `${value.getMinutes()}`.padStart(2, "0"); + const seconds = `${value.getSeconds()}`.padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; +} + +function normalizeBucketInstant(value: TimeBucketValue, timezone: string): Date | null { if (!value) return null; - const parsed = value instanceof Date ? new Date(value.getTime()) : new Date(value); + + let localDateTime: string; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null; + localDateTime = formatLocalDateTime(value); + } else { + const match = + /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/.exec(value); + if (!match) return null; + localDateTime = `${match[1]}T${match[2]}`; + } + + const parsed = fromZonedTime(localDateTime, timezone); return Number.isNaN(parsed.getTime()) ? null : parsed; } @@ -158,7 +181,7 @@ async function getTimeBuckets(timeRange: TimeRange, timezone: string): Promise) - .map((row) => normalizeBucketDate(row.bucket)) + .map((row) => normalizeBucketInstant(row.bucket, timezone)) .filter((bucket): bucket is Date => bucket !== null) .sort((a, b) => a.getTime() - b.getTime()); } @@ -166,11 +189,12 @@ async function getTimeBuckets(timeRange: TimeRange, timezone: string): Promise, - buckets: Date[] + buckets: Date[], + timezone: string ): RuntimeDatabaseStatRow[] { const rowMap = new Map(); for (const row of dbRows) { - const bucket = normalizeBucketDate(row.bucket); + const bucket = normalizeBucketInstant(row.bucket, timezone); if (!bucket) continue; rowMap.set(`${row.user_id}:${bucket.getTime()}`, { @@ -202,11 +226,12 @@ function zeroFillUserStats( function zeroFillKeyStats( dbRows: KeyBucketStatsRow[], allKeys: Array<{ id: number; name: string }>, - buckets: Date[] + buckets: Date[], + timezone: string ): RuntimeDatabaseKeyStatRow[] { const rowMap = new Map(); for (const row of dbRows) { - const bucket = normalizeBucketDate(row.bucket); + const bucket = normalizeBucketInstant(row.bucket, timezone); if (!bucket) continue; rowMap.set(`${row.key_id}:${bucket.getTime()}`, { @@ -237,11 +262,12 @@ function zeroFillKeyStats( function zeroFillMixedOthersStats( dbRows: MixedOthersBucketStatsRow[], - buckets: Date[] + buckets: Date[], + timezone: string ): RuntimeDatabaseStatRow[] { const rowMap = new Map(); for (const row of dbRows) { - const bucket = normalizeBucketDate(row.bucket); + const bucket = normalizeBucketInstant(row.bucket, timezone); if (!bucket) continue; rowMap.set(bucket.getTime(), { @@ -265,8 +291,11 @@ function zeroFillMixedOthersStats( /** * 根据时间范围获取用户消费和API调用统计 */ -export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise { - const timezone = await resolveSystemTimezone(); +export async function getUserStatisticsFromDB( + timeRange: TimeRange, + timezoneOverride?: string +): Promise { + const timezone = timezoneOverride ?? (await resolveSystemTimezone()); const { startTs, endTs, bucketExpr } = getTimeRangeSqlConfig(timeRange, timezone); const statsQuery = sql` @@ -293,7 +322,7 @@ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise { */ export async function getKeyStatisticsFromDB( userId: number, - timeRange: TimeRange + timeRange: TimeRange, + timezoneOverride?: string ): Promise { - const timezone = await resolveSystemTimezone(); + const timezone = timezoneOverride ?? (await resolveSystemTimezone()); const { startTs, endTs, bucketExpr } = getTimeRangeSqlConfig(timeRange, timezone); const statsQuery = sql` @@ -347,7 +377,7 @@ export async function getKeyStatisticsFromDB( ]); const rows = Array.from(statsResult) as KeyBucketStatsRow[]; - return zeroFillKeyStats(rows, activeKeys, buckets) as unknown as DatabaseKeyStatRow[]; + return zeroFillKeyStats(rows, activeKeys, buckets, timezone) as unknown as DatabaseKeyStatRow[]; } /** @@ -372,12 +402,13 @@ export async function getActiveKeysForUserFromDB(userId: number): Promise { - const timezone = await resolveSystemTimezone(); + const timezone = timezoneOverride ?? (await resolveSystemTimezone()); const { startTs, endTs, bucketExpr } = getTimeRangeSqlConfig(timeRange, timezone); const ownKeysQuery = sql` @@ -424,11 +455,13 @@ export async function getMixedStatisticsFromDB( ownKeys: zeroFillKeyStats( Array.from(ownKeysResult) as KeyBucketStatsRow[], activeKeys, - buckets + buckets, + timezone ) as unknown as DatabaseKeyStatRow[], othersAggregate: zeroFillMixedOthersStats( Array.from(othersResult) as MixedOthersBucketStatsRow[], - buckets + buckets, + timezone ) as unknown as DatabaseStatRow[], }; } diff --git a/src/types/dashboard-cache.ts b/src/types/dashboard-cache.ts index 07731af33..bf6da8642 100644 --- a/src/types/dashboard-cache.ts +++ b/src/types/dashboard-cache.ts @@ -3,24 +3,48 @@ import type { TimeRange } from "@/types/statistics"; export type OverviewCacheKey = { scope: "global" | "user"; userId?: number; + timezone: string; }; export type StatisticsCacheKey = { timeRange: TimeRange; mode: "users" | "keys" | "mixed"; userId?: number; + timezone: string; }; -export function buildOverviewCacheKey(scope: "global"): string; -export function buildOverviewCacheKey(scope: "user", userId: number): string; -export function buildOverviewCacheKey(scope: "global" | "user", userId?: number): string { - return scope === "global" ? "overview:global" : `overview:user:${userId}`; +export function buildOverviewCacheKey(scope: "global", timezone: string): string; +export function buildOverviewCacheKey(scope: "user", userId: number, timezone: string): string; +export function buildOverviewCacheKey( + scope: "global" | "user", + userIdOrTimezone: number | string, + timezone?: string +): string { + const resolvedTimezone = scope === "global" ? String(userIdOrTimezone) : String(timezone); + return scope === "global" + ? `overview:global:tz:${resolvedTimezone}` + : `overview:user:${userIdOrTimezone}:tz:${resolvedTimezone}`; } export function buildStatisticsCacheKey( timeRange: TimeRange, mode: "users" | "keys" | "mixed", - userId?: number + timezone: string +): string; +export function buildStatisticsCacheKey( + timeRange: TimeRange, + mode: "users" | "keys" | "mixed", + userId: number | undefined, + timezone: string +): string; +export function buildStatisticsCacheKey( + timeRange: TimeRange, + mode: "users" | "keys" | "mixed", + userIdOrTimezone: number | string | undefined, + timezone?: string ): string { - return `statistics:${timeRange}:${mode}:${userId ?? "global"}`; + const userId = typeof userIdOrTimezone === "number" ? userIdOrTimezone : undefined; + const resolvedTimezone = + typeof userIdOrTimezone === "string" ? userIdOrTimezone : String(timezone); + return `statistics:${timeRange}:${mode}:${userId ?? "global"}:tz:${resolvedTimezone}`; } diff --git a/tests/unit/dashboard-logs-time-range-utils.test.ts b/tests/unit/dashboard-logs-time-range-utils.test.ts index bceb2547e..33ae17a60 100644 --- a/tests/unit/dashboard-logs-time-range-utils.test.ts +++ b/tests/unit/dashboard-logs-time-range-utils.test.ts @@ -58,4 +58,14 @@ describe("dashboard logs time range utils", () => { endDate: "2023-12-31", }); }); + + test("getQuickDateRange keeps the first hours of the server day in that day", () => { + const now = new Date("2024-01-02T08:30:00Z"); + const tz = "America/Los_Angeles"; + + expect(getQuickDateRange("today", tz, now)).toEqual({ + startDate: "2024-01-02", + endDate: "2024-01-02", + }); + }); }); diff --git a/tests/unit/dashboard/dashboard-cache-keys.test.ts b/tests/unit/dashboard/dashboard-cache-keys.test.ts index 24cc38f1a..7a0cb6d7e 100644 --- a/tests/unit/dashboard/dashboard-cache-keys.test.ts +++ b/tests/unit/dashboard/dashboard-cache-keys.test.ts @@ -3,33 +3,43 @@ import { buildOverviewCacheKey, buildStatisticsCacheKey } from "@/types/dashboar import type { TimeRange } from "@/types/statistics"; describe("buildOverviewCacheKey", () => { - it("returns 'overview:global' for global scope", () => { - expect(buildOverviewCacheKey("global")).toBe("overview:global"); + it("returns timezone-scoped global key", () => { + expect(buildOverviewCacheKey("global", "Asia/Shanghai")).toBe( + "overview:global:tz:Asia/Shanghai" + ); }); - it("returns 'overview:user:42' for user scope with userId=42", () => { - expect(buildOverviewCacheKey("user", 42)).toBe("overview:user:42"); + it("returns timezone-scoped user key", () => { + expect(buildOverviewCacheKey("user", 42, "America/New_York")).toBe( + "overview:user:42:tz:America/New_York" + ); }); }); describe("buildStatisticsCacheKey", () => { it("returns correct key for today/users/global", () => { - expect(buildStatisticsCacheKey("today", "users")).toBe("statistics:today:users:global"); + expect(buildStatisticsCacheKey("today", "users", "Asia/Shanghai")).toBe( + "statistics:today:users:global:tz:Asia/Shanghai" + ); }); it("returns correct key with userId", () => { - expect(buildStatisticsCacheKey("7days", "keys", 42)).toBe("statistics:7days:keys:42"); + expect(buildStatisticsCacheKey("7days", "keys", 42, "America/New_York")).toBe( + "statistics:7days:keys:42:tz:America/New_York" + ); }); it("handles all TimeRange values", () => { const timeRanges: TimeRange[] = ["today", "7days", "30days", "thisMonth"]; - const keys = timeRanges.map((timeRange) => buildStatisticsCacheKey(timeRange, "users")); + const keys = timeRanges.map((timeRange) => + buildStatisticsCacheKey(timeRange, "users", "Asia/Shanghai") + ); expect(keys).toEqual([ - "statistics:today:users:global", - "statistics:7days:users:global", - "statistics:30days:users:global", - "statistics:thisMonth:users:global", + "statistics:today:users:global:tz:Asia/Shanghai", + "statistics:7days:users:global:tz:Asia/Shanghai", + "statistics:30days:users:global:tz:Asia/Shanghai", + "statistics:thisMonth:users:global:tz:Asia/Shanghai", ]); expect(new Set(keys).size).toBe(timeRanges.length); }); diff --git a/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx index 90d9f72dc..f79d4ede8 100644 --- a/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx @@ -22,6 +22,7 @@ vi.mock("next/navigation", () => ({ vi.mock("next-intl", () => ({ useTranslations: () => tMock, + useTimeZone: () => "Asia/Shanghai", })); vi.mock("@/actions/users", () => ({ diff --git a/tests/unit/redis/leaderboard-cache.test.ts b/tests/unit/redis/leaderboard-cache.test.ts index 32c575ca9..0ff0a460c 100644 --- a/tests/unit/redis/leaderboard-cache.test.ts +++ b/tests/unit/redis/leaderboard-cache.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRedisClient } from "@/lib/redis/client"; import { getLeaderboardWithCache } from "@/lib/redis/leaderboard-cache"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { findDailyUserCacheHitRateLeaderboard, type UserCacheHitRateLeaderboardEntry, @@ -69,6 +70,7 @@ function createUserCacheHitRateRows(): UserCacheHitRateLeaderboardEntry[] { describe("getLeaderboardWithCache", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveSystemTimezone).mockResolvedValue("UTC"); vi.useRealTimers(); }); @@ -100,7 +102,33 @@ describe("getLeaderboardWithCache", () => { true ); expect(redis.setex).toHaveBeenCalledWith( - "leaderboard:userCacheHitRate:daily:2026-04-13:USD:includeModelStats:tags:team-a,vip:groups:group-1", + "leaderboard:userCacheHitRate:daily:2026-04-13:tz:UTC:USD:includeModelStats:tags:team-a,vip:groups:group-1", + 60, + JSON.stringify(rows) + ); + }); + + it("includes the resolved timezone in Redis keys", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-13T23:00:00Z")); + vi.mocked(resolveSystemTimezone).mockResolvedValueOnce("Asia/Shanghai"); + + const redis = createRedisMock(); + const rows = createUserCacheHitRateRows(); + redis.get.mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce("OK"); + redis.setex.mockResolvedValueOnce("OK"); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(findDailyUserCacheHitRateLeaderboard).mockResolvedValueOnce(rows); + + await getLeaderboardWithCache("daily", "USD", "userCacheHitRate"); + + expect(redis.setex).toHaveBeenCalledWith( + "leaderboard:userCacheHitRate:daily:2026-04-14:tz:Asia/Shanghai:USD", 60, JSON.stringify(rows) ); diff --git a/tests/unit/redis/overview-cache.test.ts b/tests/unit/redis/overview-cache.test.ts index 4ac114b86..60d8db2f1 100644 --- a/tests/unit/redis/overview-cache.test.ts +++ b/tests/unit/redis/overview-cache.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRedisClient } from "@/lib/redis/client"; import { getOverviewWithCache, invalidateOverviewCache } from "@/lib/redis/overview-cache"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { getOverviewMetricsWithComparison, type OverviewMetricsWithComparison, @@ -19,6 +20,10 @@ vi.mock("@/lib/redis/client", () => ({ getRedisClient: vi.fn(), })); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn().mockResolvedValue("UTC"), +})); + vi.mock("@/repository/overview", () => ({ getOverviewMetricsWithComparison: vi.fn(), })); @@ -28,6 +33,7 @@ type RedisMock = { set: ReturnType; setex: ReturnType; del: ReturnType; + scan: ReturnType; }; function createRedisMock(): RedisMock { @@ -36,6 +42,7 @@ function createRedisMock(): RedisMock { set: vi.fn(), setex: vi.fn(), del: vi.fn(), + scan: vi.fn(), }; } @@ -55,6 +62,7 @@ function createOverviewData(): OverviewMetricsWithComparison { describe("getOverviewWithCache", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveSystemTimezone).mockResolvedValue("UTC"); }); it("returns cached data on cache hit (no DB call)", async () => { @@ -69,7 +77,7 @@ describe("getOverviewWithCache", () => { const result = await getOverviewWithCache(); expect(result).toEqual(data); - expect(redis.get).toHaveBeenCalledWith("overview:global"); + expect(redis.get).toHaveBeenCalledWith("overview:global:tz:UTC"); expect(getOverviewMetricsWithComparison).not.toHaveBeenCalled(); }); @@ -90,9 +98,9 @@ describe("getOverviewWithCache", () => { expect(result).toEqual(data); expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(42); - expect(redis.set).toHaveBeenCalledWith("overview:user:42:lock", "1", "EX", 5, "NX"); - expect(redis.setex).toHaveBeenCalledWith("overview:user:42", 10, JSON.stringify(data)); - expect(redis.del).toHaveBeenCalledWith("overview:user:42:lock"); + expect(redis.set).toHaveBeenCalledWith("overview:user:42:tz:UTC:lock", "1", "EX", 5, "NX"); + expect(redis.setex).toHaveBeenCalledWith("overview:user:42:tz:UTC", 10, JSON.stringify(data)); + expect(redis.del).toHaveBeenCalledWith("overview:user:42:tz:UTC:lock"); }); it("falls back to direct DB query when Redis is unavailable (null client)", async () => { @@ -140,9 +148,9 @@ describe("getOverviewWithCache", () => { const result = await pending; expect(result).toEqual(data); - expect(redis.set).toHaveBeenCalledWith("overview:user:99:lock", "1", "EX", 5, "NX"); - expect(redis.get).toHaveBeenNthCalledWith(1, "overview:user:99"); - expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:99"); + expect(redis.set).toHaveBeenCalledWith("overview:user:99:tz:UTC:lock", "1", "EX", 5, "NX"); + expect(redis.get).toHaveBeenNthCalledWith(1, "overview:user:99:tz:UTC"); + expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:99:tz:UTC"); expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(99); } finally { vi.useRealTimers(); @@ -166,20 +174,32 @@ describe("getOverviewWithCache", () => { await getOverviewWithCache(); await getOverviewWithCache(42); - expect(redis.get).toHaveBeenNthCalledWith(1, "overview:global"); - expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:42"); - expect(redis.setex).toHaveBeenNthCalledWith(1, "overview:global", 10, JSON.stringify(data)); - expect(redis.setex).toHaveBeenNthCalledWith(2, "overview:user:42", 10, JSON.stringify(data)); + expect(redis.get).toHaveBeenNthCalledWith(1, "overview:global:tz:UTC"); + expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:42:tz:UTC"); + expect(redis.setex).toHaveBeenNthCalledWith( + 1, + "overview:global:tz:UTC", + 10, + JSON.stringify(data) + ); + expect(redis.setex).toHaveBeenNthCalledWith( + 2, + "overview:user:42:tz:UTC", + 10, + JSON.stringify(data) + ); }); }); describe("invalidateOverviewCache", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveSystemTimezone).mockResolvedValue("UTC"); }); - it("deletes the correct cache key", async () => { + it("deletes timezone-scoped and legacy cache keys", async () => { const redis = createRedisMock(); + redis.scan.mockResolvedValueOnce(["0", ["overview:user:42:tz:UTC"]]); redis.del.mockResolvedValueOnce(1); vi.mocked(getRedisClient).mockReturnValue( @@ -188,7 +208,8 @@ describe("invalidateOverviewCache", () => { await invalidateOverviewCache(42); - expect(redis.del).toHaveBeenCalledWith("overview:user:42"); + expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "overview:user:42:tz:*", "COUNT", 100); + expect(redis.del).toHaveBeenCalledWith("overview:user:42:tz:UTC", "overview:user:42"); }); it("does nothing when Redis is unavailable", async () => { @@ -199,7 +220,7 @@ describe("invalidateOverviewCache", () => { it("swallows Redis errors during invalidation", async () => { const redis = createRedisMock(); - redis.del.mockRejectedValueOnce(new Error("delete failed")); + redis.scan.mockRejectedValueOnce(new Error("scan failed")); vi.mocked(getRedisClient).mockReturnValue( redis as unknown as NonNullable> diff --git a/tests/unit/redis/statistics-cache.test.ts b/tests/unit/redis/statistics-cache.test.ts index be4ff45a2..02897c033 100644 --- a/tests/unit/redis/statistics-cache.test.ts +++ b/tests/unit/redis/statistics-cache.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRedisClient } from "@/lib/redis/client"; import { getStatisticsWithCache, invalidateStatisticsCache } from "@/lib/redis/statistics-cache"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { getKeyStatisticsFromDB, getMixedStatisticsFromDB, @@ -21,6 +22,10 @@ vi.mock("@/lib/redis/client", () => ({ getRedisClient: vi.fn(), })); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn().mockResolvedValue("UTC"), +})); + vi.mock("@/repository/statistics", () => ({ getUserStatisticsFromDB: vi.fn(), getKeyStatisticsFromDB: vi.fn(), @@ -72,6 +77,7 @@ function createKeyStats(): DatabaseKeyStatRow[] { describe("getStatisticsWithCache", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveSystemTimezone).mockResolvedValue("UTC"); }); it("returns cached data on cache hit", async () => { @@ -86,7 +92,7 @@ describe("getStatisticsWithCache", () => { const result = await getStatisticsWithCache("today", "users"); expect(result).toEqual(cached); - expect(redis.get).toHaveBeenCalledWith("statistics:today:users:global"); + expect(redis.get).toHaveBeenCalledWith("statistics:today:users:global:tz:UTC"); expect(getUserStatisticsFromDB).not.toHaveBeenCalled(); expect(getKeyStatisticsFromDB).not.toHaveBeenCalled(); expect(getMixedStatisticsFromDB).not.toHaveBeenCalled(); @@ -108,7 +114,7 @@ describe("getStatisticsWithCache", () => { const result = await getStatisticsWithCache("today", "users"); expect(result).toEqual(rows); - expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today", "UTC"); expect(getKeyStatisticsFromDB).not.toHaveBeenCalled(); expect(getMixedStatisticsFromDB).not.toHaveBeenCalled(); }); @@ -129,7 +135,7 @@ describe("getStatisticsWithCache", () => { const result = await getStatisticsWithCache("7days", "keys", 42); expect(result).toEqual(rows); - expect(getKeyStatisticsFromDB).toHaveBeenCalledWith(42, "7days"); + expect(getKeyStatisticsFromDB).toHaveBeenCalledWith(42, "7days", "UTC"); expect(getUserStatisticsFromDB).not.toHaveBeenCalled(); expect(getMixedStatisticsFromDB).not.toHaveBeenCalled(); }); @@ -153,7 +159,7 @@ describe("getStatisticsWithCache", () => { const result = await getStatisticsWithCache("30days", "mixed", 42); expect(result).toEqual(mixedResult); - expect(getMixedStatisticsFromDB).toHaveBeenCalledWith(42, "30days"); + expect(getMixedStatisticsFromDB).toHaveBeenCalledWith(42, "30days", "UTC"); expect(getUserStatisticsFromDB).not.toHaveBeenCalled(); expect(getKeyStatisticsFromDB).not.toHaveBeenCalled(); }); @@ -174,7 +180,7 @@ describe("getStatisticsWithCache", () => { await getStatisticsWithCache("today", "users"); expect(redis.setex).toHaveBeenCalledWith( - "statistics:today:users:global", + "statistics:today:users:global:tz:UTC", 30, JSON.stringify(rows) ); @@ -188,7 +194,7 @@ describe("getStatisticsWithCache", () => { const result = await getStatisticsWithCache("today", "users"); expect(result).toEqual(rows); - expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today", "UTC"); }); it("uses retry path and returns cached data when lock is held", async () => { @@ -209,7 +215,7 @@ describe("getStatisticsWithCache", () => { expect(result).toEqual(rows); expect(redis.set).toHaveBeenCalledWith( - "statistics:today:users:global:lock", + "statistics:today:users:global:tz:UTC:lock", "1", "EX", 5, @@ -239,7 +245,7 @@ describe("getStatisticsWithCache", () => { const result = await pending; expect(result).toEqual(rows); - expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today", "UTC"); } finally { vi.useRealTimers(); } @@ -258,7 +264,7 @@ describe("getStatisticsWithCache", () => { const result = await getStatisticsWithCache("today", "users"); expect(result).toEqual(rows); - expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today", "UTC"); }); it("uses different cache keys for different timeRanges", async () => { @@ -273,8 +279,8 @@ describe("getStatisticsWithCache", () => { await getStatisticsWithCache("today", "users"); await getStatisticsWithCache("7days", "users"); - expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global"); - expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:7days:users:global"); + expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global:tz:UTC"); + expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:7days:users:global:tz:UTC"); }); it("uses different cache keys for global vs user scope", async () => { @@ -289,18 +295,25 @@ describe("getStatisticsWithCache", () => { await getStatisticsWithCache("today", "users"); await getStatisticsWithCache("today", "users", 42); - expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global"); - expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:today:users:42"); + expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global:tz:UTC"); + expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:today:users:42:tz:UTC"); }); }); describe("invalidateStatisticsCache", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveSystemTimezone).mockResolvedValue("UTC"); }); - it("deletes all mode keys for a given timeRange", async () => { + it("deletes timezone-scoped and legacy keys for a given timeRange", async () => { const redis = createRedisMock(); + const matchedKeys = [ + "statistics:today:users:42:tz:UTC", + "statistics:today:keys:42:tz:UTC", + "statistics:today:mixed:42:tz:UTC", + ]; + redis.scan.mockResolvedValueOnce(["0", matchedKeys]); redis.del.mockResolvedValueOnce(3); vi.mocked(getRedisClient).mockReturnValue( @@ -309,7 +322,15 @@ describe("invalidateStatisticsCache", () => { await invalidateStatisticsCache("today", 42); + expect(redis.scan).toHaveBeenCalledWith( + "0", + "MATCH", + "statistics:today:*:42:tz:*", + "COUNT", + 100 + ); expect(redis.del).toHaveBeenCalledWith( + ...matchedKeys, "statistics:today:users:42", "statistics:today:keys:42", "statistics:today:mixed:42" @@ -319,12 +340,15 @@ describe("invalidateStatisticsCache", () => { it("deletes all keys for scope when timeRange is undefined", async () => { const redis = createRedisMock(); const matchedKeys = [ - "statistics:today:users:global", - "statistics:7days:keys:global", - "statistics:30days:mixed:global", + "statistics:today:users:global:tz:UTC", + "statistics:7days:keys:global:tz:UTC", + "statistics:30days:mixed:global:tz:UTC", ]; - redis.scan.mockResolvedValueOnce(["0", matchedKeys]); - redis.del.mockResolvedValueOnce(matchedKeys.length); + const legacyMatchedKeys = ["statistics:today:users:global", "statistics:7days:keys:global"]; + redis.scan + .mockResolvedValueOnce(["0", matchedKeys]) + .mockResolvedValueOnce(["0", legacyMatchedKeys]); + redis.del.mockResolvedValueOnce(matchedKeys.length + legacyMatchedKeys.length); vi.mocked(getRedisClient).mockReturnValue( redis as unknown as NonNullable> @@ -332,8 +356,23 @@ describe("invalidateStatisticsCache", () => { await invalidateStatisticsCache(undefined, undefined); - expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:global", "COUNT", 100); - expect(redis.del).toHaveBeenCalledWith(...matchedKeys); + expect(redis.scan).toHaveBeenNthCalledWith( + 1, + "0", + "MATCH", + "statistics:*:*:global:tz:*", + "COUNT", + 100 + ); + expect(redis.scan).toHaveBeenNthCalledWith( + 2, + "0", + "MATCH", + "statistics:*:*:global", + "COUNT", + 100 + ); + expect(redis.del).toHaveBeenCalledWith(...matchedKeys, ...legacyMatchedKeys); }); it("does nothing when Redis is unavailable", async () => { @@ -344,7 +383,7 @@ describe("invalidateStatisticsCache", () => { it("does not call del when wildcard query returns no key", async () => { const redis = createRedisMock(); - redis.scan.mockResolvedValueOnce(["0", []]); + redis.scan.mockResolvedValueOnce(["0", []]).mockResolvedValueOnce(["0", []]); vi.mocked(getRedisClient).mockReturnValue( redis as unknown as NonNullable> @@ -352,7 +391,15 @@ describe("invalidateStatisticsCache", () => { await invalidateStatisticsCache(undefined, 42); - expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:42", "COUNT", 100); + expect(redis.scan).toHaveBeenNthCalledWith( + 1, + "0", + "MATCH", + "statistics:*:*:42:tz:*", + "COUNT", + 100 + ); + expect(redis.scan).toHaveBeenNthCalledWith(2, "0", "MATCH", "statistics:*:*:42", "COUNT", 100); expect(redis.del).not.toHaveBeenCalled(); }); diff --git a/tests/unit/repository/admin-user-insights-overview.test.ts b/tests/unit/repository/admin-user-insights-overview.test.ts index b92593eda..12727076f 100644 --- a/tests/unit/repository/admin-user-insights-overview.test.ts +++ b/tests/unit/repository/admin-user-insights-overview.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; function sqlToString(sqlObj: unknown): string { const visited = new Set(); @@ -67,6 +68,10 @@ vi.mock("@/drizzle/db", () => ({ })); vi.mock("@/drizzle/schema", () => ({ + messageRequest: { + blockedBy: "blockedBy", + endpoint: "endpoint", + }, usageLedger: { userId: "userId", costUsd: "costUsd", @@ -93,9 +98,14 @@ vi.mock("@/repository/system-config", () => ({ getSystemSettings: vi.fn(), })); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn().mockResolvedValue("Asia/Shanghai"), +})); + describe("getUserOverviewMetrics", () => { beforeEach(() => { vi.resetModules(); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); selectResults.length = 0; allWhereArgs.length = 0; capturedSelections.length = 0; @@ -125,6 +135,8 @@ describe("getUserOverviewMetrics", () => { const whereSql = sqlToString(allWhereArgs[0][0]); expect(whereSql).toContain("2026-03-01"); expect(whereSql).toContain("2026-03-09"); + expect(whereSql).toContain("AT TIME ZONE"); + expect(resolveSystemTimezone).toHaveBeenCalled(); expect(whereSql).toContain("INTERVAL '1 day'"); const errorCountSql = sqlToString(capturedSelections[0].errorCount).toLowerCase(); diff --git a/tests/unit/repository/statistics-timezone-buckets.test.ts b/tests/unit/repository/statistics-timezone-buckets.test.ts new file mode 100644 index 000000000..bff71c5d1 --- /dev/null +++ b/tests/unit/repository/statistics-timezone-buckets.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { db } from "@/drizzle/db"; + +vi.mock("@/drizzle/db", () => ({ + db: { + execute: vi.fn(), + }, +})); + +describe("statistics timezone buckets", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("serializes local SQL buckets as instants in the configured timezone", async () => { + vi.mocked(db.execute) + .mockResolvedValueOnce([{ id: 1, name: "alice" }]) + .mockResolvedValueOnce([{ bucket: "2026-05-30 00:00:00" }]) + .mockResolvedValueOnce([ + { + user_id: 1, + user_name: "alice", + bucket: "2026-05-30 00:00:00", + api_calls: "3", + total_cost: "1.25", + }, + ]); + + const { getUserStatisticsFromDB } = await import("@/repository/statistics"); + + const rows = await getUserStatisticsFromDB("7days", "Asia/Shanghai"); + + expect(rows).toHaveLength(1); + expect(new Date(rows[0].date).toISOString()).toBe("2026-05-29T16:00:00.000Z"); + expect(rows[0].api_calls).toBe(3); + expect(rows[0].total_cost).toBe("1.25"); + }); +}); diff --git a/tests/unit/user-insights-filters.test.ts b/tests/unit/user-insights-filters.test.ts index 466037f0b..c11b1c83a 100644 --- a/tests/unit/user-insights-filters.test.ts +++ b/tests/unit/user-insights-filters.test.ts @@ -12,6 +12,23 @@ describe("resolveTimePresetDates", () => { expect(startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); + it("uses the configured timezone when resolving presets", () => { + const now = new Date("2026-05-30T02:00:00Z"); + + expect(resolveTimePresetDates("today", "America/New_York", now)).toEqual({ + startDate: "2026-05-29", + endDate: "2026-05-29", + }); + expect(resolveTimePresetDates("today", "Asia/Shanghai", now)).toEqual({ + startDate: "2026-05-30", + endDate: "2026-05-30", + }); + expect(resolveTimePresetDates("7days", "America/New_York", now)).toEqual({ + startDate: "2026-05-23", + endDate: "2026-05-29", + }); + }); + it("returns 7-day range for '7days' preset", () => { const { startDate, endDate } = resolveTimePresetDates("7days");