{title ?
{title}
: }
diff --git a/src/shared/utils/time.js b/src/shared/utils/time.js
index 94dc7d4..8d8660a 100644
--- a/src/shared/utils/time.js
+++ b/src/shared/utils/time.js
@@ -85,6 +85,66 @@ export function formatMillis(value, timeZone = activeTimeZone) {
return formatDateTime(value, timeZone);
}
+export function startOfDayMillis(value = Date.now(), timeZone = activeTimeZone) {
+ const normalizedTimeZone = normalizeTimeZone(timeZone);
+ const targetParts = dateTimeParts(value, normalizedTimeZone);
+ if (!targetParts) {
+ return null;
+ }
+
+ return localDateStartMillis(targetParts, normalizedTimeZone);
+}
+
+export function nextDayStartMillis(value = Date.now(), timeZone = activeTimeZone) {
+ const normalizedTimeZone = normalizeTimeZone(timeZone);
+ const targetParts = dateTimeParts(value, normalizedTimeZone);
+ if (!targetParts) {
+ return null;
+ }
+
+ // 先按日历推进一天,再把该日期的本地零点换算成 epoch;不能直接加 24 小时,
+ // 否则未来开放夏令时时区后会在切换日提前或延后一小时刷新 Dashboard。
+ const nextCalendarDate = new Date(
+ Date.UTC(Number(targetParts.year), Number(targetParts.month) - 1, Number(targetParts.day) + 1)
+ );
+ return localDateStartMillis(
+ {
+ day: String(nextCalendarDate.getUTCDate()).padStart(2, "0"),
+ month: String(nextCalendarDate.getUTCMonth() + 1).padStart(2, "0"),
+ year: String(nextCalendarDate.getUTCFullYear())
+ },
+ normalizedTimeZone
+ );
+}
+
+function localDateStartMillis(targetParts, timeZone) {
+ const targetUtc = Date.UTC(Number(targetParts.year), Number(targetParts.month) - 1, Number(targetParts.day));
+ let candidate = targetUtc;
+
+ // IANA 时区的 UTC 偏移可能随日期变化;迭代校正能在不引入时区库的前提下得到目标日期的本地零点。
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const candidateParts = dateTimeParts(candidate, timeZone);
+ if (!candidateParts) {
+ return null;
+ }
+ const representedUtc = Date.UTC(
+ Number(candidateParts.year),
+ Number(candidateParts.month) - 1,
+ Number(candidateParts.day),
+ Number(candidateParts.hour),
+ Number(candidateParts.minute),
+ Number(candidateParts.second)
+ );
+ const corrected = targetUtc - (representedUtc - candidate);
+ if (corrected === candidate) {
+ return corrected;
+ }
+ candidate = corrected;
+ }
+
+ return candidate;
+}
+
function finitePositiveMillis(value) {
return Number.isFinite(value) && value > 0 ? value : null;
}
diff --git a/src/shared/utils/time.test.js b/src/shared/utils/time.test.js
index b227df2..031673c 100644
--- a/src/shared/utils/time.test.js
+++ b/src/shared/utils/time.test.js
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
-import { formatMillis, normalizeTimeZone, toEpochMillis } from "./time.js";
+import { formatMillis, nextDayStartMillis, normalizeTimeZone, startOfDayMillis, toEpochMillis } from "./time.js";
describe("time formatting", () => {
test("formats the same epoch milliseconds in UTC and China time", () => {
@@ -18,4 +18,18 @@ describe("time formatting", () => {
expect(normalizeTimeZone("UTC")).toBe("UTC");
expect(normalizeTimeZone("America/Los_Angeles")).toBe("Asia/Shanghai");
});
+
+ test("calculates the current local day boundary for dashboard statistics", () => {
+ const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0);
+
+ expect(startOfDayMillis(epochMillis, "UTC")).toBe(Date.UTC(2026, 4, 9, 0, 0, 0));
+ expect(startOfDayMillis(epochMillis, "Asia/Shanghai")).toBe(Date.UTC(2026, 4, 9, 16, 0, 0));
+ });
+
+ test("calculates the next local day boundary used for dashboard rollover", () => {
+ const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0);
+
+ expect(nextDayStartMillis(epochMillis, "UTC")).toBe(Date.UTC(2026, 4, 10, 0, 0, 0));
+ expect(nextDayStartMillis(epochMillis, "Asia/Shanghai")).toBe(Date.UTC(2026, 4, 10, 16, 0, 0));
+ });
});
diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css
index 42d9477..adb03ae 100644
--- a/src/styles/shared-ui.css
+++ b/src/styles/shared-ui.css
@@ -1222,6 +1222,10 @@
width: min(560px, 100vw);
}
+.side-drawer--detail {
+ width: min(720px, 100vw);
+}
+
.side-drawer__header {
display: flex;
flex: 0 0 auto;