- {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 ac8110a..d4277e8 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, money, monthUsage,
- osName, pair, quarters, timeTicks, uptime,
+ osName, pair, quarters, tickClock, timeTicks, uptime, windows,
} from "./format.ts"
let failed = 0
@@ -82,6 +82,45 @@ 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} 天窗的刻度落在零点`)
+ }
+ // 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 - 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(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 天"], "一天")
}
// daysUntil: whole days, negative once past, null when there is no date.
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 39cf81e..2d4f64f 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -189,6 +189,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)
}
@@ -201,6 +203,38 @@ 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, 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
+ 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. 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 * 1.25 <= 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
@@ -226,17 +260,40 @@ 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)
+ 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
}
+// 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.