dsh-peak-status 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,10 +17,20 @@ DeepSeek's official peak/valley API pricing (effective 2026-08-17, weekends adju
17
17
 
18
18
  Weekends (Saturday and Sunday) are charged at the valley price all day — no peak windows on weekends.
19
19
 
20
+ The Chinese official work calendar refines the weekend half of that rule:
21
+
22
+ | Day | State (Beijing time) |
23
+ | :-- | :-- |
24
+ | 法定节假日 (statutory holiday) | valley for the whole day, exactly like a weekend |
25
+ | 调休 makeup workday (a weekend worked) | the normal weekday windows apply |
26
+
27
+ So the chip shows 🟢 **国庆节** instead of a red peak on 1 October, and 🔴 peak windows on a 调休 Saturday.
28
+
20
29
  ## What you get
21
30
 
22
31
  - A colored status dot — **red = peak, green = off-peak** — plus the live Beijing clock (`HH:MM:SS`, timezone `Asia/Shanghai`, independent of your machine's timezone)
23
32
  - A countdown: `剩 3h 54m · 价格 ×2` during peak / `距高峰 3h 54m · 半价` off-peak
33
+ - Holiday awareness: on a 法定节假日 the chip names the festival (e.g. `国庆节`) and counts down to the first peak window after the break; on a 调休 workday it shows the normal peak state and tags the day
24
34
  - Click the chip to expand a detail card: peak windows, the next transition with a `HH:MM:SS` countdown, and a progress bar of the current phase
25
35
  - Fully themed with the web UI's native `--dsw-alias-*` variables — it blends into the interface instead of floating over it
26
36
  - Mounted persistently: it survives dsh web restarts
@@ -41,10 +51,22 @@ Then restart `dsh web` (or refresh the page). The chip appears at the top-right
41
51
 
42
52
  ## Usage
43
53
 
44
- The chip updates every second. Click it to expand/collapse the detail card; hover for a quick tooltip. Peak/off-peak state follows the official schedule above, computed in Beijing time.
54
+ The chip updates every second. Click it to expand/collapse the detail card; hover for a quick tooltip. Peak/off-peak state follows the official schedule above, computed in Beijing time over the official work calendar.
45
55
 
46
56
  If DeepSeek ever changes the peak schedule, a new release will ship with the updated windows — open an issue on this repository if you notice a mismatch.
47
57
 
58
+ ## Holiday calendar
59
+
60
+ The calendar (法定节假日 + 调休 makeup days) is fetched over the network, with three layers of fallback:
61
+
62
+ 1. **Host route** — the plugin's host half serves a same-origin route `/dsh-peak-status/holiday/<year>`. It tries jiejiariapi first and apihubs second, normalizes both shapes and caches the result in memory for 6 hours, so every browser tab shares one fetch.
63
+ 2. **Browser fallback** — if that route is unavailable, the browser half calls `api.apihubs.cn` directly (it is CORS-enabled).
64
+ 3. **Plain weekend rule** — with no calendar at all the chip behaves exactly like 1.0.3: weekday windows plus weekend valley.
65
+
66
+ Normalized days are cached in `localStorage` for 24 hours, so a restart or a spell offline still renders the right state, and the detail card always reports the source (`已同步 · jiejiariapi` / `未同步 · 暂按周末规则`).
67
+
68
+ This is the *official Chinese work calendar* reading of the pricing: statutory holidays are valley and 调休 days are workdays. DeepSeek's published adjustment only mentions Saturday and Sunday — if the calendar ever diverges from what is billed, the chip follows the calendar.
69
+
48
70
  ## License
49
71
 
50
72
  [MIT](LICENSE)
package/README.zh-CN.md CHANGED
@@ -17,10 +17,20 @@ DeepSeek 官方峰谷定价(2026-08-17 生效,2026-08-23 起周末调整)
17
17
 
18
18
  周末(周六、周日)全天按谷价计费,不设高峰时段。
19
19
 
20
+ 中国官方工作日历对"周末"这一半规则做了细化:
21
+
22
+ | 日期 | 状态(北京时间) |
23
+ | :-- | :-- |
24
+ | 法定节假日 | 全天谷价,与周末完全一致 |
25
+ | 调休上班日(周末上班) | 照工作日高峰窗口计费 |
26
+
27
+ 所以 10 月 1 日胶囊显示的是 🟢 **国庆节**,而不是红色高峰;调休的周六则照常显示 🔴 高峰窗口。
28
+
20
29
  ## 功能
21
30
 
22
31
  - 状态圆点——**红 = 高峰,绿 = 空闲**,外加实时北京时钟(`HH:MM:SS`,时区 `Asia/Shanghai`,与本机时区无关)
23
32
  - 倒计时:高峰时显示 `剩 3h 54m · 价格 ×2`,空闲时显示 `距高峰 3h 54m · 半价`
33
+ - 节假日感知:法定节假日直接显示节日名(如 `国庆节`),并倒计时到假期结束后的第一个高峰窗口;调休上班日显示为正常高峰状态并标注该日
24
34
  - 点击胶囊展开详情卡:高峰窗口、下一转折(含 `HH:MM:SS` 倒计时)、当前阶段进度条
25
35
  - 全部使用 Web UI 原生 `--dsw-alias-*` 主题变量,与界面完全融合,没有悬浮贴片
26
36
  - 持久挂载:重启 dsh web 后依然存在
@@ -41,10 +51,22 @@ dsh plugin --profile web add github:xiaoyi-xx/dsh-peak-status
41
51
 
42
52
  ## 使用
43
53
 
44
- 胶囊每秒自动更新。点击展开/收起详情卡,悬停有快捷提示。高峰/空闲状态严格按上面官方时刻表、以北京时间计算。
54
+ 胶囊每秒自动更新。点击展开/收起详情卡,悬停有快捷提示。高峰/空闲状态严格按上面官方时刻表、以北京时间计算,并叠加中国官方工作日历。
45
55
 
46
56
  如果 DeepSeek 以后调整高峰时段,我们会发布包含新时段的新版本——发现不一致欢迎在仓库提 Issue。
47
57
 
58
+ ## 节假日表
59
+
60
+ 节假日表(法定节假日 + 调休上班日)通过网络获取,共三层降级:
61
+
62
+ 1. **host 同源路由** —— 插件 host 半提供 `/dsh-peak-status/holiday/<year>`。它先取 jiejiariapi、失败再取 apihubs,归一化两种返回结构并在内存缓存 6 小时,所有浏览器标签页共用一次拉取。
63
+ 2. **浏览器直连兜底** —— 路由不可用时,浏览器半改直连 `api.apihubs.cn`(该源允许跨域)。
64
+ 3. **纯周末规则** —— 完全拿不到日历时,胶囊行为与 1.0.3 完全一致:工作日窗口 + 周末全谷价。
65
+
66
+ 归一化后的结果还会写入 `localStorage` 缓存 24 小时,所以重启或短暂断网时状态依旧正确;详情卡始终标注数据来源(`已同步 · jiejiariapi` / `未同步 · 暂按周末规则`)。
67
+
68
+ 需要说明:这是按**中国官方工作日历**理解峰谷计价——法定节假日算谷价、调休日算工作日。DeepSeek 公布的调整只提到周六与周日;若官方日历与实际计费口径出现分歧,胶囊以日历为准。
69
+
48
70
  ## 许可证
49
71
 
50
72
  [MIT](LICENSE)
package/lib/client.js CHANGED
@@ -13,6 +13,12 @@
13
13
  * weekends: Saturday & Sunday all day at the valley price
14
14
  * off-peak: everything else (priced at half of peak)
15
15
  *
16
+ * The Chinese official work calendar refines the weekend half of that rule:
17
+ * 法定节假日 (holiday rest days) are valley all day, and 调休 makeup days keep
18
+ * the normal weekday windows. The calendar is fetched over the network — the
19
+ * host half serves it same-origin, a CORS-capable public source and finally
20
+ * the plain weekend rule are the fallbacks — and cached in localStorage.
21
+ *
16
22
  * Red = peak (bold), green = off-peak; live Beijing clock, countdown to the
17
23
  * next transition, click to expand a small popover with the full detail.
18
24
  * Mounted persistently via the profile's cordis.patch.yml, so it survives
@@ -22,15 +28,32 @@
22
28
  // The host client-modules expects a bundle to register the id of the entry
23
29
  // it is mounted as — the package name ("@local/dsh-peak-status" for a
24
30
  // profile @local install, "dsh-peak-status" when installed from npm).
25
- // Derive it from the bundle URL at runtime so one artifact works in every
26
- // mount shape (bundles are loaded via <script>, so document.currentScript
27
- // is the plugin script while this top-level code runs).
31
+ //
32
+ // Single-file loads use /plugins/<id>/client.js, so the id is derivable
33
+ // from document.currentScript.src. Since dsh 0.1.2-alpha.3 the web shell
34
+ // may serve plugin bundles as one shared combo script
35
+ // (/plugins/??<id>/client.js,<id2>/client.js&rev=...) — currentScript.src
36
+ // then names every bundle in the batch, not this one. Fall back to
37
+ // scanning the combo URL for our own package leaf, which is present in
38
+ // both install shapes.
28
39
  var MODULE_ID = (function () {
40
+ var OWN = 'dsh-peak-status'
29
41
  try {
30
42
  var el = document.currentScript
31
43
  if (el && el.src) {
32
- var m = /\/plugins\/(.+?)\/client\.js/.exec(el.src)
33
- if (m) return decodeURIComponent(m[1])
44
+ var src = el.src
45
+ var single = /\/plugins\/([^/]+)\/client\.js(?:\?|$)/.exec(src)
46
+ if (single) return decodeURIComponent(single[1])
47
+ var combo = /\/plugins\/\?\?([^&]+)/.exec(src)
48
+ if (combo) {
49
+ var parts = combo[1].split(',')
50
+ for (var i = 0; i < parts.length; i++) {
51
+ var m = /^(.+?)\/client\.js$/.exec(parts[i])
52
+ if (m && decodeURIComponent(m[1]).split('/').pop() === OWN) {
53
+ return decodeURIComponent(m[1])
54
+ }
55
+ }
56
+ }
34
57
  }
35
58
  } catch (error) { /* ignore */ }
36
59
  return null
@@ -54,10 +77,14 @@
54
77
  var PEAK_COLOR = 'var(--dsw-alias-state-error-primary, #e5484d)'
55
78
  var OFF_COLOR = 'var(--dsw-alias-state-success-primary, #30a46c)'
56
79
 
57
- /** Beijing time parts incl. ISO weekday (1=Mon … 7=Sun), timezone-proof. */
80
+ /**
81
+ * Beijing time parts: calendar date (y/mo/d), clock, and ISO weekday
82
+ * (1=Mon … 7=Sun). `mo` is the month; minutes are `m`, timezone-proof.
83
+ */
58
84
  function bjParts() {
59
85
  var parts = new Intl.DateTimeFormat('en-GB', {
60
86
  timeZone: TZ, hour12: false, weekday: 'short',
87
+ year: 'numeric', month: '2-digit', day: '2-digit',
61
88
  hour: '2-digit', minute: '2-digit', second: '2-digit',
62
89
  }).formatToParts(new Date())
63
90
  var get = function (type) {
@@ -68,6 +95,9 @@
68
95
  }
69
96
  var wdMap = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }
70
97
  return {
98
+ y: Number(get('year')),
99
+ mo: Number(get('month')),
100
+ d: Number(get('day')),
71
101
  h: Number(get('hour')),
72
102
  m: Number(get('minute')),
73
103
  s: Number(get('second')),
@@ -75,53 +105,302 @@
75
105
  }
76
106
  }
77
107
 
108
+ // ── 节假日表(网络拉取 + 本地缓存) ────────────────────────────────────
109
+ // 数据源优先级:host 半同源路由 → 浏览器可直连的 CORS 源 → 内置周末规则。
110
+ // 全部失败时退化成 1.0.3 的行为:胶囊照常工作,只在详情里标注未同步。
111
+ var CAL_ROUTE = '/dsh-peak-status/holiday/'
112
+ var CAL_CORS_URL = function (year) { return 'https://api.apihubs.cn/holiday/get?year=' + year + '&size=400' }
113
+ var CAL_TTL_MS = 24 * 60 * 60 * 1000
114
+ var CAL_TIMEOUT_MS = 8000
115
+ var CAL_KEY = 'dsh-peak-status:holiday:'
116
+ var CAL_SCAN_LIMIT = 40
117
+
118
+ /** year → { days, source, fetchedAt };pending[year] 防止并发重复拉取。 */
119
+ var cal = { years: {}, pending: {}, listeners: [] }
120
+
121
+ /** 订阅节假日表变化,返回退订函数(React 侧用它触发重渲染)。 */
122
+ function calSubscribe(listener) {
123
+ cal.listeners.push(listener)
124
+ return function () {
125
+ var index = cal.listeners.indexOf(listener)
126
+ if (index >= 0) cal.listeners.splice(index, 1)
127
+ }
128
+ }
129
+
130
+ function calNotify() {
131
+ for (var i = 0; i < cal.listeners.length; i++) {
132
+ try { cal.listeners[i]() } catch (error) { /* 单个订阅者失败不影响其它 */ }
133
+ }
134
+ }
135
+
136
+ /** 'YYYY-MM-DD' —— 日历表的键。 */
137
+ function dayKey(y, mo, d) { return y + '-' + pad(mo) + '-' + pad(d) }
138
+
139
+ /** 自 1970-01-01 起的天序号;用 UTC 换算,绕开本地时区与夏令时。 */
140
+ function dayOrdinal(y, mo, d) { return Math.round(Date.UTC(y, mo - 1, d) / 86400000) }
141
+ function ordinalToParts(ordinal) {
142
+ var at = new Date(ordinal * 86400000)
143
+ return { y: at.getUTCFullYear(), mo: at.getUTCMonth() + 1, d: at.getUTCDate() }
144
+ }
145
+ /** 1970-01-01 是周四 → ISO 1=周一 … 7=周日。 */
146
+ function weekdayOf(ordinal) { return ((ordinal + 3) % 7 + 7) % 7 + 1 }
147
+
148
+ function storageGet(key) {
149
+ try { return window.localStorage.getItem(key) } catch (error) { return null }
150
+ }
151
+ function storageSet(key, value) {
152
+ try { window.localStorage.setItem(key, value) } catch (error) { /* 隐私模式/配额满:忽略 */ }
153
+ }
154
+
155
+ /** `20261001` 或 `2026-10-01` → `2026-10-01`;其它 → null。 */
156
+ function toIsoDate(value) {
157
+ if (typeof value === 'string') return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null
158
+ if (typeof value !== 'number' || !isFinite(value)) return null
159
+ var text = String(Math.trunc(value))
160
+ return /^\d{8}$/.test(text) ? text.slice(0, 4) + '-' + text.slice(4, 6) + '-' + text.slice(6, 8) : null
161
+ }
162
+
163
+ /** 只保留结构正确的条目,脏缓存不至于把 UI 弄崩。 */
164
+ function sanitizeDays(days) {
165
+ if (days === null || typeof days !== 'object') return null
166
+ var out = {}
167
+ var count = 0
168
+ for (var key in days) {
169
+ if (!Object.prototype.hasOwnProperty.call(days, key)) continue
170
+ var row = days[key]
171
+ if (row === null || typeof row !== 'object' || typeof row.off !== 'boolean') continue
172
+ out[key] = { name: typeof row.name === 'string' ? row.name : '', off: row.off }
173
+ count++
174
+ }
175
+ return count > 0 ? out : null
176
+ }
177
+
178
+ /** jiejiariapi:{ 'YYYY-MM-DD': { date, name, isOffDay } },只列特殊日。 */
179
+ function fromJiejiariapi(payload) {
180
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
181
+ var days = {}
182
+ var count = 0
183
+ for (var key in payload) {
184
+ if (!Object.prototype.hasOwnProperty.call(payload, key)) continue
185
+ var row = payload[key]
186
+ if (row === null || typeof row !== 'object' || typeof row.isOffDay !== 'boolean') continue
187
+ var date = toIsoDate(row.date === undefined ? key : row.date)
188
+ if (date === null) continue
189
+ days[date] = { name: typeof row.name === 'string' ? row.name : '', off: row.isOffDay }
190
+ count++
191
+ }
192
+ return count > 0 ? days : null
193
+ }
194
+
195
+ /**
196
+ * apihubs:{ data: { list: [ { date: 20261001, holiday_recess, holiday_overtime } ] } }。
197
+ * 数字是节日编码:holiday_recess=1 放假,holiday_overtime≠10 为调休补班(见下方注释)。
198
+ */
199
+ function fromApihubs(payload) {
200
+ if (payload === null || typeof payload !== 'object') return null
201
+ var data = payload.data !== null && typeof payload.data === 'object' ? payload.data : payload
202
+ var list = Array.isArray(data.list) ? data.list : (Array.isArray(data) ? data : null)
203
+ if (list === null) return null
204
+ var days = {}
205
+ var count = 0
206
+ for (var i = 0; i < list.length; i++) {
207
+ var row = list[i]
208
+ if (row === null || typeof row !== 'object') continue
209
+ var date = toIsoDate(row.date)
210
+ if (date === null) continue
211
+ // apihubs 用节日编号编码:holiday_recess=1 是放假日,holiday_overtime
212
+ // 为 10 表示非调休,其余值(11/22/55/88…)都是周末补班。
213
+ var off = row.holiday_recess === 1
214
+ var overtime = typeof row.holiday_overtime === 'number' && row.holiday_overtime !== 10
215
+ if (!off && !overtime) continue
216
+ days[date] = { name: '', off: off }
217
+ count++
218
+ }
219
+ return count > 0 ? days : null
220
+ }
221
+
222
+ /** 归一化任一支持的载荷(host 路由 / jiejiariapi / apihubs)。 */
223
+ function normalizeCalendar(payload) {
224
+ if (payload === null || typeof payload !== 'object') return null
225
+ if (payload.days !== undefined) return sanitizeDays(payload.days)
226
+ return fromJiejiariapi(payload) || fromApihubs(payload)
227
+ }
228
+
229
+ /** 带超时的 JSON 拉取;浏览器不支持 fetch 时直接失败,走下一层降级。 */
230
+ function fetchJson(url) {
231
+ return new Promise(function (resolve, reject) {
232
+ if (typeof fetch !== 'function') { reject(new Error('fetch unavailable')); return }
233
+ var controller = typeof AbortController === 'function' ? new AbortController() : null
234
+ var timer = setTimeout(function () {
235
+ if (controller !== null) controller.abort()
236
+ reject(new Error('timeout'))
237
+ }, CAL_TIMEOUT_MS)
238
+ var options = { headers: { accept: 'application/json' } }
239
+ if (controller !== null) options.signal = controller.signal
240
+ fetch(url, options).then(function (response) {
241
+ if (!response.ok) throw new Error('HTTP ' + response.status)
242
+ return response.json()
243
+ }).then(function (json) {
244
+ clearTimeout(timer)
245
+ resolve(json)
246
+ }, function (error) {
247
+ clearTimeout(timer)
248
+ reject(error)
249
+ })
250
+ })
251
+ }
252
+
253
+ /** 读缓存:内存 → localStorage。 */
254
+ function cachedYear(year) {
255
+ var entry = cal.years[year]
256
+ if (entry !== undefined) return entry
257
+ var raw = storageGet(CAL_KEY + year)
258
+ if (raw === null) return null
259
+ var parsed = null
260
+ try { parsed = JSON.parse(raw) } catch (error) { return null }
261
+ if (parsed === null || typeof parsed !== 'object') return null
262
+ var days = sanitizeDays(parsed.days)
263
+ if (days === null) return null
264
+ entry = { days: days, source: String(parsed.source || 'cache'), fetchedAt: Number(parsed.fetchedAt) || 0 }
265
+ cal.years[year] = entry
266
+ return entry
267
+ }
268
+
269
+ /** 拉取一年:先同源路由(含节日名),再浏览器可直连的 CORS 源。 */
270
+ function loadYear(year) {
271
+ return fetchJson(CAL_ROUTE + year).then(function (payload) {
272
+ var days = normalizeCalendar(payload)
273
+ if (days === null) return null
274
+ return {
275
+ days: days,
276
+ source: String(payload === null || payload === undefined || payload.source === undefined ? 'host' : payload.source),
277
+ fetchedAt: Date.now(),
278
+ }
279
+ }, function () { return null }).then(function (entry) {
280
+ if (entry !== null) return entry
281
+ return fetchJson(CAL_CORS_URL(year)).then(function (payload) {
282
+ var days = normalizeCalendar(payload)
283
+ return days === null ? null : { days: days, source: 'apihubs', fetchedAt: Date.now() }
284
+ }, function () { return null })
285
+ })
286
+ }
287
+
288
+ /** 需要时异步补拉一年;命中新鲜缓存或已在拉取则直接返回。 */
289
+ function ensureYear(year) {
290
+ var entry = cachedYear(year)
291
+ if (entry !== null && Date.now() - entry.fetchedAt < CAL_TTL_MS) return
292
+ if (cal.pending[year] === true) return
293
+ cal.pending[year] = true
294
+ loadYear(year).then(function (loaded) {
295
+ cal.pending[year] = false
296
+ if (loaded === null) { calNotify(); return }
297
+ cal.years[year] = loaded
298
+ storageSet(CAL_KEY + year, JSON.stringify({ source: loaded.source, fetchedAt: loaded.fetchedAt, days: loaded.days }))
299
+ calNotify()
300
+ }, function () {
301
+ cal.pending[year] = false
302
+ calNotify()
303
+ })
304
+ }
305
+
306
+ /** 该年的日历;未同步/未缓存 → null,调用方按周末规则退化。 */
307
+ function daysOfYear(year) {
308
+ var entry = cachedYear(year)
309
+ return entry === null ? null : entry.days
310
+ }
311
+
312
+ /** 详情卡里的同步状态文案。 */
313
+ function calStatusText(year) {
314
+ if (cal.years[year] !== undefined) return '已同步 · ' + cal.years[year].source
315
+ if (cal.pending[year] === true) return '同步中…'
316
+ return '未同步 · 暂按周末规则'
317
+ }
318
+
78
319
  /**
79
- * Peak/off-peak state at a Beijing-time instant.
80
- * Weekends (Sat/Sun) are all-day off-peak; weekdays follow the peak
81
- * windows 09:00–12:00 / 14:00–18:00. Returns the next state boundary as
82
- * { nextMin, daysAhead } (daysAhead counts how many Beijing days away),
83
- * seconds until it, and the progress through the current phase.
320
+ * 一天的工作/休息属性。
321
+ * 有日历:法定节假日 = 全天谷价;调休上班日 = 照工作日窗口;其余照常。
322
+ * 无日历:退化成 1.0.3 的规则(周六周日休息,其余工作日)。
323
+ * @param key - 'YYYY-MM-DD'。
324
+ * @param weekday - ISO 1=周一 7=周日。
325
+ * @param days - 该年归一化日历或 null。
84
326
  */
85
- function compute(t) {
86
- var now = t.h * 60 + t.m
87
- var weekend = t.weekday === 6 || t.weekday === 7
88
- var peak, nextMin, daysAhead, phaseStartMin, phaseLenMin
89
-
90
- if (weekend) {
91
- peak = false
92
- nextMin = BOUNDS[0] // Monday 09:00
93
- daysAhead = ((1 - t.weekday + 7) % 7) || 7 // Sat → 2, Sun 1
94
- var daysSinceSat = (t.weekday - 6 + 7) % 7 // Sat=0, Sun=1
95
- phaseStartMin = daysSinceSat * 1440
96
- phaseLenMin = 2 * 1440 + BOUNDS[0] // Sat 00:00 → Mon 09:00
97
- } else {
98
- peak = (now >= BOUNDS[0] && now < BOUNDS[1]) || (now >= BOUNDS[2] && now < BOUNDS[3])
99
- var next = null
327
+ function classifyDay(key, weekday, days) {
328
+ var row = days === null || days === undefined ? null : days[key]
329
+ if (row !== undefined && row !== null) {
330
+ if (row.off === true) return { rest: true, kind: 'holiday', name: row.name || '' }
331
+ if (weekday === 6 || weekday === 7) return { rest: false, kind: 'makeup', name: row.name || '' }
332
+ return { rest: false, kind: 'weekday', name: row.name || '' }
333
+ }
334
+ if (weekday === 6 || weekday === 7) return { rest: true, kind: 'weekend', name: '' }
335
+ return { rest: false, kind: 'weekday', name: '' }
336
+ }
337
+
338
+ function classifyOrdinal(ordinal, daysOf) {
339
+ var parts = ordinalToParts(ordinal)
340
+ return classifyDay(dayKey(parts.y, parts.mo, parts.d), weekdayOf(ordinal), daysOf(parts.y))
341
+ }
342
+
343
+ /** 下一个转折点:当天剩下的窗口边界,或下一个工作日 09:00(可跨假期)。 */
344
+ function nextTransition(ordinal, nowMin, daysOf) {
345
+ if (!classifyOrdinal(ordinal, daysOf).rest) {
100
346
  for (var i = 0; i < BOUNDS.length; i++) {
101
- if (BOUNDS[i] > now) { next = BOUNDS[i]; break }
347
+ if (BOUNDS[i] > nowMin) return { ordinal: ordinal, min: BOUNDS[i] }
102
348
  }
103
- if (next !== null) {
104
- nextMin = next
105
- daysAhead = 0
106
- var ps = 0
107
- for (var j = BOUNDS.length - 1; j >= 0; j--) {
108
- if (BOUNDS[j] <= now) { ps = BOUNDS[j]; break }
109
- }
110
- phaseStartMin = ps
111
- phaseLenMin = nextMin - ps
112
- } else {
113
- nextMin = BOUNDS[0]
114
- // After 18:00: Friday next Monday 09:00 (3 days), otherwise next day 09:00.
115
- daysAhead = t.weekday === 5 ? 3 : 1
116
- phaseStartMin = BOUNDS[3]
117
- phaseLenMin = daysAhead * 1440 + BOUNDS[0] - phaseStartMin
349
+ }
350
+ for (var step = 1; step <= CAL_SCAN_LIMIT; step++) {
351
+ var ahead = ordinal + step
352
+ if (!classifyOrdinal(ahead, daysOf).rest) return { ordinal: ahead, min: BOUNDS[0] }
353
+ }
354
+ return { ordinal: ordinal + 1, min: BOUNDS[0] } // 40 天内没有工作日:理论上不可达
355
+ }
356
+
357
+ /** 上一个转折点:当天已过的最后一个边界,或上一个工作日 18:00。 */
358
+ function prevTransition(ordinal, nowMin, daysOf) {
359
+ if (!classifyOrdinal(ordinal, daysOf).rest) {
360
+ for (var i = BOUNDS.length - 1; i >= 0; i--) {
361
+ if (BOUNDS[i] <= nowMin) return { ordinal: ordinal, min: BOUNDS[i] }
118
362
  }
119
363
  }
364
+ for (var step = 1; step <= CAL_SCAN_LIMIT; step++) {
365
+ var behind = ordinal - step
366
+ if (!classifyOrdinal(behind, daysOf).rest) return { ordinal: behind, min: BOUNDS[3] }
367
+ }
368
+ return { ordinal: ordinal - 1, min: BOUNDS[3] } // 40 天内没有工作日:理论上不可达
369
+ }
120
370
 
121
- var secsToNext = daysAhead * 86400 + (nextMin - now) * 60 - t.s
122
- var elapsedMin = weekend ? daysSinceSat * 1440 + now : now - phaseStartMin
123
- var progress = phaseLenMin > 0 ? Math.min(100, Math.max(0, (elapsedMin / phaseLenMin) * 100)) : 0
124
- return { peak: peak, nextMin: nextMin, daysAhead: daysAhead, secsToNext: secsToNext, progress: progress }
371
+ /**
372
+ * Peak/off-peak state at one Beijing-time instant.
373
+ * The official work calendar decides which days may peak at all: holiday
374
+ * rest days are valley across their whole span and makeup workdays keep the
375
+ * weekday windows, so both the current state and its neighbouring
376
+ * transitions are found by scanning days instead of weekday arithmetic.
377
+ * @param t - bjParts() result (calendar date, clock, ISO weekday).
378
+ * @param daysOf - year → normalized days; defaults to the loaded cache.
379
+ * @returns { peak, nextMin, daysAhead, nextOrdinal, nextWeekday, secsToNext, progress, day }
380
+ */
381
+ function compute(t, daysOf) {
382
+ var lookup = daysOf === undefined ? daysOfYear : daysOf
383
+ var ordinal = dayOrdinal(t.y, t.mo, t.d)
384
+ var nowMin = t.h * 60 + t.m
385
+ var day = classifyDay(dayKey(t.y, t.mo, t.d), t.weekday, lookup(t.y))
386
+ var peak = !day.rest &&
387
+ ((nowMin >= BOUNDS[0] && nowMin < BOUNDS[1]) || (nowMin >= BOUNDS[2] && nowMin < BOUNDS[3]))
388
+ var next = nextTransition(ordinal, nowMin, lookup)
389
+ var prev = prevTransition(ordinal, nowMin, lookup)
390
+ var nowAbs = ordinal * 1440 + nowMin
391
+ var nextAbs = next.ordinal * 1440 + next.min
392
+ var prevAbs = prev.ordinal * 1440 + prev.min
393
+ var phaseLen = nextAbs - prevAbs
394
+ return {
395
+ peak: peak,
396
+ nextMin: next.min,
397
+ daysAhead: next.ordinal - ordinal,
398
+ nextOrdinal: next.ordinal,
399
+ nextWeekday: weekdayOf(next.ordinal),
400
+ secsToNext: (nextAbs - nowAbs) * 60 - t.s,
401
+ progress: phaseLen > 0 ? Math.min(100, Math.max(0, ((nowAbs - prevAbs) / phaseLen) * 100)) : 0,
402
+ day: day,
403
+ }
125
404
  }
126
405
 
127
406
  function pad(n) { return (n < 10 ? '0' : '') + n }
@@ -142,12 +421,17 @@
142
421
  var sec = s % 60
143
422
  return pad(h) + ':' + pad(m) + ':' + pad(sec)
144
423
  }
145
- /** Human label for the next boundary: "18:00" / "明天 09:00" / "周一 09:00". */
146
- function fmtBoundaryLabel(t, s) {
424
+ /**
425
+ * Human label for the next boundary: "18:00" / "明天 09:00" / "周一 09:00",
426
+ * and an explicit date once the jump passes a week — a 国庆 break or a 调休
427
+ * week easily spans six days and a weekday name would read as next week.
428
+ */
429
+ function fmtBoundaryLabel(s) {
147
430
  if (s.daysAhead === 0) return fmtBoundary(s.nextMin)
148
431
  if (s.daysAhead === 1) return '明天 ' + fmtBoundary(s.nextMin)
149
- var nextW = ((t.weekday - 1 + s.daysAhead) % 7) + 1
150
- return '周' + WEEKDAY_LABELS[nextW - 1] + ' ' + fmtBoundary(s.nextMin)
432
+ if (s.daysAhead <= 6) return '周' + WEEKDAY_LABELS[s.nextWeekday - 1] + ' ' + fmtBoundary(s.nextMin)
433
+ var parts = ordinalToParts(s.nextOrdinal)
434
+ return pad(parts.mo) + '-' + pad(parts.d) + ' ' + fmtBoundary(s.nextMin)
151
435
  }
152
436
 
153
437
  function PeakChip() {
@@ -156,25 +440,49 @@
156
440
  var setTick = tickState[1]
157
441
  var open = react.useState(false)
158
442
  var setOpen = open[1]
443
+ var setBump = react.useState(0)[1]
159
444
 
160
445
  react.useEffect(function () {
161
- var id = setInterval(function () { setTick(bjParts()) }, 1000)
446
+ var id = setInterval(function () {
447
+ var next = bjParts()
448
+ setTick(next)
449
+ ensureYear(next.y)
450
+ if (next.mo === 12) ensureYear(next.y + 1) // 跨年:提前取下一年的表
451
+ }, 1000)
162
452
  return function () { clearInterval(id) }
163
453
  }, [])
164
454
 
455
+ // The calendar arrives asynchronously — re-render when it lands. Until
456
+ // then compute() degrades to the plain weekday/weekend rule on its own.
457
+ react.useEffect(function () {
458
+ return calSubscribe(function () { setBump(function (n) { return n + 1 }) })
459
+ }, [])
460
+
165
461
  var s = compute(tick)
166
- var weekend = tick.weekday === 6 || tick.weekday === 7
462
+ var day = s.day
167
463
  var color = s.peak ? PEAK_COLOR : OFF_COLOR
168
464
  var clock = fmtClock(tick)
169
- var boundaryLabel = fmtBoundaryLabel(tick, s)
465
+ var boundaryLabel = fmtBoundaryLabel(s)
466
+ var stateWord = s.peak
467
+ ? '高峰期'
468
+ : (day.kind === 'holiday' ? (day.name || '法定节假日') : (day.kind === 'weekend' ? '周末谷价' : '空闲期'))
469
+ var dayNote = day.kind === 'holiday'
470
+ ? ('今日' + (day.name === '' ? '为法定节假日' : '为' + day.name) + ':全天按谷价计费。')
471
+ : (day.kind === 'makeup'
472
+ ? ('今日为调休上班日(周' + WEEKDAY_LABELS[tick.weekday - 1] + '):高峰窗口照常。')
473
+ : '')
474
+ var offNote = day.kind === 'holiday'
475
+ ? ('(' + (day.name === '' ? '法定节假日' : day.name) + ' 全天谷价)')
476
+ : (day.kind === 'weekend' ? '(周末全天谷价)' : '')
477
+ var tooltip = (s.peak
478
+ ? '高峰期(工作日 9:00-12:00 / 14:00-18:00):价格 = 空闲 ×2。' + boundaryLabel + ' 结束。'
479
+ : '空闲期:价格为高峰一半' + offNote + ',下一高峰 ' + boundaryLabel + '。') + dayNote + '点击展开详情'
170
480
 
171
481
  var chip = react.createElement(
172
482
  'div',
173
483
  {
174
484
  onClick: function () { setOpen(!open[0]) },
175
- title: s.peak
176
- ? '高峰期(工作日 9:00-12:00 / 14:00-18:00):价格 = 空闲 ×2。' + boundaryLabel + ' 结束,点击展开详情'
177
- : '空闲期:价格为高峰一半' + (weekend ? '(周末全天谷价)' : '') + '。下一高峰 ' + boundaryLabel + ',点击展开详情',
485
+ title: tooltip,
178
486
  style: {
179
487
  display: 'inline-flex', alignItems: 'center', gap: 6,
180
488
  fontFamily: FONT, fontSize: 12, lineHeight: '20px',
@@ -186,8 +494,7 @@
186
494
  },
187
495
  [
188
496
  react.createElement('span', { key: 'dot', style: { width: 7, height: 7, borderRadius: '50%', background: color, flexShrink: 0 } }),
189
- react.createElement('span', { key: 'state', style: { fontWeight: s.peak ? 700 : 500, color: color } },
190
- s.peak ? '高峰期' : (weekend ? '周末谷价' : '空闲期')),
497
+ react.createElement('span', { key: 'state', style: { fontWeight: s.peak ? 700 : 500, color: color } }, stateWord),
191
498
  react.createElement('span', { key: 'clock', style: { fontVariantNumeric: 'tabular-nums' } }, clock),
192
499
  react.createElement('span', { key: 'count', style: { fontVariantNumeric: 'tabular-nums' } },
193
500
  s.peak
@@ -216,7 +523,7 @@
216
523
  [
217
524
  react.createElement('div', { key: 'l1', style: { display: 'flex', alignItems: 'center', gap: 6 } }, [
218
525
  react.createElement('span', { key: 'st', style: { fontWeight: 700, color: color } },
219
- s.peak ? '高峰时段' : (weekend ? '周末谷价' : '空闲时段')),
526
+ s.peak ? '高峰时段' : stateWord),
220
527
  react.createElement('span', { key: 'tm', style: { fontVariantNumeric: 'tabular-nums' } },
221
528
  clock + '(北京)'),
222
529
  react.createElement('span', { key: 'price', style: { color: color, fontWeight: 500 } },
@@ -224,8 +531,11 @@
224
531
  ]),
225
532
  react.createElement('div', { key: 'l2' },
226
533
  '高峰窗口(工作日):09:00–12:00 / 14:00–18:00'),
227
- react.createElement('div', { key: 'l2b', style: { color: weekend ? OFF_COLOR : DIM, fontWeight: weekend ? 600 : 400 } },
228
- '周末(周六、周日):全天按谷价计费'),
534
+ react.createElement('div', { key: 'l2b', style: { color: day.rest ? OFF_COLOR : DIM, fontWeight: day.rest ? 600 : 400 } },
535
+ '法定节假日与周末(周六、周日):全天按谷价计费'),
536
+ react.createElement('div', { key: 'l2c', style: { color: day.kind === 'makeup' ? PEAK_COLOR : DIM, fontWeight: day.kind === 'makeup' ? 600 : 400 } },
537
+ '调休上班日:照工作日高峰窗口计费'),
538
+ dayNote === '' ? null : react.createElement('div', { key: 'daynote', style: { color: color, fontWeight: 600 } }, dayNote),
229
539
  react.createElement('div', { key: 'l3', style: { fontVariantNumeric: 'tabular-nums' } },
230
540
  '下一转折:' + boundaryLabel + ' · 倒计时 ' + fmtDurFull(s.secsToNext)),
231
541
  react.createElement('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
@@ -238,6 +548,8 @@
238
548
  react.createElement('span', { key: 'pct', style: { fontSize: 10.5, fontVariantNumeric: 'tabular-nums' } },
239
549
  s.progress.toFixed(0) + '%'),
240
550
  ]),
551
+ react.createElement('div', { key: 'cal', style: { fontSize: 10.5, opacity: 0.85 } },
552
+ '节假日表(北京):' + calStatusText(tick.y)),
241
553
  react.createElement('div', { key: 'foot', style: { fontSize: 10.5, lineHeight: 1.5, opacity: 0.85 } },
242
554
  '官方峰谷定价 8/17 生效,8/23 起周末全天谷价;空闲价 = 高峰一半。'),
243
555
  ],
@@ -269,6 +581,23 @@
269
581
 
270
582
  exports.apply = apply
271
583
  exports.inject = inject
584
+ // Test seam for scripts/test-peak.mjs: the harness drives these pure
585
+ // functions directly instead of mirroring them, so the shipped algorithm
586
+ // is the one under test. The loader never reads it.
587
+ exports.__test = {
588
+ compute: compute,
589
+ classifyDay: classifyDay,
590
+ dayKey: dayKey,
591
+ dayOrdinal: dayOrdinal,
592
+ ordinalToParts: ordinalToParts,
593
+ weekdayOf: weekdayOf,
594
+ normalizeCalendar: normalizeCalendar,
595
+ calRoute: CAL_ROUTE,
596
+ calCorsUrl: CAL_CORS_URL,
597
+ calStatusText: calStatusText,
598
+ ensureYear: ensureYear,
599
+ daysOfYear: daysOfYear,
600
+ }
272
601
  return module.exports
273
602
  }
274
603