dsh-peak-status 1.0.3 → 1.0.5

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
+ Per DeepSeek's peak/valley note, the only days that can peak are ordinary Monday–Friday working days:
21
+
22
+ | Day | State (Beijing time) |
23
+ | :-- | :-- |
24
+ | 法定节假日 (statutory holiday) | valley for the whole day, exactly like a weekend |
25
+ | 调休上班的周末 (a weekend worked) | valley for the whole day too — 调休 does not change billing |
26
+
27
+ So the chip shows 🟢 **国庆节** instead of a red peak on 1 October, and 🟢 **调休上班 · 谷价** 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 调休上班的周末 it shows all-day valley 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
+ Pricing follows DeepSeek's 峰谷时间说明: **调休上班的周末 and 中国法定节假日 are billed at the off-peak rate for the whole day**. The calendar supplies exactly those two labels — a 调休 day never gains a peak window.
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
+ 按 DeepSeek 峰谷时间说明,能有高峰窗口的只有"既非周六周日、也非法定节假日"的普通工作日:
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,13 @@
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 that rule: per DeepSeek's peak/
17
+ * valley note, 中国法定节假日 and 调休上班的周末 are valley for the whole day —
18
+ * only ordinary Monday–Friday working days can peak. The calendar is fetched
19
+ * over the network — the
20
+ * host half serves it same-origin, a CORS-capable public source and finally
21
+ * the plain weekend rule are the fallbacks — and cached in localStorage.
22
+ *
16
23
  * Red = peak (bold), green = off-peak; live Beijing clock, countdown to the
17
24
  * next transition, click to expand a small popover with the full detail.
18
25
  * Mounted persistently via the profile's cordis.patch.yml, so it survives
@@ -71,10 +78,14 @@
71
78
  var PEAK_COLOR = 'var(--dsw-alias-state-error-primary, #e5484d)'
72
79
  var OFF_COLOR = 'var(--dsw-alias-state-success-primary, #30a46c)'
73
80
 
74
- /** Beijing time parts incl. ISO weekday (1=Mon … 7=Sun), timezone-proof. */
81
+ /**
82
+ * Beijing time parts: calendar date (y/mo/d), clock, and ISO weekday
83
+ * (1=Mon … 7=Sun). `mo` is the month; minutes are `m`, timezone-proof.
84
+ */
75
85
  function bjParts() {
76
86
  var parts = new Intl.DateTimeFormat('en-GB', {
77
87
  timeZone: TZ, hour12: false, weekday: 'short',
88
+ year: 'numeric', month: '2-digit', day: '2-digit',
78
89
  hour: '2-digit', minute: '2-digit', second: '2-digit',
79
90
  }).formatToParts(new Date())
80
91
  var get = function (type) {
@@ -85,6 +96,9 @@
85
96
  }
86
97
  var wdMap = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }
87
98
  return {
99
+ y: Number(get('year')),
100
+ mo: Number(get('month')),
101
+ d: Number(get('day')),
88
102
  h: Number(get('hour')),
89
103
  m: Number(get('minute')),
90
104
  s: Number(get('second')),
@@ -92,53 +106,315 @@
92
106
  }
93
107
  }
94
108
 
