Skip to content
Draft
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
16 changes: 6 additions & 10 deletions src/actions/statistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
* 获取用户统计数据,用于图表展示
*/
Expand Down Expand Up @@ -99,16 +104,7 @@ export async function getUserStatistics(
const dataByDate = new Map<string, ChartDataItem>();

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, {
Expand Down
17 changes: 17 additions & 0 deletions src/actions/system-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand All @@ -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) };
Expand All @@ -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) };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since baseDate is already computed as the zoned time in the target timezone (toZonedTime(now, timeZone)), we can directly use formatDate(baseDate) instead of calling formatDateInSystemTimeZone(now, timeZone). This improves consistency with the other cases in the switch statement and avoids redundant timezone calculations.

Suggested change
return { startDate: "2020-01-01", endDate: formatDateInSystemTimeZone(now, timeZone) };
return { startDate: "2020-01-01", endDate: formatDate(baseDate) };

}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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) }}
/>
</PopoverContent>
</Popover>
Expand All @@ -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")}
>
<ChevronRight className="h-4 w-4" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 };
}
}
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<UserInsightsFilters>(DEFAULT_FILTERS);

const { startDate, endDate } = resolveTimePresetDates(filters.timeRange);
const { startDate, endDate } = resolveTimePresetDates(filters.timeRange, timeZone);

return (
<div className="space-y-6" data-testid="user-insights-page">
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +16,7 @@ interface ActiveFiltersDisplayProps {
onClearAll: () => void;
displayNames: FilterDisplayNames;
isAdmin: boolean;
serverTimeZone?: string;
className?: string;
}

Expand All @@ -30,6 +32,7 @@ export function ActiveFiltersDisplay({
onClearAll,
displayNames,
isAdmin,
serverTimeZone,
className,
}: ActiveFiltersDisplayProps) {
const t = useTranslations("dashboard.logs.filters");
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -133,7 +140,7 @@ export function ActiveFiltersDisplay({
}

return result;
}, [filters, displayNames, isAdmin, t]);
}, [filters, displayNames, isAdmin, serverTimeZone, t]);

if (activeFilters.length === 0) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -212,7 +216,7 @@ export function LogsDateRangePicker({
selected={selectedRange}
onSelect={handleDateRangeSelect}
numberOfMonths={2}
disabled={{ after: new Date() }}
disabled={{ after: parseDate(today) }}
/>
{hasDateRange && (
<div className="border-t p-2">
Expand All @@ -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")}
>
<ChevronRight className="h-4 w-4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ export function UsageLogsFilters({
onClearAll={handleReset}
displayNames={displayNames}
isAdmin={isAdmin}
serverTimeZone={serverTimeZone}
/>

{/* Filter Sections */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ function UsageLogsViewContent({
autoRefreshEnabled={!isFullscreenOpen && isAutoRefresh}
autoRefreshIntervalMs={logsRefreshIntervalMs ?? 5000}
hiddenColumns={hiddenColumns}
serverTimeZone={serverTimeZone}
/>
</div>
</div>
Expand Down Expand Up @@ -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}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function VirtualizedLogsTable({
hideScrollToTop = false,
hiddenColumns,
bodyClassName,
serverTimeZone: _serverTimeZone,
serverTimeZone,
fetchFn,
queryKeyPrefix = "usage-logs-batch",
disableDetailDialog = false,
Expand Down Expand Up @@ -717,7 +717,12 @@ export function VirtualizedLogsTable({
>
{/* Time */}
<div className="flex-[0.6] min-w-[56px] font-mono text-xs truncate pl-3">
<RelativeTime date={log.createdAt} fallback="-" format="short" />
<RelativeTime
date={log.createdAt}
fallback="-"
format="short"
timeZone={serverTimeZone}
/>
</div>

{/* User */}
Expand Down
Loading
Loading