- {RANGES.map((r) => (
+ {/* Wraps on a screen narrower than the seven windows of a year. */}
+
+ {ranges.map((r) => (
setHours(r.hours)}>
{r.label}
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index 2627a39..bd53281 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -5,7 +5,7 @@
// Nothing imports it, so the bundle never includes it.
import {
axisBytes, axisTop, bytes, compact, cpuName, cycle, daysUntil, despike, distro, duration, expiresIn, monthUsage, osName, pair,
- quarters, timeTicks, uptime,
+ quarters, tickClock, timeTicks, uptime, windows,
} from "./format.ts"
let failed = 0
@@ -82,8 +82,33 @@ for (const max of [3_000, 300_000, 3_000_000, 300_000_000]) {
"刻度间距均匀",
)
eq(timeTicks(to, to - day), [], "反向区间不产出刻度")
+
+ // Past what two weeks can cover, the first of a month; either way a window of
+ // days is labelled by date alone.
+ const midnight = (t: number) => new Date(t).getHours() === 0 && new Date(t).getMinutes() === 0
+ for (const days of [30, 90, 180, 365]) {
+ const wide = timeTicks(to - days * day, to)
+ eq(wide.length >= 3 && wide.length <= 8, true, `${days} 天窗 3 到 8 个刻度(得到 ${wide.length})`)
+ eq(wide.every(midnight), true, `${days} 天窗的刻度落在零点`)
+ }
+ eq(timeTicks(to - 365 * day, to).every((t) => new Date(t).getDate() === 1), true, "一年窗的刻度落在每月 1 日")
+ const october = new Date(2026, 9, 1).getTime()
+ eq(tickClock(timeTicks(to - 365 * day, to), 8760)(october), "10/01", "全在零点的刻度只写日期")
+ eq(tickClock(timeTicks(to - 2 * day, to), 8760)(october + 6 * 3_600_000), "10/01 06:00", "缩放到两天写回时刻")
+ eq(tickClock(timeTicks(to - day, to), 24)(october + 6 * 3_600_000), "06:00", "一天之内只写时刻")
+}
+
+// windows: the round windows shorter than the history kept, then all of it.
+{
+ const hours = (days: number) => windows(days).map((w) => w.hours)
+ eq(hours(7), [1, 6, 24, 168], "保留一周与原来的四档相同")
+ eq(hours(90), [1, 6, 24, 168, 720, 2160], "默认 90 天")
+ eq(windows(365).at(-1), { hours: 8760, label: "1 年" }, "一年")
+ eq(windows(45).at(-1), { hours: 1080, label: "45 天" }, "不在取整档位上的保留期作为最后一档")
+ eq(windows(1).map((w) => w.label), ["1 小时", "6 小时", "1 天"], "一天")
}
+
// daysUntil: whole days, negative once past, null when there is no date.
{
const at = (days: number) => {
diff --git a/src/lib/format.ts b/src/lib/format.ts
index c222bbe..b1ee5d2 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -169,6 +169,8 @@ const MDHHMM = new Intl.DateTimeFormat("zh-CN", {
minute: "2-digit",
})
+const MMDD = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" })
+
function clock(ms: number): string {
return HHMM.format(ms)
}
@@ -181,6 +183,36 @@ export function clockFor(hours: number): (ms: number) => string {
return hours <= 24 ? clock : (ms: number) => MDHHMM.format(ms)
}
+/**
+ * The label format for an axis carrying `ticks` over a window `hours` wide: the
+ * date alone once every tick is a local midnight, where "00:00" beside each
+ * would say nothing, and `clockFor` otherwise. Decided by the ticks rather than
+ * the window, so a month dragged down to one day by the brush gets its times
+ * back.
+ */
+export function tickClock(ticks: number[], hours: number): (ms: number) => string {
+ const midnight = (t: number) => new Date(t).getHours() === 0 && new Date(t).getMinutes() === 0
+ return ticks.length > 0 && ticks.every(midnight) ? (ms: number) => MMDD.format(ms) : clockFor(hours)
+}
+
+// Round chart windows, in hours: up to a week they are drawn from minute rows,
+// past it from the hub's hourly tier.
+const WINDOWS = [1, 6, 24, 168, 720, 2160]
+
+/**
+ * The chart windows offered for a hub keeping `days` of history, which `/api/me`
+ * reports as `history_days`: the round windows shorter than it, then the whole
+ * of it. A window past it would be narrowed by the hub without saying so, and
+ * drawn under a label claiming more than it holds.
+ */
+export function windows(days: number): { hours: number; label: string }[] {
+ const whole = Math.max(1, Math.floor(days)) * 24
+ return [...WINDOWS.filter((h) => h < whole), whole].map((hours) => ({
+ hours,
+ label: hours < 24 ? `${hours} 小时` : hours === 8760 ? "1 年" : `${hours / 24} 天`,
+ }))
+}
+
/**
* Distro and CPU names as vendors write them carry mostly redundant text: a
* codename in brackets, "GNU/Linux", "(R)", a core count already printed
@@ -206,17 +238,35 @@ export function cpuName(name: string): string {
// time scale's own ticks, whatever `scale` specifies. The axis is therefore given
// the list explicitly: the smallest step from the ladder keeping the count under
// `count`, phased on local midnight so a daily tick lands on the day even in a
-// zone offset by 30 or 45 minutes.
-const TICK_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080].map((m) => m * 60_000)
+// zone offset by 30 or 45 minutes. Past what two weeks can cover, on the first of
+// a month: months differ in length, so no fixed step lands on one.
+const TICK_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080, 20160].map((m) => m * 60_000)
+const TICK_MONTHS = [1, 2, 3, 6, 12]
export function timeTicks(from: number, to: number, count = 8): number[] {
- const step = TICK_STEPS.find((s) => (to - from) / s <= count) ?? TICK_STEPS[TICK_STEPS.length - 1]
+ const step = TICK_STEPS.find((s) => (to - from) / s <= count)
+ if (step === undefined) return monthTicks(from, to, count)
const zone = new Date(from).getTimezoneOffset() * 60_000
const ticks: number[] = []
for (let t = Math.ceil((from - zone) / step) * step + zone; t <= to; t += step) ticks.push(t)
return ticks
}
+// The first of every `every`th month within `[from, to]`, on months divisible
+// by `every` so a quarterly axis reads January, April, July.
+function monthTicks(from: number, to: number, count: number): number[] {
+ const start = new Date(from)
+ const every = TICK_MONTHS.find((k) => (to - from) / (k * 30 * 86_400_000) <= count) ?? 12
+ let month = start.getFullYear() * 12 + start.getMonth()
+ if (new Date(start.getFullYear(), start.getMonth(), 1).getTime() < from) month++
+ const ticks: number[] = []
+ for (month = Math.ceil(month / every) * every; ; month += every) {
+ const t = new Date(Math.floor(month / 12), month % 12, 1).getTime()
+ if (t > to) return ticks
+ ticks.push(t)
+ }
+}
+
/**
* A zero-anchored axis top for a quantity with no capacity to measure against: a
* utilisation percentage, a load average, a transfer rate.
From fc36122280e09b6e4400bfe078c9d14eebf2c04a Mon Sep 17 00:00:00 2001
From: stqfdyr <89149493+stqfdyr@users.noreply.github.com>
Date: Wed, 30 Sep 2026 19:05:48 +0800
Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E6=9C=9F=E6=AF=94?=
=?UTF-8?q?=E6=95=B4=E6=A1=A3=E4=BD=8D=E5=A4=9A=E4=B8=8D=E5=88=B0=E5=9B=9B?=
=?UTF-8?q?=E5=88=86=E4=B9=8B=E4=B8=80=E6=97=B6=E4=B8=8D=E5=8D=95=E5=88=97?=
=?UTF-8?q?=E9=82=A3=E4=B8=80=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
保留 92 天时不再同时出现 90 天和 92 天两个几乎一样的档位;用例补上默认 30 天
---
src/lib/format.test.ts | 5 ++++-
src/lib/format.ts | 6 ++++--
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index bd53281..085d564 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -102,9 +102,12 @@ for (const max of [3_000, 300_000, 3_000_000, 300_000_000]) {
{
const hours = (days: number) => windows(days).map((w) => w.hours)
eq(hours(7), [1, 6, 24, 168], "保留一周与原来的四档相同")
- eq(hours(90), [1, 6, 24, 168, 720, 2160], "默认 90 天")
+ eq(hours(30), [1, 6, 24, 168, 720], "默认 30 天")
+ eq(hours(90), [1, 6, 24, 168, 720, 2160], "保留 90 天")
eq(windows(365).at(-1), { hours: 8760, label: "1 年" }, "一年")
eq(windows(45).at(-1), { hours: 1080, label: "45 天" }, "不在取整档位上的保留期作为最后一档")
+ eq(hours(92), [1, 6, 24, 168, 720, 2208], "紧挨保留期的整档位不单列")
+ eq(hours(8), [1, 6, 24, 192], "8 天不再并列 7 天")
eq(windows(1).map((w) => w.label), ["1 小时", "6 小时", "1 天"], "一天")
}
diff --git a/src/lib/format.ts b/src/lib/format.ts
index b1ee5d2..d02ac92 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -203,11 +203,13 @@ const WINDOWS = [1, 6, 24, 168, 720, 2160]
* The chart windows offered for a hub keeping `days` of history, which `/api/me`
* reports as `history_days`: the round windows shorter than it, then the whole
* of it. A window past it would be narrowed by the hub without saying so, and
- * drawn under a label claiming more than it holds.
+ * drawn under a label claiming more than it holds. A round window the whole
+ * exceeds by less than a quarter is left out, as it would sit beside a tab of
+ * nearly the same width: 30 and 31 days, 90 and 92.
*/
export function windows(days: number): { hours: number; label: string }[] {
const whole = Math.max(1, Math.floor(days)) * 24
- return [...WINDOWS.filter((h) => h < whole), whole].map((hours) => ({
+ return [...WINDOWS.filter((h) => h * 1.25 <= whole), whole].map((hours) => ({
hours,
label: hours < 24 ? `${hours} 小时` : hours === 8760 ? "1 年" : `${hours / 24} 天`,
}))
From 12060c26b964b9f63fdba9102fb63c19d351ce91 Mon Sep 17 00:00:00 2001
From: stqfdyr <89149493+stqfdyr@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:48:30 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E6=8C=89=E5=A4=A9=E4=BB=A5=E4=B8=8A?=
=?UTF-8?q?=E7=9A=84=E5=88=BB=E5=BA=A6=E5=8F=96=E6=9C=AC=E5=9C=B0=E9=9B=B6?=
=?UTF-8?q?=E7=82=B9=EF=BC=8C=E8=B7=A8=E5=A4=8F=E4=BB=A4=E6=97=B6=E4=B9=9F?=
=?UTF-8?q?=E4=B8=8D=E5=81=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 窗口跨过夏令时切换时,之后的刻度不再偏到 23:00 或 01:00,整根轴仍只写日期
- 纽约时区的 30、90 天窗用例覆盖春秋两次切换
---
src/lib/format.test.ts | 15 +++++++++++++--
src/lib/format.ts | 11 ++++++++---
2 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index 085d564..a68e643 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -91,10 +91,22 @@ for (const max of [3_000, 300_000, 3_000_000, 300_000_000]) {
eq(wide.length >= 3 && wide.length <= 8, true, `${days} 天窗 3 到 8 个刻度(得到 ${wide.length})`)
eq(wide.every(midnight), true, `${days} 天窗的刻度落在零点`)
}
+ // The zone above may have no daylight saving; New York changes on 8 March and
+ // 1 November 2026.
+ const zone = process.env.TZ
+ process.env.TZ = "America/New_York"
+ for (const end of [new Date(2026, 2, 25).getTime(), new Date(2026, 10, 20).getTime()]) {
+ for (const days of [30, 90]) {
+ const label = `纽约 ${new Date(end).getMonth() + 1} 月止的 ${days} 天窗跨夏令时仍落在零点`
+ eq(timeTicks(end - days * day, end).every(midnight), true, label)
+ }
+ }
+ if (zone === undefined) delete process.env.TZ
+ else process.env.TZ = zone
eq(timeTicks(to - 365 * day, to).every((t) => new Date(t).getDate() === 1), true, "一年窗的刻度落在每月 1 日")
const october = new Date(2026, 9, 1).getTime()
eq(tickClock(timeTicks(to - 365 * day, to), 8760)(october), "10/01", "全在零点的刻度只写日期")
- eq(tickClock(timeTicks(to - 2 * day, to), 8760)(october + 6 * 3_600_000), "10/01 06:00", "缩放到两天写回时刻")
+ eq(tickClock(timeTicks(to - 2 * day, to), 8760)(october + 6 * 3_600_000), "10/01 06:00", "两天的刻度写回时刻")
eq(tickClock(timeTicks(to - day, to), 24)(october + 6 * 3_600_000), "06:00", "一天之内只写时刻")
}
@@ -111,7 +123,6 @@ for (const max of [3_000, 300_000, 3_000_000, 300_000_000]) {
eq(windows(1).map((w) => w.label), ["1 小时", "6 小时", "1 天"], "一天")
}
-
// daysUntil: whole days, negative once past, null when there is no date.
{
const at = (days: number) => {
diff --git a/src/lib/format.ts b/src/lib/format.ts
index d02ac92..2c9119e 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -187,8 +187,8 @@ export function clockFor(hours: number): (ms: number) => string {
* The label format for an axis carrying `ticks` over a window `hours` wide: the
* date alone once every tick is a local midnight, where "00:00" beside each
* would say nothing, and `clockFor` otherwise. Decided by the ticks rather than
- * the window, so a month dragged down to one day by the brush gets its times
- * back.
+ * the window, as `timeTicks` places them on midnight only once its step reaches
+ * a day.
*/
export function tickClock(ticks: number[], hours: number): (ms: number) => string {
const midnight = (t: number) => new Date(t).getHours() === 0 && new Date(t).getMinutes() === 0
@@ -250,7 +250,12 @@ export function timeTicks(from: number, to: number, count = 8): number[] {
if (step === undefined) return monthTicks(from, to, count)
const zone = new Date(from).getTimezoneOffset() * 60_000
const ticks: number[] = []
- for (let t = Math.ceil((from - zone) / step) * step + zone; t <= to; t += step) ticks.push(t)
+ for (let t = Math.ceil((from - zone) / step) * step + zone; t <= to; t += step) {
+ // Past a daylight-saving change a step of days lands an hour off midnight,
+ // so each such tick is set back on the nearest one.
+ const tick = step < 86_400_000 ? t : new Date(t + 43_200_000).setHours(0, 0, 0, 0)
+ if (tick <= to) ticks.push(tick)
+ }
return ticks
}