109
+ // ── 节假日表(网络拉取 + 本地缓存) ────────────────────────────────────
110
+ // 数据源优先级:host 半同源路由 → 浏览器可直连的 CORS 源 → 内置周末规则。
111
+ // 全部失败时退化成 1.0.3 的行为:胶囊照常工作,只在详情里标注未同步。
112
+ var CAL_ROUTE = '/dsh-peak-status/holiday/'
113
+ var CAL_CORS_URL = function (year) { return 'https://api.apihubs.cn/holiday/get?year=' + year + '&size=400' }
114
+ var CAL_TTL_MS = 24 * 60 * 60 * 1000
115
+ var CAL_TIMEOUT_MS = 8000
116
+ var CAL_KEY = 'dsh-peak-status:holiday:'
117
+ var CAL_SCAN_LIMIT = 40
118
+
119
+ /** year → { days, source, fetchedAt };pending[year] 防止并发重复拉取。 */
120
+ var cal = { years: {}, pending: {}, listeners: [] }
121
+
122
+ /** 订阅节假日表变化,返回退订函数(React 侧用它触发重渲染)。 */
123
+ function calSubscribe(listener) {
124
+ cal.listeners.push(listener)
125
+ return function () {
126
+ var index = cal.listeners.indexOf(listener)
127
+ if (index >= 0) cal.listeners.splice(index, 1)
128
+ }
129
+ }
130
+
131
+ function calNotify() {
132
+ for (var i = 0; i < cal.listeners.length; i++) {
133
+ try { cal.listeners[i]() } catch (error) { /* 单个订阅者失败不影响其它 */ }
134
+ }
135
+ }
136
+
137
+ /** 'YYYY-MM-DD' —— 日历表的键。 */
138
+ function dayKey(y, mo, d) { return y + '-' + pad(mo) + '-' + pad(d) }
139
+
140
+ /** 自 1970-01-01 起的天序号;用 UTC 换算,绕开本地时区与夏令时。 */
141
+ function dayOrdinal(y, mo, d) { return Math.round(Date.UTC(y, mo - 1, d) / 86400000) }
142
+ function ordinalToParts(ordinal) {
143
+ var at = new Date(ordinal * 86400000)
144
+ return { y: at.getUTCFullYear(), mo: at.getUTCMonth() + 1, d: at.getUTCDate() }
145
+ }
146
+ /** 1970-01-01 是周四 → ISO 1=周一 … 7=周日。 */
147
+ function weekdayOf(ordinal) { return ((ordinal + 3) % 7 + 7) % 7 + 1 }
148
+
149
+ function storageGet(key) {
150
+ try { return window.localStorage.getItem(key) } catch (error) { return null }
151
+ }
152
+ function storageSet(key, value) {
153
+ try { window.localStorage.setItem(key, value) } catch (error) { /* 隐私模式/配额满:忽略 */ }
154
+ }
155
+
156
+ /** `20261001` 或 `2026-10-01` → `2026-10-01`;其它 → null。 */
157
+ function toIsoDate(value) {
158
+ if (typeof value === 'string') return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null
159
+ if (typeof value !== 'number' || !isFinite(value)) return null
160
+ var text = String(Math.trunc(value))
161
+ return /^\d{8}$/.test(text) ? text.slice(0, 4) + '-' + text.slice(4, 6) + '-' + text.slice(6, 8) : null
162
+ }
163
+
164
+ /** 只保留结构正确的条目,脏缓存不至于把 UI 弄崩。 */
165
+ function sanitizeDays(days) {
166
+ if (days === null || typeof days !== 'object') return null
167
+ var out = {}
168
+ var count = 0
169
+ for (var key in days) {
170
+ if (!Object.prototype.hasOwnProperty.call(days, key)) continue
171
+ var row = days[key]
172
+ if (row === null || typeof row !== 'object' || typeof row.off !== 'boolean') continue
173
+ out[key] = { name: typeof row.name === 'string' ? row.name : '', off: row.off }
174
+ count++
175
+ }
176
+ return count > 0 ? out : null
177
+ }
178
+
179
+ /** jiejiariapi:{ 'YYYY-MM-DD': { date, name, isOffDay } },只列特殊日。 */
180
+ function fromJiejiariapi(payload) {
181
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
182
+ var days = {}
183
+ var count = 0
184
+ for (var key in payload) {
185
+ if (!Object.prototype.hasOwnProperty.call(payload, key)) continue
186
+ var row = payload[key]
187
+ if (row === null || typeof row !== 'object' || typeof row.isOffDay !== 'boolean') continue
188
+ var date = toIsoDate(row.date === undefined ? key : row.date)
189
+ if (date === null) continue
190
+ days[date] = { name: typeof row.name === 'string' ? row.name : '', off: row.isOffDay }
191
+ count++
192
+ }
193
+ return count > 0 ? days : null
194
+ }
195
+
95
196
  /**
96
- * Peak/off-peak state at a Beijing-time instant.
97
- * Weekends (Sat/Sun) are all-day off-peak; weekdays follow the peak
98
- * windows 09:00–12:00 / 14:00–18:00. Returns the next state boundary as
99
- * { nextMin, daysAhead } (daysAhead counts how many Beijing days away),
100
- * seconds until it, and the progress through the current phase.
197
+ * apihubs:{ data: { list: [ { date: 20261001, holiday_recess, holiday_overtime } ] } }。
198
+ * 数字是节日编码:holiday_recess=1 放假,holiday_overtime≠10 为调休补班(见下方注释)。
101
199
  */
