dsh-usage-badge 0.1.0

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/lib/fold.js ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * dsh-usage-badge — session-log folding and its incremental cache.
3
+ *
4
+ * DSH persists one append-only log per session under `$DSH_HOME/sessions`. This
5
+ * module folds every log it finds into per-day / per-(provider, model) token
6
+ * buckets holding the raw per-request usage records, which is what lets the
7
+ * pricing layer apply a different multiplier to each request instead of pricing
8
+ * a day at one flat rate.
9
+ *
10
+ * Ported from the desktop shell's `usage-sidecar.mjs` fold, with the same
11
+ * discovery rules (generation selection per session directory) and the same
12
+ * size/mtime cursor that keeps a refresh proportional to what actually changed.
13
+ * The cache file is this plugin's own (`usage-badge-cache.json`) rather than the
14
+ * shell's `usage-cache.json`: both read the same logs, but sharing one cache file
15
+ * would mean two writers racing over it.
16
+ *
17
+ * @module dsh-usage-badge/fold
18
+ */
19
+
20
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
21
+ import { homedir } from 'node:os'
22
+ import { join, resolve } from 'node:path'
23
+ import { zstdDecompressSync } from 'node:zlib'
24
+
25
+ import { dayKey, initHourly, localHour } from './pricing.js'
26
+
27
+ /** Bucket key and display name used when a log records no provider/model. */
28
+ export const UNKNOWN = 'unknown'
29
+
30
+ /** Matches `session.jsonl`, `session.jsonl.zstd`, `session.v2.jsonl(.zstd)`, … */
31
+ const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/
32
+
33
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
34
+
35
+ /** This plugin's cache format version. Unknown versions are ignored, not migrated. */
36
+ const CACHE_VERSION = 1
37
+
38
+ /** Files folded between event-loop yields, so a cold scan never stalls the host. */
39
+ const YIELD_EVERY = 25
40
+
41
+ /** Expand `~`, `~/`, `~\` against the OS home. */
42
+ function expandHome(path) {
43
+ if (path === '~') return homedir()
44
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
45
+ return path
46
+ }
47
+
48
+ /**
49
+ * Resolve the DeepSeek Harness home.
50
+ *
51
+ * This mirrors `resolveDshHome()` from `@deepseek-ai/dsh-home-paths`, inlined so
52
+ * the plugin needs no peer dependency: an explicit path, then a non-blank
53
+ * `$DSH_HOME`, then `~/.dsh`. A whitespace-only `$DSH_HOME` counts as unset, so a
54
+ * blank override can never resolve the home to the current directory.
55
+ */
56
+ export function resolveDshHome(configured, env = process.env) {
57
+ const fromEnv = env?.DSH_HOME
58
+ const chosen =
59
+ configured ?? (typeof fromEnv === 'string' && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), '.dsh'))
60
+ return resolve(expandHome(chosen))
61
+ }
62
+
63
+ /**
64
+ * Decode a log body that may hold one or more concatenated zstd frames.
65
+ *
66
+ * DSH appends frames as a session grows, so a `.zstd` log is a frame sequence
67
+ * rather than one stream; `zstdDecompressSync` handles a single frame, so the
68
+ * buffer is split on the frame magic first. A body with no frame magic is
69
+ * returned as UTF-8, which keeps plain `.jsonl` and any partially written file
70
+ * readable instead of throwing.
71
+ */
72
+ export function decodeLogBody(buf) {
73
+ const starts = []
74
+ let i = 0
75
+ while ((i = buf.indexOf(ZSTD_MAGIC, i)) !== -1) {
76
+ starts.push(i)
77
+ i += 1
78
+ }
79
+ if (starts.length === 0) return buf.toString('utf8')
80
+ if (typeof zstdDecompressSync !== 'function') {
81
+ throw new Error('this Node build has no zlib.zstdDecompressSync; a zstd session log cannot be read')
82
+ }
83
+ let out = ''
84
+ for (let k = 0; k < starts.length; k++) {
85
+ const end = k + 1 < starts.length ? starts[k + 1] : buf.length
86
+ out += zstdDecompressSync(buf.subarray(starts[k], end)).toString('utf8')
87
+ }
88
+ return out
89
+ }
90
+
91
+ function logParent(file) {
92
+ const index = Math.max(file.lastIndexOf('\\'), file.lastIndexOf('/'))
93
+ return index >= 0 ? file.slice(0, index) : ''
94
+ }
95
+
96
+ /** Rank a log file within its session directory: generation first, then `.zstd` over plain. */
97
+ function logRank(file) {
98
+ const index = Math.max(file.lastIndexOf('\\'), file.lastIndexOf('/'))
99
+ const name = index >= 0 ? file.slice(index + 1) : file
100
+ const match = SESSION_LOG_RE.exec(name)
101
+ if (!match) return -1
102
+ return (Number(match[1]) || 0) * 2 + (name.endsWith('.zstd') ? 1 : 0)
103
+ }
104
+
105
+ /**
106
+ * Every log file under `dir`, keeping only the newest generation per session
107
+ * directory.
108
+ *
109
+ * A migrated session can leave older artifacts beside its current log; those are
110
+ * generations of one session, not sessions to add up. Picking the highest
111
+ * generation per directory is also what keeps a future `v3`/`v4` log correct
112
+ * without a code change.
113
+ */
114
+ export function listLogFiles(dir, root = dir, out = []) {
115
+ let entries
116
+ try {
117
+ entries = readdirSync(dir, { withFileTypes: true })
118
+ } catch {
119
+ return out
120
+ }
121
+ for (const entry of entries) {
122
+ const full = join(dir, entry.name)
123
+ if (entry.isDirectory()) listLogFiles(full, root, out)
124
+ else if (SESSION_LOG_RE.test(entry.name)) out.push(full)
125
+ }
126
+ if (dir === root) {
127
+ const best = new Map()
128
+ for (const file of out) {
129
+ const parent = logParent(file)
130
+ const current = best.get(parent)
131
+ if (!current || logRank(file) > logRank(current)) best.set(parent, file)
132
+ }
133
+ return out.filter((file) => best.get(logParent(file)) === file)
134
+ }
135
+ return out
136
+ }
137
+
138
+ /** The session id a log path belongs to (its containing directory name). */
139
+ export function sessionIdFromLogPath(file) {
140
+ const parts = file.split(/[\\/]/)
141
+ return parts.length >= 2 ? parts[parts.length - 2] : file
142
+ }
143
+
144
+ function emptyBucket(provider, model) {
145
+ return {
146
+ provider,
147
+ model,
148
+ input: 0,
149
+ cacheRead: 0,
150
+ cacheWrite: 0,
151
+ output: 0,
152
+ requests: 0,
153
+ hourly: initHourly(),
154
+ usageRecords: [],
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Add `source` into `target`.
160
+ *
161
+ * A missing per-request list marks a legacy aggregate row: mixing one into a
162
+ * bucket that has a list would silently drop the aggregate's share from the
163
+ * context-tier price, so the list is dropped to `null` for the whole bucket
164
+ * instead, and that bucket falls back to flat pricing. Appending is looped rather
165
+ * than spread because one day bucket can hold hundreds of thousands of requests,
166
+ * which would overflow the argument list long before it exhausted memory.
167
+ */
168
+ function addBucket(target, source) {
169
+ target.input += Number(source?.input) || 0
170
+ target.cacheRead += Number(source?.cacheRead) || 0
171
+ target.cacheWrite += Number(source?.cacheWrite) || 0
172
+ target.output += Number(source?.output) || 0
173
+ target.requests += Number(source?.requests) || 0
174
+ if (!Array.isArray(target.hourly)) target.hourly = initHourly()
175
+ if (Array.isArray(source?.hourly)) {
176
+ for (let hour = 0; hour < 24; hour++) {
177
+ const from = source.hourly[hour]
178
+ const to = target.hourly[hour]
179
+ if (!from || !to) continue
180
+ to.input += Number(from.input) || 0
181
+ to.cacheRead += Number(from.cacheRead) || 0
182
+ to.cacheWrite += Number(from.cacheWrite) || 0
183
+ to.output += Number(from.output) || 0
184
+ to.requests += Number(from.requests) || 0
185
+ }
186
+ }
187
+ if (!Array.isArray(target.usageRecords) || !Array.isArray(source?.usageRecords)) {
188
+ target.usageRecords = null
189
+ } else {
190
+ for (const usage of source.usageRecords) {
191
+ if (usage && typeof usage === 'object') target.usageRecords.push(usage)
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Fold one session log into per-day, per-route buckets.
198
+ *
199
+ * Route attribution follows the log's own control events: `request/header` (and
200
+ * `request/context`) carry the provider and model, and each `assistant/message`
201
+ * carries the provider-reported `usage` for one completed request. Requests are
202
+ * stamped with the hour they were *sent* where the log allows it, because a
203
+ * streaming call can cross a peak/valley boundary and filing it by completion
204
+ * would price it in the wrong band.
205
+ *
206
+ * @returns {object|null} the session record, or null when the file is unreadable.
207
+ */
208
+ export function foldSession(file, previous) {
209
+ const sessionId = sessionIdFromLogPath(file)
210
+ let stat
211
+ try {
212
+ stat = statSync(file)
213
+ } catch {
214
+ return null
215
+ }
216
+ let body
217
+ try {
218
+ body = readFileSync(file)
219
+ } catch {
220
+ return null
221
+ }
222
+ let text
223
+ try {
224
+ text = decodeLogBody(body)
225
+ } catch {
226
+ // An unreadable body keeps whatever this session contributed before, so a
227
+ // transient decompression failure cannot erase history.
228
+ return previous ?? null
229
+ }
230
+
231
+ const sessionDays = new Map()
232
+ let provider = null
233
+ let model = null
234
+ let maxSeq = -1
235
+
236
+ for (const line of text.split('\n')) {
237
+ if (!line) continue
238
+ let ev
239
+ try {
240
+ ev = JSON.parse(line)
241
+ } catch {
242
+ continue // a torn tail line is expected on a log being appended
243
+ }
244
+ const seq = typeof ev?.seq === 'number' ? ev.seq : -1
245
+ if (seq > maxSeq) maxSeq = seq
246
+ const data = ev?.data ?? {}
247
+ if (ev?.type === 'request/header') {
248
+ const config = data.header?.config ?? {}
249
+ provider = config.provider ?? data.provider ?? null
250
+ model = config.model ?? data.model ?? null
251
+ } else if (ev?.type === 'request/context') {
252
+ provider = data.provider ?? provider
253
+ model = data.model ?? model
254
+ } else if (ev?.type === 'assistant/message') {
255
+ const usage = data.usage
256
+ if (!usage || typeof usage !== 'object') continue
257
+ const pm = provider ?? UNKNOWN
258
+ const mm = model ?? UNKNOWN
259
+ const date = dayKey(ev.time ?? Date.now())
260
+ let dayObj = sessionDays.get(date)
261
+ if (!dayObj) {
262
+ dayObj = new Map()
263
+ sessionDays.set(date, dayObj)
264
+ }
265
+ const key = `${pm}|${mm}`
266
+ const bucket = dayObj.get(key) ?? emptyBucket(pm, mm)
267
+ const record = {
268
+ input: Number(usage.inputTokens) || 0,
269
+ cacheRead: Number(usage.cacheReadTokens) || 0,
270
+ cacheWrite: Number(usage.cacheWriteTokens) || 0,
271
+ output: Number(usage.outputTokens) || 0,
272
+ }
273
+ const hour = localHour(ev.time ?? Date.now())
274
+ bucket.input += record.input
275
+ bucket.cacheRead += record.cacheRead
276
+ bucket.cacheWrite += record.cacheWrite
277
+ bucket.output += record.output
278
+ bucket.requests += 1
279
+ if (Array.isArray(bucket.usageRecords)) bucket.usageRecords.push({ ...record, hour })
280
+ const hourBucket = bucket.hourly[hour]
281
+ hourBucket.input += record.input
282
+ hourBucket.cacheRead += record.cacheRead
283
+ hourBucket.cacheWrite += record.cacheWrite
284
+ hourBucket.output += record.output
285
+ hourBucket.requests += 1
286
+ dayObj.set(key, bucket)
287
+ }
288
+ }
289
+
290
+ return {
291
+ sessionId,
292
+ path: file,
293
+ fileSize: stat.size,
294
+ fileMtimeMs: stat.mtimeMs || 0,
295
+ seq: maxSeq,
296
+ provider,
297
+ model,
298
+ days: sessionDays,
299
+ }
300
+ }
301
+
302
+ function serializeSession(record) {
303
+ const days = {}
304
+ for (const [date, dayObj] of record.days ?? []) {
305
+ days[date] = {}
306
+ for (const [key, bucket] of dayObj) days[date][key] = bucket
307
+ }
308
+ return {
309
+ path: record.path,
310
+ fileSize: record.fileSize,
311
+ fileMtimeMs: record.fileMtimeMs,
312
+ seq: record.seq,
313
+ provider: record.provider,
314
+ model: record.model,
315
+ days,
316
+ }
317
+ }
318
+
319
+ function deserializeSession(sessionId, record) {
320
+ if (!record || typeof record !== 'object' || !record.days || typeof record.days !== 'object') return null
321
+ const days = new Map()
322
+ for (const [date, sourceDay] of Object.entries(record.days)) {
323
+ if (!sourceDay || typeof sourceDay !== 'object') continue
324
+ const dayObj = new Map()
325
+ for (const [key, source] of Object.entries(sourceDay)) {
326
+ if (!source || typeof source !== 'object') continue
327
+ const bucket = emptyBucket(String(source.provider ?? UNKNOWN), String(source.model ?? UNKNOWN))
328
+ addBucket(bucket, source)
329
+ dayObj.set(key, bucket)
330
+ }
331
+ days.set(date, dayObj)
332
+ }
333
+ return {
334
+ sessionId,
335
+ path: String(record.path ?? ''),
336
+ fileSize: Number(record.fileSize) || 0,
337
+ fileMtimeMs: Number(record.fileMtimeMs) || 0,
338
+ seq: Number(record.seq) || -1,
339
+ provider: record.provider ?? null,
340
+ model: record.model ?? null,
341
+ days,
342
+ }
343
+ }
344
+
345
+ /**
346
+ * An incremental view over every session log.
347
+ *
348
+ * `refresh()` re-reads only logs whose size, mtime or path changed, so a steady
349
+ * state costs one `stat` per session. The merged `days` map is rebuilt from the
350
+ * per-session records on every refresh; that is deliberate, because rebuilding is
351
+ * what makes a deleted log keep its cached contribution instead of erasing
352
+ * history that the log no longer exists to prove.
353
+ */
354
+ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
355
+ const records = new Map()
356
+ const cursors = new Map()
357
+ let days = new Map()
358
+ let saveTimer = null
359
+ let lastRefresh = null
360
+
361
+ function mergeDays() {
362
+ const merged = new Map()
363
+ for (const record of records.values()) {
364
+ for (const [date, sourceDay] of record.days ?? []) {
365
+ let dayObj = merged.get(date)
366
+ if (!dayObj) {
367
+ dayObj = new Map()
368
+ merged.set(date, dayObj)
369
+ }
370
+ for (const [key, source] of sourceDay) {
371
+ const target = dayObj.get(key) ?? emptyBucket(source.provider, source.model)
372
+ addBucket(target, source)
373
+ dayObj.set(key, target)
374
+ }
375
+ }
376
+ }
377
+ days = merged
378
+ }
379
+
380
+ function loadCache() {
381
+ let raw
382
+ try {
383
+ raw = JSON.parse(readFileSync(cachePath, 'utf8'))
384
+ } catch {
385
+ return false
386
+ }
387
+ if (!raw || raw.version !== CACHE_VERSION || !raw.sessions || typeof raw.sessions !== 'object') return false
388
+ for (const [sessionId, source] of Object.entries(raw.sessions)) {
389
+ const record = deserializeSession(sessionId, source)
390
+ if (!record) continue
391
+ records.set(sessionId, record)
392
+ cursors.set(sessionId, {
393
+ seq: record.seq,
394
+ provider: record.provider,
395
+ model: record.model,
396
+ fileSize: record.fileSize,
397
+ fileMtimeMs: record.fileMtimeMs,
398
+ path: record.path,
399
+ })
400
+ }
401
+ mergeDays()
402
+ log(`cache loaded: ${records.size} session(s)`)
403
+ return true
404
+ }
405
+
406
+ function saveCache() {
407
+ const sessions = {}
408
+ for (const [sessionId, record] of records) sessions[sessionId] = serializeSession(record)
409
+ // The temporary name carries pid, time and entropy so a restarted host can
410
+ // never collide with a stale temp file another process still holds open.
411
+ const temp = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
412
+ try {
413
+ mkdirSync(join(cachePath, '..'), { recursive: true })
414
+ writeFileSync(temp, JSON.stringify({ version: CACHE_VERSION, sessions }))
415
+ try {
416
+ renameSync(temp, cachePath)
417
+ } catch {
418
+ // Windows cannot rename over an existing file; the complete temp file is
419
+ // already on disk, so removing the old one first is safe here.
420
+ try {
421
+ unlinkSync(cachePath)
422
+ } catch {
423
+ // absent is fine
424
+ }
425
+ renameSync(temp, cachePath)
426
+ }
427
+ } catch (error) {
428
+ try {
429
+ unlinkSync(temp)
430
+ } catch {
431
+ // best-effort cleanup only
432
+ }
433
+ log(`cache save failed: ${error?.message ?? error}`)
434
+ }
435
+ }
436
+
437
+ function scheduleCacheSave() {
438
+ if (saveTimer) return
439
+ saveTimer = setTimeout(() => {
440
+ saveTimer = null
441
+ saveCache()
442
+ }, 2000)
443
+ saveTimer.unref?.()
444
+ }
445
+
446
+ /**
447
+ * Fold every changed log.
448
+ *
449
+ * @returns {Promise<{files:number, folded:number, ms:number}>} what this pass did.
450
+ */
451
+ async function refresh() {
452
+ const started = Date.now()
453
+ const files = listLogFiles(sessionsRoot)
454
+ const seen = new Set()
455
+ let folded = 0
456
+ let sinceYield = 0
457
+ for (const file of files) {
458
+ const sessionId = sessionIdFromLogPath(file)
459
+ seen.add(sessionId)
460
+ const cursor = cursors.get(sessionId)
461
+ let stat
462
+ try {
463
+ stat = statSync(file)
464
+ } catch {
465
+ continue
466
+ }
467
+ if (cursor && cursor.fileSize === stat.size && cursor.fileMtimeMs === (stat.mtimeMs || 0) && cursor.path === file) {
468
+ continue
469
+ }
470
+ const record = foldSession(file, records.get(sessionId))
471
+ if (record) {
472
+ records.set(sessionId, record)
473
+ cursors.set(sessionId, {
474
+ seq: record.seq,
475
+ provider: record.provider,
476
+ model: record.model,
477
+ fileSize: record.fileSize,
478
+ fileMtimeMs: record.fileMtimeMs,
479
+ path: record.path,
480
+ })
481
+ folded++
482
+ }
483
+ if (++sinceYield >= YIELD_EVERY) {
484
+ sinceYield = 0
485
+ await new Promise((r) => setImmediate(r))
486
+ }
487
+ }
488
+ // A session whose log disappeared keeps its record (and therefore its
489
+ // history) but stops being refreshed.
490
+ if (folded > 0) {
491
+ mergeDays()
492
+ scheduleCacheSave()
493
+ }
494
+ lastRefresh = { files: files.length, folded, ms: Date.now() - started, at: Date.now() }
495
+ return lastRefresh
496
+ }
497
+
498
+ return {
499
+ loadCache,
500
+ refresh,
501
+ saveCache,
502
+ get days() {
503
+ return days
504
+ },
505
+ get sessionCount() {
506
+ return records.size
507
+ },
508
+ get lastRefresh() {
509
+ return lastRefresh
510
+ },
511
+ }
512
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "note": "中国法定节假日与调休上班日。数据来自国务院办公厅的年度放假安排通知;DeepSeek 的高峰时段定义依赖该日历:「北京时间周一至周五(不含中国法定节假日)9:00-12:00、14:00-18:00 为高峰时段;其余时段,包括周末及中国法定节假日全天均为空闲时段」。注意:政策把「周末」整体划为空闲时段,所以 blocks 里的放假日才影响计价,workdays 里的调休上班日只是存档(调休后的周日仍是周末,仍按空闲价)。新增年份时按同样格式补充 years.<year> 即可,blocks 用日期区间表示,加载时展开。",
4
+ "source": {
5
+ "2025": {
6
+ "document": "国办发明电〔2024〕7号",
7
+ "published": "2024-11-12",
8
+ "url": "https://www.gov.cn/gongbao/2024/issue_11726/202411/content_6989767.html"
9
+ },
10
+ "2026": {
11
+ "document": "国办发明电〔2025〕7号",
12
+ "published": "2025-11-04",
13
+ "url": "https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"
14
+ }
15
+ },
16
+ "years": {
17
+ "2025": {
18
+ "blocks": [
19
+ { "name": "元旦", "from": "2025-01-01", "to": "2025-01-01" },
20
+ { "name": "春节", "from": "2025-01-28", "to": "2025-02-04" },
21
+ { "name": "清明节", "from": "2025-04-04", "to": "2025-04-06" },
22
+ { "name": "劳动节", "from": "2025-05-01", "to": "2025-05-05" },
23
+ { "name": "端午节", "from": "2025-05-31", "to": "2025-06-02" },
24
+ { "name": "国庆节、中秋节", "from": "2025-10-01", "to": "2025-10-08" }
25
+ ],
26
+ "workdays": ["2025-01-26", "2025-02-08", "2025-04-27", "2025-09-28", "2025-10-11"]
27
+ },
28
+ "2026": {
29
+ "blocks": [
30
+ { "name": "元旦", "from": "2026-01-01", "to": "2026-01-03" },
31
+ { "name": "春节", "from": "2026-02-15", "to": "2026-02-23" },
32
+ { "name": "清明节", "from": "2026-04-04", "to": "2026-04-06" },
33
+ { "name": "劳动节", "from": "2026-05-01", "to": "2026-05-05" },
34
+ { "name": "端午节", "from": "2026-06-19", "to": "2026-06-21" },
35
+ { "name": "中秋节", "from": "2026-09-25", "to": "2026-09-27" },
36
+ { "name": "国庆节", "from": "2026-10-01", "to": "2026-10-07" }
37
+ ],
38
+ "workdays": ["2026-01-04", "2026-02-14", "2026-02-28", "2026-05-09", "2026-09-20", "2026-10-10"]
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * dsh-usage-badge — the legal-holiday calendar behind the peak/valley rule.
3
+ *
4
+ * DeepSeek's published policy is explicit about holidays:
5
+ *
6
+ * > 空闲时段价格为高峰时段价格的一半。北京时间周一至周五(**不含中国法定节假日**)
7
+ * > 9:00 - 12:00、14:00 - 18:00 为高峰时段;其余时段,包括周末及**中国法定节假日全天均为空闲时段**。
8
+ *
9
+ * So a weekday holiday must be billed entirely off-peak, and a 调休 weekend that
10
+ * the State Council turns into a working day must be billed as a working day
11
+ * again. Both directions need the real calendar, which is why this module ships
12
+ * one rather than inferring from the weekday alone.
13
+ *
14
+ * The bundled table is per-year data transcribed from the State Council notices
15
+ * (see `holidays-cn.json`, which records document numbers and URLs). A year the
16
+ * table does not cover is reported rather than guessed, and `config.holidays`
17
+ * can always add or override dates by hand.
18
+ *
19
+ * @module dsh-usage-badge/holidays
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs'
23
+
24
+ import { dayKey } from './pricing.js'
25
+
26
+ const TABLE_URL = new URL('./holidays-cn.json', import.meta.url)
27
+
28
+ let bundledCache = null
29
+
30
+ /** Expand an inclusive `YYYY-MM-DD` range into its dates, walking in UTC to dodge DST. */
31
+ function expandRange(from, to) {
32
+ const out = []
33
+ const start = Date.parse(`${from}T00:00:00Z`)
34
+ const end = Date.parse(`${to}T00:00:00Z`)
35
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return out
36
+ for (let ms = start; ms <= end; ms += 86400000) out.push(new Date(ms).toISOString().slice(0, 10))
37
+ return out
38
+ }
39
+
40
+ /**
41
+ * Load and expand the bundled calendar.
42
+ *
43
+ * @returns {{holidays: Map<string,string>, workdays: Set<string>, years: Set<string>, source: object}|null}
44
+ * `holidays` maps a date to its festival name; `null` when the bundled file
45
+ * cannot be read, which is treated as "no bundled calendar" rather than a crash.
46
+ */
47
+ export function loadBundledTable() {
48
+ if (bundledCache !== null) return bundledCache
49
+ try {
50
+ const raw = JSON.parse(readFileSync(TABLE_URL, 'utf8'))
51
+ const holidays = new Map()
52
+ const workdays = new Set()
53
+ const years = new Set()
54
+ for (const [year, data] of Object.entries(raw.years ?? {})) {
55
+ years.add(year)
56
+ for (const block of data.blocks ?? []) {
57
+ for (const date of expandRange(block.from, block.to)) holidays.set(date, block.name ?? '')
58
+ }
59
+ for (const date of data.workdays ?? []) workdays.add(date)
60
+ }
61
+ bundledCache = { holidays, workdays, years, source: raw.source ?? {}, note: raw.note ?? '' }
62
+ } catch {
63
+ bundledCache = { holidays: new Map(), workdays: new Set(), years: new Set(), source: {}, note: '' }
64
+ }
65
+ return bundledCache
66
+ }
67
+
68
+ /**
69
+ * Resolve the day-classification rules a pricing document asks for.
70
+ *
71
+ * The document opts in through a top-level `holidays` object, kept separate from
72
+ * `timeOfUse` because it answers a different question: `timeOfUse` says *when*
73
+ * peak windows are, `holidays` says *which days* they can apply to at all. It also
74
+ * means one holiday calendar governs every per-model `timeOfUse` row.
75
+ *
76
+ * | `holidays.source` | meaning |
77
+ * |---|---|
78
+ * | absent / `"none"` | no holiday handling — the historical behavior |
79
+ * | `"cn"` | the bundled China calendar, plus any `extra` dates |
80
+ * | `"custom"` | only `extra` dates, no bundled calendar |
81
+ *
82
+ * Only *holidays* affect pricing. The bundled table also records the 调休 working
83
+ * days, and they are deliberately not resolved into a rule here: the published
84
+ * policy makes every weekend off-peak in full, and a 调休 day is still a 周末, so
85
+ * treating one as a working day would charge peak rates on a day the page calls
86
+ * off-peak. The dates stay in the data file as provenance.
87
+ *
88
+ * @param {object|undefined} config - the pricing document's `holidays` field.
89
+ * @returns {{enabled:boolean, holidays:Map<string,string>, source:object, years:Set<string>, missingYears:string[]}}
90
+ */
91
+ export function resolveHolidayRules(config) {
92
+ const source = config?.source === 'cn' || config?.source === 'custom' ? config.source : 'none'
93
+ const bundled = source === 'cn' ? loadBundledTable() : { holidays: new Map(), workdays: new Set(), years: new Set(), source: {} }
94
+ const enabled = source !== 'none'
95
+
96
+ const holidays = new Map(enabled && source === 'cn' ? bundled.holidays : [])
97
+ for (const date of config?.extra ?? []) if (typeof date === 'string' && date) holidays.set(date.trim(), '自定义')
98
+
99
+ // A year with no bundled block is worth surfacing: its holidays would otherwise
100
+ // silently be treated as ordinary weekdays.
101
+ const missingYears = []
102
+ if (enabled && source === 'cn') {
103
+ const thisYear = new Date().getFullYear()
104
+ for (const year of [thisYear, thisYear + 1]) {
105
+ if (!bundled.years.has(String(year))) missingYears.push(String(year))
106
+ }
107
+ }
108
+
109
+ return { enabled, holidays, source: bundled.source, years: bundled.years, missingYears, source_kind: source }
110
+ }
111
+
112
+ /**
113
+ * Classify one date under the resolved rules.
114
+ *
115
+ * @param {string} date - local `YYYY-MM-DD` day key.
116
+ * @param {object} rules - result of {@link resolveHolidayRules}.
117
+ * @returns {{class:'holiday'|'normal', name:string|null}}
118
+ */
119
+ export function classifyDate(date, rules) {
120
+ if (!rules?.enabled) return { class: 'normal', name: null }
121
+ const name = rules.holidays?.get?.(date)
122
+ if (name !== undefined) return { class: 'holiday', name: name || null }
123
+ return { class: 'normal', name: null }
124
+ }
125
+
126
+ /**
127
+ * The holiday-related facts the browser half displays: whether the calendar is on,
128
+ * which years it covers, and how the current day is classified.
129
+ */
130
+ export function describeCalendar(rules, now = Date.now()) {
131
+ const date = dayKey(now)
132
+ const today = classifyDate(date, rules)
133
+ return {
134
+ enabled: Boolean(rules?.enabled),
135
+ source: rules?.source_kind ?? 'none',
136
+ years: [...(rules?.years ?? [])].sort(),
137
+ missingYears: rules?.missingYears ?? [],
138
+ today: { date, class: today.class, name: today.name },
139
+ counts: { holidays: rules?.holidays?.size ?? 0 },
140
+ }
141
+ }