102
- function compute(t) {
103
- var now = t.h * 60 + t.m
104
- var weekend = t.weekday === 6 || t.weekday === 7
105
- var peak, nextMin, daysAhead, phaseStartMin, phaseLenMin
200
+ function fromApihubs(payload) {
201
+ if (payload === null || typeof payload !== 'object') return null
202
+ var data = payload.data !== null && typeof payload.data === 'object' ? payload.data : payload
203
+ var list = Array.isArray(data.list) ? data.list : (Array.isArray(data) ? data : null)
204
+ if (list === null) return null
205
+ var days = {}
206
+ var count = 0
207
+ for (var i = 0; i < list.length; i++) {
208
+ var row = list[i]
209
+ if (row === null || typeof row !== 'object') continue
210
+ var date = toIsoDate(row.date)
211
+ if (date === null) continue
212
+ // apihubs 用节日编号编码:holiday_recess=1 是放假日,holiday_overtime
213
+ // 为 10 表示非调休,其余值(11/22/55/88…)都是周末补班。
214
+ var off = row.holiday_recess === 1
215
+ var overtime = typeof row.holiday_overtime === 'number' && row.holiday_overtime !== 10
216
+ if (!off && !overtime) continue
217
+ days[date] = { name: '', off: off }
218
+ count++
219
+ }
220
+ return count > 0 ? days : null
221
+ }
222
+
223
+ /** 归一化任一支持的载荷(host 路由 / jiejiariapi / apihubs)。 */
224
+ function normalizeCalendar(payload) {
225
+ if (payload === null || typeof payload !== 'object') return null
226
+ if (payload.days !== undefined) return sanitizeDays(payload.days)
227
+ return fromJiejiariapi(payload) || fromApihubs(payload)
228
+ }
229
+
230
+ /** 带超时的 JSON 拉取;浏览器不支持 fetch 时直接失败,走下一层降级。 */
231
+ function fetchJson(url) {
232
+ return new Promise(function (resolve, reject) {
233
+ if (typeof fetch !== 'function') { reject(new Error('fetch unavailable')); return }
234
+ var controller = typeof AbortController === 'function' ? new AbortController() : null
235
+ var timer = setTimeout(function () {
236
+ if (controller !== null) controller.abort()
237
+ reject(new Error('timeout'))
238
+ }, CAL_TIMEOUT_MS)
239
+ var options = { headers: { accept: 'application/json' } }
240
+ if (controller !== null) options.signal = controller.signal
241
+ fetch(url, options).then(function (response) {
242
+ if (!response.ok) throw new Error('HTTP ' + response.status)
243
+ return response.json()
244
+ }).then(function (json) {
245
+ clearTimeout(timer)
246
+ resolve(json)
247
+ }, function (error) {
248
+ clearTimeout(timer)
249
+ reject(error)
250
+ })
251
+ })
252
+ }
253
+
254
+ /** 读缓存:内存 → localStorage。 */
255
+ function cachedYear(year) {
256
+ var entry = cal.years[year]
257
+ if (entry !== undefined) return entry
258
+ var raw = storageGet(CAL_KEY + year)
259
+ if (raw === null) return null
260
+ var parsed = null
261
+ try { parsed = JSON.parse(raw) } catch (error) { return null }
262
+ if (parsed === null || typeof parsed !== 'object') return null
263
+ var days = sanitizeDays(parsed.days)
264
+ if (days === null) return null
265
+ entry = { days: days, source: String(parsed.source || 'cache'), fetchedAt: Number(parsed.fetchedAt) || 0 }
266
+ cal.years[year] = entry
267
+ return entry
268
+ }
106
269
 
270
+ /** 拉取一年:先同源路由(含节日名),再浏览器可直连的 CORS 源。 */
271
+ function loadYear(year) {
272
+ return fetchJson(CAL_ROUTE + year).then(function (payload) {
273
+ var days = normalizeCalendar(payload)
274
+ if (days === null) return null
275
+ return {
276
+ days: days,
277
+ source: String(payload === null || payload === undefined || payload.source === undefined ? 'host' : payload.source),
278
+ fetchedAt: Date.now(),
279
+ }
280
+ }, function () { return null }).then(function (entry) {
281
+ if (entry !== null) return entry
282
+ return fetchJson(CAL_CORS_URL(year)).then(function (payload) {
283
+ var days = normalizeCalendar(payload)
284
+ return days === null ? null : { days: days, source: 'apihubs', fetchedAt: Date.now() }
285
+ }, function () { return null })
286
+ })
287
+ }
288
+
289
+ /** 需要时异步补拉一年;命中新鲜缓存或已在拉取则直接返回。 */
290
+ function ensureYear(year) {
291
+ var entry = cachedYear(year)
292
+ if (entry !== null && Date.now() - entry.fetchedAt < CAL_TTL_MS) return
293
+ if (cal.pending[year] === true) return
294
+ cal.pending[year] = true
295
+ loadYear(year).then(function (loaded) {
296
+ cal.pending[year] = false
297
+ if (loaded === null) { calNotify(); return }
298
+ cal.years[year] = loaded
299
+ storageSet(CAL_KEY + year, JSON.stringify({ source: loaded.source, fetchedAt: loaded.fetchedAt, days: loaded.days }))
300
+ calNotify()
301
+ }, function () {
302
+ cal.pending[year] = false
303
+ calNotify()
304
+ })
305
+ }
306
+
307
+ /** 该年的日历;未同步/未缓存 → null,调用方按周末规则退化。 */
308
+ function daysOfYear(year) {
309
+ var entry = cachedYear(year)
310
+ return entry === null ? null : entry.days
311
+ }
312
+
313
+ /** 详情卡里的同步状态文案。 */
314
+ function calStatusText(year) {
315
+ if (cal.years[year] !== undefined) return '已同步 · ' + cal.years[year].source
316
+ if (cal.pending[year] === true) return '同步中…'
317
+ return '未同步 · 暂按周末规则'
318
+ }
319
+
320
+ /**
321
+ * 一天的"能否有高峰窗口 + 计费属性"。
322
+ *
323
+ * 官方口径(DeepSeek API 峰谷时间说明):**调休上班的周末、中国法定节假
324
+ * 日,全天均按空闲时段计费**。所以能进入高峰窗口的只有"既不是周六周日、
325
+ * 也不是法定节假日"的普通工作日:
326
+ * 法定节假日 → 全天谷价(哪怕落在周一~周五)
327
+ * 周六/周日(含调休上班) → 全天谷价(调休不改计费,只影响标签)
328
+ * 其余周一~周五 → 高峰窗口 09:00–12:00 / 14:00–18:00
329
+ * 无日历:退化成 1.0.3 的规则(周六周日谷价,其余工作日窗口)——对普通周
330
+ * 末与工作日而言与官方口径一致,唯一差别是节假日落在工作日时按高峰算。
331
+ *
332
+ * @param key - 'YYYY-MM-DD'。
333
+ * @param weekday - ISO 1=周一 … 7=周日。
334
+ * @param days - 该年归一化日历或 null。
335
+ * @returns {rest, kind, name},kind ∈ holiday | makeup | weekend | weekday。
336
+ */
337
+ function classifyDay(key, weekday, days) {
338
+ var row = days === null || days === undefined ? null : days[key]
339
+ var weekend = weekday === 6 || weekday === 7
340
+ var name = row === undefined || row === null ? '' : (row.name || '')
341
+ if (row !== undefined && row !== null && row.off === true) {
342
+ return { rest: true, kind: 'holiday', name: name }
343
+ }
107
344
  if (weekend) {
108
- peak = false
109
- nextMin = BOUNDS[0] // Monday 09:00
110
- daysAhead = ((1 - t.weekday + 7) % 7) || 7 // Sat → 2, Sun → 1
111
- var daysSinceSat = (t.weekday - 6 + 7) % 7 // Sat=0, Sun=1
112
- phaseStartMin = daysSinceSat * 1440
113
- phaseLenMin = 2 * 1440 + BOUNDS[0] // Sat 00:00 → Mon 09:00
114
- } else {
115
- peak = (now >= BOUNDS[0] && now < BOUNDS[1]) || (now >= BOUNDS[2] && now < BOUNDS[3])
116
- var next = null
345
+ // 调休上班的周末依旧全天谷价,只换个标签说明"这天本该上班"。
346
+ return { rest: true, kind: row === undefined || row === null ? 'weekend' : 'makeup', name: name }
347
+ }
348
+ return { rest: false, kind: 'weekday', name: name }
349
+ }
350
+
351
+ function classifyOrdinal(ordinal, daysOf) {
352
+ var parts = ordinalToParts(ordinal)
353
+ return classifyDay(dayKey(parts.y, parts.mo, parts.d), weekdayOf(ordinal), daysOf(parts.y))
354
+ }
355
+
356
+ /** 下一个转折点:当天剩下的窗口边界,或下一个工作日 09:00(可跨假期)。 */
357
+ function nextTransition(ordinal, nowMin, daysOf) {
358
+ if (!classifyOrdinal(ordinal, daysOf).rest) {
117
359
  for (var i = 0; i < BOUNDS.length; i++) {
118
- if (BOUNDS[i] > now) { next = BOUNDS[i]; break }
360
+ if (BOUNDS[i] > nowMin) return { ordinal: ordinal, min: BOUNDS[i] }
119
361
  }
120
- if (next !== null) {
121
- nextMin = next
122
- daysAhead = 0
123
- var ps = 0
124
- for (var j = BOUNDS.length - 1; j >= 0; j--) {
125
- if (BOUNDS[j] <= now) { ps = BOUNDS[j]; break }
126
- }
127
- phaseStartMin = ps
128
- phaseLenMin = nextMin - ps
129
- } else {
130
- nextMin = BOUNDS[0]
131
- // After 18:00: Friday next Monday 09:00 (3 days), otherwise next day 09:00.
132
- daysAhead = t.weekday === 5 ? 3 : 1
133
- phaseStartMin = BOUNDS[3]
134
- phaseLenMin = daysAhead * 1440 + BOUNDS[0] - phaseStartMin
362
+ }
363
+ for (var step = 1; step <= CAL_SCAN_LIMIT; step++) {
364
+ var ahead = ordinal + step
365
+ if (!classifyOrdinal(ahead, daysOf).rest) return { ordinal: ahead, min: BOUNDS[0] }
366
+ }
367
+ return { ordinal: ordinal + 1, min: BOUNDS[0] } // 40 天内没有工作日:理论上不可达
368
+ }
369
+
370
+ /** 上一个转折点:当天已过的最后一个边界,或上一个工作日 18:00。 */
371
+ function prevTransition(ordinal, nowMin, daysOf) {
372
+ if (!classifyOrdinal(ordinal, daysOf).rest) {
373
+ for (var i = BOUNDS.length - 1; i >= 0; i--) {
374
+ if (BOUNDS[i] <= nowMin) return { ordinal: ordinal, min: BOUNDS[i] }
135
375
  }
136
376
  }
377
+ for (var step = 1; step <= CAL_SCAN_LIMIT; step++) {
378
+ var behind = ordinal - step
379
+ if (!classifyOrdinal(behind, daysOf).rest) return { ordinal: behind, min: BOUNDS[3] }
380
+ }
381
+ return { ordinal: ordinal - 1, min: BOUNDS[3] } // 40 天内没有工作日:理论上不可达
382
+ }
137
383
 
138
- var secsToNext = daysAhead * 86400 + (nextMin - now) * 60 - t.s
139
- var elapsedMin = weekend ? daysSinceSat * 1440 + now : now - phaseStartMin
140
- var progress = phaseLenMin > 0 ? Math.min(100, Math.max(0, (elapsedMin / phaseLenMin) * 100)) : 0
141
- return { peak: peak, nextMin: nextMin, daysAhead: daysAhead, secsToNext: secsToNext, progress: progress }
384
+ /**
385
+ * Peak/off-peak state at one Beijing-time instant.
386
+ * The official work calendar decides which days may peak at all: statutory
387
+ * holidays and every Saturday/Sunday (调休 makeup weekends included) are
388
+ * valley across their whole span, so both the current state and its
389
+ * neighbouring transitions are found by scanning days instead of weekday
390
+ * arithmetic.
391
+ * @param t - bjParts() result (calendar date, clock, ISO weekday).
392
+ * @param daysOf - year → normalized days; defaults to the loaded cache.
393
+ * @returns { peak, nextMin, daysAhead, nextOrdinal, nextWeekday, secsToNext, progress, day }
394
+ */
395
+ function compute(t, daysOf) {
396
+ var lookup = daysOf === undefined ? daysOfYear : daysOf
397
+ var ordinal = dayOrdinal(t.y, t.mo, t.d)
398
+ var nowMin = t.h * 60 + t.m
399
+ var day = classifyDay(dayKey(t.y, t.mo, t.d), t.weekday, lookup(t.y))
400
+ var peak = !day.rest &&
401
+ ((nowMin >= BOUNDS[0] && nowMin < BOUNDS[1]) || (nowMin >= BOUNDS[2] && nowMin < BOUNDS[3]))
402
+ var next = nextTransition(ordinal, nowMin, lookup)
403
+ var prev = prevTransition(ordinal, nowMin, lookup)
404
+ var nowAbs = ordinal * 1440 + nowMin
405
+ var nextAbs = next.ordinal * 1440 + next.min
406
+ var prevAbs = prev.ordinal * 1440 + prev.min
407
+ var phaseLen = nextAbs - prevAbs
408
+ return {
409
+ peak: peak,
410
+ nextMin: next.min,
411
+ daysAhead: next.ordinal - ordinal,
412
+ nextOrdinal: next.ordinal,
413
+ nextWeekday: weekdayOf(next.ordinal),
414
+ secsToNext: (nextAbs - nowAbs) * 60 - t.s,
415
+ progress: phaseLen > 0 ? Math.min(100, Math.max(0, ((nowAbs - prevAbs) / phaseLen) * 100)) : 0,
416
+ day: day,
417
+ }
142
418
  }
143
419
 
144
420
  function pad(n) { return (n < 10 ? '0' : '') + n }
@@ -159,12 +435,17 @@
159
435
  var sec = s % 60
160
436
  return pad(h) + ':' + pad(m) + ':' + pad(sec)
161
437
  }
162
- /** Human label for the next boundary: "18:00" / "明天 09:00" / "周一 09:00". */
163
- function fmtBoundaryLabel(t, s) {
438
+ /**
439
+ * Human label for the next boundary: "18:00" / "明天 09:00" / "周一 09:00",
440
+ * and an explicit date once the jump passes a week — a 国庆 break or a 调休
441
+ * week easily spans six days and a weekday name would read as next week.
442
+ */
443
+ function fmtBoundaryLabel(s) {
164
444
  if (s.daysAhead === 0) return fmtBoundary(s.nextMin)
165
445
  if (s.daysAhead === 1) return '明天 ' + fmtBoundary(s.nextMin)
166
- var nextW = ((t.weekday - 1 + s.daysAhead) % 7) + 1
167
- return '周' + WEEKDAY_LABELS[nextW - 1] + ' ' + fmtBoundary(s.nextMin)
446
+ if (s.daysAhead <= 6) return '周' + WEEKDAY_LABELS[s.nextWeekday - 1] + ' ' + fmtBoundary(s.nextMin)
447
+ var parts = ordinalToParts(s.nextOrdinal)
448
+ return pad(parts.mo) + '-' + pad(parts.d) + ' ' + fmtBoundary(s.nextMin)
168
449
  }
169
450
 
170
451
  function PeakChip() {
@@ -173,25 +454,51 @@
173
454
  var setTick = tickState[1]
174
455
  var open = react.useState(false)
175
456
  var setOpen = open[1]
457
+ var setBump = react.useState(0)[1]
176
458
 
177
459
  react.useEffect(function () {
178
- var id = setInterval(function () { setTick(bjParts()) }, 1000)
460
+ var id = setInterval(function () {
461
+ var next = bjParts()
462
+ setTick(next)
463
+ ensureYear(next.y)
464
+ if (next.mo === 12) ensureYear(next.y + 1) // 跨年:提前取下一年的表
465
+ }, 1000)
179
466
  return function () { clearInterval(id) }
180
467
  }, [])
181
468
 
469
+ // The calendar arrives asynchronously — re-render when it lands. Until
470
+ // then compute() degrades to the plain weekday/weekend rule on its own.
471
+ react.useEffect(function () {
472
+ return calSubscribe(function () { setBump(function (n) { return n + 1 }) })
473
+ }, [])
474
+
182
475
  var s = compute(tick)
183
- var weekend = tick.weekday === 6 || tick.weekday === 7
476
+ var day = s.day
184
477
  var color = s.peak ? PEAK_COLOR : OFF_COLOR
185
478
  var clock = fmtClock(tick)
186
- var boundaryLabel = fmtBoundaryLabel(tick, s)
479
+ var boundaryLabel = fmtBoundaryLabel(s)
480
+ var stateWord = s.peak
481
+ ? '高峰期'
482
+ : (day.kind === 'holiday'
483
+ ? (day.name || '法定节假日')
484
+ : (day.kind === 'weekend' ? '周末谷价' : (day.kind === 'makeup' ? '调休上班 · 谷价' : '空闲期')))
485
+ var dayNote = day.kind === 'holiday'
486
+ ? ('今日' + (day.name === '' ? '为法定节假日' : '为' + day.name) + ':全天按谷价计费。')
487
+ : (day.kind === 'makeup'
488
+ ? ('今日为调休上班日(周' + WEEKDAY_LABELS[tick.weekday - 1] + '):官方口径仍全天按空闲时段计费。')
489
+ : '')
490
+ var offNote = day.kind === 'holiday'
491
+ ? ('(' + (day.name === '' ? '法定节假日' : day.name) + ' 全天谷价)')
492
+ : (day.kind === 'makeup' ? '(调休上班,全天谷价)' : (day.kind === 'weekend' ? '(周末全天谷价)' : ''))
493
+ var tooltip = (s.peak
494
+ ? '高峰期(工作日 9:00-12:00 / 14:00-18:00):价格 = 空闲 ×2。' + boundaryLabel + ' 结束。'
495
+ : '空闲期:价格为高峰一半' + offNote + ',下一高峰 ' + boundaryLabel + '。') + dayNote + '点击展开详情'
187
496
 
188
497
  var chip = react.createElement(
189
498
  'div',
190
499
  {
191
500
  onClick: function () { setOpen(!open[0]) },
192
- title: s.peak
193
- ? '高峰期(工作日 9:00-12:00 / 14:00-18:00):价格 = 空闲 ×2。' + boundaryLabel + ' 结束,点击展开详情'
194
- : '空闲期:价格为高峰一半' + (weekend ? '(周末全天谷价)' : '') + '。下一高峰 ' + boundaryLabel + ',点击展开详情',
501
+ title: tooltip,
195
502
  style: {
196
503
  display: 'inline-flex', alignItems: 'center', gap: 6,
197
504
  fontFamily: FONT, fontSize: 12, lineHeight: '20px',
@@ -203,8 +510,7 @@
203
510
  },
204
511
  [
205
512
  react.createElement('span', { key: 'dot', style: { width: 7, height: 7, borderRadius: '50%', background: color, flexShrink: 0 } }),
206
- react.createElement('span', { key: 'state', style: { fontWeight: s.peak ? 700 : 500, color: color } },
207
- s.peak ? '高峰期' : (weekend ? '周末谷价' : '空闲期')),
513
+ react.createElement('span', { key: 'state', style: { fontWeight: s.peak ? 700 : 500, color: color } }, stateWord),
208
514
  react.createElement('span', { key: 'clock', style: { fontVariantNumeric: 'tabular-nums' } }, clock),
209
515
  react.createElement('span', { key: 'count', style: { fontVariantNumeric: 'tabular-nums' } },
210
516
  s.peak
@@ -233,7 +539,7 @@
233
539
  [
234
540
  react.createElement('div', { key: 'l1', style: { display: 'flex', alignItems: 'center', gap: 6 } }, [
235
541
  react.createElement('span', { key: 'st', style: { fontWeight: 700, color: color } },
236
- s.peak ? '高峰时段' : (weekend ? '周末谷价' : '空闲时段')),
542
+ s.peak ? '高峰时段' : stateWord),
237
543
  react.createElement('span', { key: 'tm', style: { fontVariantNumeric: 'tabular-nums' } },
238
544
  clock + '(北京)'),
239
545
  react.createElement('span', { key: 'price', style: { color: color, fontWeight: 500 } },
@@ -241,8 +547,11 @@
241
547
  ]),
242
548
  react.createElement('div', { key: 'l2' },
243
549
  '高峰窗口(工作日):09:00–12:00 / 14:00–18:00'),
244
- react.createElement('div', { key: 'l2b', style: { color: weekend ? OFF_COLOR : DIM, fontWeight: weekend ? 600 : 400 } },
245
- '周末(周六、周日):全天按谷价计费'),
550
+ react.createElement('div', { key: 'l2b', style: { color: day.rest ? OFF_COLOR : DIM, fontWeight: day.rest ? 600 : 400 } },
551
+ '法定节假日与周末(周六、周日):全天按谷价计费'),
552
+ react.createElement('div', { key: 'l2c', style: { color: day.kind === 'makeup' ? OFF_COLOR : DIM, fontWeight: day.kind === 'makeup' ? 600 : 400 } },
553
+ '调休上班的周末:仍全天按谷价计费(官方口径)'),
554
+ dayNote === '' ? null : react.createElement('div', { key: 'daynote', style: { color: color, fontWeight: 600 } }, dayNote),
246
555
  react.createElement('div', { key: 'l3', style: { fontVariantNumeric: 'tabular-nums' } },
247
556
  '下一转折:' + boundaryLabel + ' · 倒计时 ' + fmtDurFull(s.secsToNext)),
248
557
  react.createElement('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
@@ -255,6 +564,8 @@
255
564
  react.createElement('span', { key: 'pct', style: { fontSize: 10.5, fontVariantNumeric: 'tabular-nums' } },
256
565
  s.progress.toFixed(0) + '%'),
257
566
  ]),
567
+ react.createElement('div', { key: 'cal', style: { fontSize: 10.5, opacity: 0.85 } },
568
+ '节假日表(北京):' + calStatusText(tick.y)),
258
569
  react.createElement('div', { key: 'foot', style: { fontSize: 10.5, lineHeight: 1.5, opacity: 0.85 } },
259
570
  '官方峰谷定价 8/17 生效,8/23 起周末全天谷价;空闲价 = 高峰一半。'),
260
571
  ],
@@ -286,6 +597,23 @@
286
597
 
287
598
  exports.apply = apply
288
599
  exports.inject = inject
600
+ // Test seam for scripts/test-peak.mjs: the harness drives these pure
601
+ // functions directly instead of mirroring them, so the shipped algorithm
602
+ // is the one under test. The loader never reads it.
603
+ exports.__test = {
604
+ compute: compute,
605
+ classifyDay: classifyDay,
606
+ dayKey: dayKey,
607
+ dayOrdinal: dayOrdinal,
608
+ ordinalToParts: ordinalToParts,
609
+ weekdayOf: weekdayOf,
610
+ normalizeCalendar: normalizeCalendar,
611
+ calRoute: CAL_ROUTE,
612
+ calCorsUrl: CAL_CORS_URL,
613
+ calStatusText: calStatusText,
614
+ ensureYear: ensureYear,
615
+ daysOfYear: daysOfYear,
616
+ }
289
617
  return module.exports
290
618
  }
291
619