@linxin666/dsh-usage 0.4.4 → 0.4.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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Chinese public-holiday dates, Beijing calendar days, as `YYYY-MM-DD`.
3
+ *
4
+ * The DeepSeek peak rule bills a public holiday off-peak in full, and the
5
+ * State Council publishes these dates per year, so they cannot be derived
6
+ * from a rule. This module is that data; `pricing.ts` keeps the arithmetic.
7
+ *
8
+ * Adjusted workdays are deliberately absent: the provider counts the
9
+ * calendar day, so an adjusted Saturday or Sunday is off-peak exactly like
10
+ * any other weekend and listing it would price it as peak.
11
+ *
12
+ * Source: the yearly schedules in bastengao/chinese-holidays-node
13
+ * (`data/<year>.json`, MIT). Only the `holiday` entries are taken; that
14
+ * package's runtime code (which fetches over the network) and its
15
+ * dependencies are not vendored.
16
+ *
17
+ * Coverage ends at the newest year below. A year with no entry is priced as
18
+ * if it had no holidays, which is the documented default of
19
+ * {@link PublicHolidayCalendar} in `pricing.ts`.
20
+ * @module @linxin666/dsh-usage/core/holidays
21
+ */
22
+
23
+ /** Public-holiday dates keyed by Beijing calendar year. */
24
+ export const CHINESE_PUBLIC_HOLIDAYS: Readonly<Record<string, readonly string[]>> = {
25
+ "2020": [
26
+ "2020-01-24",
27
+ "2020-01-25",
28
+ "2020-01-26",
29
+ "2020-01-27",
30
+ "2020-01-28",
31
+ "2020-01-29",
32
+ "2020-01-30",
33
+ "2020-04-04",
34
+ "2020-04-05",
35
+ "2020-04-06",
36
+ "2020-05-01",
37
+ "2020-05-02",
38
+ "2020-05-03",
39
+ "2020-05-04",
40
+ "2020-05-05",
41
+ "2020-06-25",
42
+ "2020-06-26",
43
+ "2020-06-27",
44
+ "2020-10-01",
45
+ "2020-10-02",
46
+ "2020-10-03",
47
+ "2020-10-04",
48
+ "2020-10-05",
49
+ "2020-10-06",
50
+ "2020-10-07",
51
+ "2020-10-08"
52
+ ],
53
+ "2021": [
54
+ "2021-01-01",
55
+ "2021-01-02",
56
+ "2021-01-03",
57
+ "2021-02-11",
58
+ "2021-02-12",
59
+ "2021-02-13",
60
+ "2021-02-14",
61
+ "2021-02-15",
62
+ "2021-02-16",
63
+ "2021-02-17",
64
+ "2021-04-03",
65
+ "2021-04-04",
66
+ "2021-04-05",
67
+ "2021-05-01",
68
+ "2021-05-02",
69
+ "2021-05-03",
70
+ "2021-05-04",
71
+ "2021-05-05",
72
+ "2021-06-12",
73
+ "2021-06-13",
74
+ "2021-06-14",
75
+ "2021-09-19",
76
+ "2021-09-20",
77
+ "2021-09-21",
78
+ "2021-10-01",
79
+ "2021-10-02",
80
+ "2021-10-03",
81
+ "2021-10-04",
82
+ "2021-10-05",
83
+ "2021-10-06",
84
+ "2021-10-07"
85
+ ],
86
+ "2022": [
87
+ "2022-01-01",
88
+ "2022-01-02",
89
+ "2022-01-03",
90
+ "2022-01-31",
91
+ "2022-02-01",
92
+ "2022-02-02",
93
+ "2022-02-03",
94
+ "2022-02-04",
95
+ "2022-02-05",
96
+ "2022-02-06",
97
+ "2022-04-03",
98
+ "2022-04-04",
99
+ "2022-04-05",
100
+ "2022-04-30",
101
+ "2022-05-01",
102
+ "2022-05-02",
103
+ "2022-05-03",
104
+ "2022-05-04",
105
+ "2022-06-03",
106
+ "2022-06-04",
107
+ "2022-06-05",
108
+ "2022-09-10",
109
+ "2022-09-11",
110
+ "2022-09-12",
111
+ "2022-10-01",
112
+ "2022-10-02",
113
+ "2022-10-03",
114
+ "2022-10-04",
115
+ "2022-10-05",
116
+ "2022-10-06",
117
+ "2022-10-07"
118
+ ],
119
+ "2023": [
120
+ "2022-12-31",
121
+ "2023-01-01",
122
+ "2023-01-02",
123
+ "2023-01-21",
124
+ "2023-01-22",
125
+ "2023-01-23",
126
+ "2023-01-24",
127
+ "2023-01-25",
128
+ "2023-01-26",
129
+ "2023-01-27",
130
+ "2023-04-29",
131
+ "2023-04-30",
132
+ "2023-05-01",
133
+ "2023-05-02",
134
+ "2023-05-03",
135
+ "2023-06-22",
136
+ "2023-06-23",
137
+ "2023-06-24",
138
+ "2023-09-29",
139
+ "2023-09-30",
140
+ "2023-10-01",
141
+ "2023-10-02",
142
+ "2023-10-03",
143
+ "2023-10-04",
144
+ "2023-10-05",
145
+ "2023-10-06"
146
+ ],
147
+ "2024": [
148
+ "2023-12-30",
149
+ "2023-12-31",
150
+ "2024-01-01",
151
+ "2024-02-10",
152
+ "2024-02-11",
153
+ "2024-02-12",
154
+ "2024-02-13",
155
+ "2024-02-14",
156
+ "2024-02-15",
157
+ "2024-02-16",
158
+ "2024-02-17",
159
+ "2024-04-04",
160
+ "2024-04-05",
161
+ "2024-04-06",
162
+ "2024-05-01",
163
+ "2024-05-02",
164
+ "2024-05-03",
165
+ "2024-05-04",
166
+ "2024-05-05",
167
+ "2024-06-08",
168
+ "2024-06-09",
169
+ "2024-06-10",
170
+ "2024-09-15",
171
+ "2024-09-16",
172
+ "2024-09-17",
173
+ "2024-10-01",
174
+ "2024-10-02",
175
+ "2024-10-03",
176
+ "2024-10-04",
177
+ "2024-10-05",
178
+ "2024-10-06",
179
+ "2024-10-07"
180
+ ],
181
+ "2025": [
182
+ "2025-01-28",
183
+ "2025-01-29",
184
+ "2025-01-30",
185
+ "2025-01-31",
186
+ "2025-02-01",
187
+ "2025-02-02",
188
+ "2025-02-03",
189
+ "2025-02-04",
190
+ "2025-04-04",
191
+ "2025-04-05",
192
+ "2025-04-06",
193
+ "2025-05-01",
194
+ "2025-05-02",
195
+ "2025-05-03",
196
+ "2025-05-04",
197
+ "2025-05-05",
198
+ "2025-05-31",
199
+ "2025-06-01",
200
+ "2025-06-02",
201
+ "2025-10-01",
202
+ "2025-10-02",
203
+ "2025-10-03",
204
+ "2025-10-04",
205
+ "2025-10-05",
206
+ "2025-10-06",
207
+ "2025-10-07",
208
+ "2025-10-08"
209
+ ],
210
+ "2026": [
211
+ "2026-01-01",
212
+ "2026-01-02",
213
+ "2026-01-03",
214
+ "2026-02-15",
215
+ "2026-02-16",
216
+ "2026-02-17",
217
+ "2026-02-18",
218
+ "2026-02-19",
219
+ "2026-02-20",
220
+ "2026-02-21",
221
+ "2026-02-22",
222
+ "2026-02-23",
223
+ "2026-04-04",
224
+ "2026-04-05",
225
+ "2026-04-06",
226
+ "2026-05-01",
227
+ "2026-05-02",
228
+ "2026-05-03",
229
+ "2026-05-04",
230
+ "2026-05-05",
231
+ "2026-06-19",
232
+ "2026-06-20",
233
+ "2026-06-21",
234
+ "2026-09-25",
235
+ "2026-09-26",
236
+ "2026-09-27",
237
+ "2026-10-01",
238
+ "2026-10-02",
239
+ "2026-10-03",
240
+ "2026-10-04",
241
+ "2026-10-05",
242
+ "2026-10-06",
243
+ "2026-10-07"
244
+ ]
245
+ }
246
+
247
+ /** The newest year this table covers; later years carry no entry. */
248
+ export const HOLIDAY_TABLE_LAST_YEAR = 2026
@@ -15,6 +15,19 @@ export function localDateKey(ms: number): string {
15
15
  return `${date.getFullYear()}-${month}-${day}`
16
16
  }
17
17
 
18
+ /**
19
+ * Whether `key` is a real local-date key (`YYYY-MM-DD`). The shape check is
20
+ * round-tripped: an impossible date (9999-99-99) parses to NaN and a rollover
21
+ * (2026-02-30) lands on another day, so both would otherwise fold into a bogus,
22
+ * never-pruned bucket. Shared by the persisted-document reader and the day
23
+ * route's query gate.
24
+ */
25
+ export function isLedgerDateKey(key: string): boolean {
26
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return false
27
+ const atMs = new Date(key + 'T12:00:00').getTime()
28
+ return Number.isFinite(atMs) && localDateKey(atMs) === key
29
+ }
30
+
18
31
  /** An empty ledger document. */
19
32
  export function createLedgerDocument(): UsageLedgerDocument {
20
33
  return { version: 1, days: {} }
@@ -152,12 +165,8 @@ export function deserializeLedger(value: unknown): UsageLedgerDocument {
152
165
  const days = (value as Record<string, unknown>).days
153
166
  if (typeof days !== 'object' || days === null) return doc
154
167
  for (const [dateKey, providers] of Object.entries(days as Record<string, unknown>)) {
155
- if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey) || typeof providers !== 'object' || providers === null) continue
156
- // Round-trip the date key: an impossible date (9999-99-99) parses to NaN
157
- // and a rollover (2026-02-30) lands on another day; both would otherwise
158
- // fold into a bogus, never-pruned bucket.
168
+ if (!isLedgerDateKey(dateKey) || typeof providers !== 'object' || providers === null) continue
159
169
  const atMs = new Date(dateKey + 'T12:00:00').getTime()
160
- if (!Number.isFinite(atMs) || localDateKey(atMs) !== dateKey) continue
161
170
  for (const [provider, models] of Object.entries(providers as Record<string, unknown>)) {
162
171
  if (typeof models !== 'object' || models === null) continue
163
172
  for (const [model, totals] of Object.entries(models as Record<string, unknown>)) {
@@ -4,10 +4,19 @@
4
4
  * at fold time (the provider bills each request in the period the request ran
5
5
  * in, so pricing at the fold is the honest estimate).
6
6
  *
7
- * Policy (api-docs.deepseek.com pricing page, effective 2026-09-10 12:00
8
- * Beijing): peak hours are Beijing time Monday-Friday 09:00-12:00 and
9
- * 14:00-18:00; every other hour (nights, weekends) is off-peak and billed at
10
- * half the peak price. All prices here are CNY per million tokens.
7
+ * Policy (api-docs.deepseek.com pricing page): peak hours are Beijing time
8
+ * Monday-Friday 09:00-12:00 and 14:00-18:00, excluding Chinese public
9
+ * holidays; every other hour is off-peak and billed at half the peak price.
10
+ * The published rule counts a calendar day, not the working schedule: a
11
+ * Saturday or Sunday is off-peak in full even on an adjusted workday, and a
12
+ * public holiday is off-peak in full even on a Monday-Friday. All prices
13
+ * here are CNY per million tokens.
14
+ *
15
+ * Public-holiday dates are not derivable from the rule, so the rule takes them as
16
+ * a `PublicHolidayCalendar`. The default is the published table in `./holidays.ts`,
17
+ * covering 2020 through ${HOLIDAY_TABLE_LAST_YEAR}; a caller with newer dates
18
+ * passes its own. A holiday weekday therefore prices off-peak, which is the half
19
+ * of the published rule that is easy to get wrong.
11
20
  *
12
21
  * The published rows are `deepseek-flash` (DeepSeek-V4.1-Flash) and
13
22
  * `deepseek-v4-pro` (DeepSeek-V4-Pro-0813). The retired flash ids
@@ -18,6 +27,7 @@
18
27
  */
19
28
 
20
29
  import type { UsageTokenTotals } from './types.ts'
30
+ import { CHINESE_PUBLIC_HOLIDAYS, HOLIDAY_TABLE_LAST_YEAR } from './holidays.ts'
21
31
 
22
32
  /** One peak window in minutes-of-day, Beijing time. */
23
33
  interface PeakWindow {
@@ -71,32 +81,82 @@ function withinWindow(minuteOfDay: number): PeakWindow | undefined {
71
81
  return PEAK_WINDOWS.find((window) => minuteOfDay >= window.from && minuteOfDay < window.to)
72
82
  }
73
83
 
84
+ /**
85
+ * Supplies the Chinese public-holiday dates the peak rule excludes. The dates
86
+ * are not derivable from any rule (the State Council publishes them per year),
87
+ * so this repository ships no table of its own: the default calendar is empty,
88
+ * and a caller that has real dates passes them in. A date is compared as a
89
+ * Beijing-time `YYYY-MM-DD` string, which is the granularity the published
90
+ * rule needs (a holiday is off-peak for the whole day, so times never matter).
91
+ */
92
+ export interface PublicHolidayCalendar {
93
+ /** True when the Beijing-time calendar day `date` is a Chinese public holiday. */
94
+ isPublicHoliday(date: string): boolean
95
+ }
96
+
97
+ /**
98
+ * The default calendar: the published Chinese public-holiday table, so a holiday
99
+ * weekday prices off-peak instead of over-reporting at the peak rate. A year the
100
+ * table does not cover holds no dates and falls back to peak on weekdays, which
101
+ * is the safe direction to be wrong in: the estimate reads high rather than low.
102
+ */
103
+ export const CN_PUBLIC_HOLIDAYS: PublicHolidayCalendar = {
104
+ isPublicHoliday: (date) => {
105
+ const year = date.slice(0, 4)
106
+ if (Number(year) > HOLIDAY_TABLE_LAST_YEAR) return false
107
+ return CHINESE_PUBLIC_HOLIDAYS[year]?.includes(date) ?? false
108
+ },
109
+ }
110
+
111
+ /** The Beijing-time `YYYY-MM-DD` calendar day of `ms` (UTC+8 has no DST, so the shift is exact). */
112
+ function beijingDate(ms: number): string {
113
+ const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS)
114
+ const month = `${shifted.getUTCMonth() + 1}`.padStart(2, '0')
115
+ const day = `${shifted.getUTCDate()}`.padStart(2, '0')
116
+ return `${shifted.getUTCFullYear()}-${month}-${day}`
117
+ }
118
+
119
+ /**
120
+ * Whether `ms` falls on a peak-eligible Beijing day: Monday-Friday that is
121
+ * not a public holiday. A Saturday or Sunday is never eligible, including on
122
+ * an adjusted workday — the provider counts the calendar day, not the
123
+ * working schedule.
124
+ */
125
+ function isPeakDay(ms: number, calendar: PublicHolidayCalendar): boolean {
126
+ const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS)
127
+ const weekday = shifted.getUTCDay()
128
+ if (weekday < 1 || weekday > 5) return false
129
+ return !calendar.isPublicHoliday(beijingDate(ms))
130
+ }
131
+
74
132
  /**
75
133
  * The DeepSeek billing period at `ms`, plus when it next flips. The clock is
76
134
  * Beijing time regardless of the host timezone (UTC+8 has no DST, so a fixed
77
135
  * shift is exact). `boundaryMs` is the instant the current period ends — the
78
136
  * window's close while peaking, the next window's open otherwise.
79
137
  */
80
- export function deepseekPeriodAt(ms: number): { peak: boolean; boundaryMs: number } {
138
+ export function deepseekPeriodAt(ms: number, calendar: PublicHolidayCalendar = CN_PUBLIC_HOLIDAYS): { peak: boolean; boundaryMs: number } {
81
139
  const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS)
82
- const weekday = shifted.getUTCDay()
83
140
  const minuteOfDay = shifted.getUTCHours() * 60 + shifted.getUTCMinutes()
84
- const current = weekday >= 1 && weekday <= 5 ? withinWindow(minuteOfDay) : undefined
141
+ const current = isPeakDay(ms, calendar) ? withinWindow(minuteOfDay) : undefined
85
142
  if (current !== undefined) {
86
143
  return { peak: true, boundaryMs: ms + (current.to - minuteOfDay) * 60_000 - shifted.getUTCSeconds() * 1000 - shifted.getUTCMilliseconds() }
87
144
  }
88
- // Next window start: later today (weekday only), else the following days'
89
- // first morning window; the scan bound makes a malformed clock terminate.
145
+ // Next window start: later today (on a peak-eligible day), else the first
146
+ // peak-eligible day's morning window; the scan bound makes a malformed clock
147
+ // terminate.
90
148
  for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
91
- const day = new Date(ms + BEIJING_UTC_OFFSET_MS + dayOffset * 86_400_000)
92
- if (day.getUTCDay() < 1 || day.getUTCDay() > 5) continue
149
+ const dayStartMs = ms + dayOffset * 86_400_000
150
+ if (!isPeakDay(dayStartMs, calendar)) continue
151
+ const day = new Date(dayStartMs + BEIJING_UTC_OFFSET_MS)
93
152
  const realDayStart = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()) - BEIJING_UTC_OFFSET_MS
94
153
  for (const window of PEAK_WINDOWS) {
95
154
  if (dayOffset === 0 && window.from <= minuteOfDay) continue
96
155
  return { peak: false, boundaryMs: realDayStart + window.from * 60_000 }
97
156
  }
98
157
  }
99
- // Unreachable (the scan covers a full week); a conservative off-peak answer.
158
+ // Unreachable (the scan covers a full week of peak-eligible days); a
159
+ // conservative off-peak answer.
100
160
  return { peak: false, boundaryMs: ms + 86_400_000 }
101
161
  }
102
162
 
@@ -112,12 +172,12 @@ function priceFor(model: string, atMs: number): ModelPrice {
112
172
  * row; ids from other providers never reach this function (the service gates
113
173
  * by route family). Rounded to micro-CNY so the ledger stays readable.
114
174
  */
115
- export function deepseekModelSpend(model: string, totals: Readonly<UsageTokenTotals>, atMs: number): number {
175
+ export function deepseekModelSpend(model: string, totals: Readonly<UsageTokenTotals>, atMs: number, calendar: PublicHolidayCalendar = CN_PUBLIC_HOLIDAYS): number {
116
176
  const price = priceFor(model, atMs)
117
- const period = deepseekPeriodAt(atMs)
177
+ const period = deepseekPeriodAt(atMs, calendar)
118
178
  const column = period.peak ? 'peak' : 'offPeak'
119
179
  const spend = (totals.cacheReadTokens * price.cacheHit[column]
120
180
  + (totals.inputTokens + totals.cacheWriteTokens) * price.inputMiss[column]
121
181
  + totals.outputTokens * price.output[column]) / 1_000_000
122
182
  return Math.round(spend * 1e6) / 1e6
123
- }
183
+ }
package/src/core/types.ts CHANGED
@@ -67,6 +67,26 @@ export interface UsageDaySummary {
67
67
  totals: UsageTokenTotals
68
68
  }
69
69
 
70
+ /**
71
+ * One local day aggregated per provider and model. The overview serves today
72
+ * through this shape and the day route serves any retained day with it, so a
73
+ * day the user picks in the browser and the live today card are the same
74
+ * document.
75
+ */
76
+ export interface UsageDayView {
77
+ /** Local-date key `YYYY-MM-DD`. */
78
+ date: string
79
+ totals: UsageTokenTotals
80
+ /** Per-provider rows, heaviest by total tokens first. */
81
+ providers: UsageProviderSummary[]
82
+ }
83
+
84
+ /** The day route's response body. */
85
+ export interface UsageDayResponse {
86
+ ok: true
87
+ day: UsageDayView
88
+ }
89
+
70
90
  /** A balance fact normalized from a provider probe. */
71
91
  export interface BalanceView {
72
92
  /** ISO 4217 code the provider bills in (`CNY`, `USD`, ...). */
@@ -161,9 +181,16 @@ export interface UsageOverviewView {
161
181
  today?: UsageTokenTotals
162
182
  }
163
183
  usage: {
164
- today: { date: string; totals: UsageTokenTotals; providers: UsageProviderSummary[] }
184
+ today: UsageDayView
165
185
  /** Last N local days ascending, including today. */
166
186
  days: UsageDaySummary[]
187
+ /**
188
+ * Every retained local day that recorded usage, ascending — the day
189
+ * picker's option list, reaching past the trend cap to the whole
190
+ * retention window. Optional so an older host document still renders
191
+ * (the section then offers no picker).
192
+ */
193
+ availableDays?: string[]
167
194
  /**
168
195
  * The same window aggregated per provider and model — the trend card's
169
196
  * bar-chart data. Optional so an older host document still renders.
@@ -3,6 +3,8 @@ import type { Context } from '@deepseek-ai/cordis'
3
3
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
4
4
  import { isUsageAllowed } from './access.ts'
5
5
  import { writeJson } from './http.ts'
6
+ import { isLedgerDateKey } from '../core/ledger.ts'
7
+ import type { UsageDayResponse } from '../core/types.ts'
6
8
  import type { UsageService } from './usage-service.ts'
7
9
 
8
10
  export const USAGE_API_PREFIX = '/api/dsh-usage'
@@ -31,6 +33,35 @@ export function makeUsageOverviewRoute(ctx: Context, service: UsageService): Web
31
33
  }
32
34
  }
33
35
 
36
+ /**
37
+ * One retained day route: `GET /api/dsh-usage/day?date=YYYY-MM-DD` answers
38
+ * that local day's per-provider and per-model totals, so the section's day
39
+ * pickers can show a past day instead of only today. Same trust fence as the
40
+ * overview route, and a key that is not a real local date is refused with 400
41
+ * rather than folded into a bogus day.
42
+ * @param ctx - host context; may expose remoteWebUiPairing.
43
+ * @param service - the usage service.
44
+ * @returns the route.
45
+ */
46
+ export function makeUsageDayRoute(ctx: Context, service: UsageService): WebRoute {
47
+ return {
48
+ kind: 'exact',
49
+ path: USAGE_API_PREFIX + '/day',
50
+ handler: (req: IncomingMessage, res: ServerResponse): void => {
51
+ if (!isUsageAllowed(ctx, req)) {
52
+ writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
53
+ return
54
+ }
55
+ const date = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('date') ?? ''
56
+ if (!isLedgerDateKey(date)) {
57
+ writeJson(res, 400, { ok: false, error: 'date must be YYYY-MM-DD' })
58
+ return
59
+ }
60
+ writeJson(res, 200, { ok: true, day: service.day(date) } satisfies UsageDayResponse, { 'cache-control': 'no-store' })
61
+ },
62
+ }
63
+ }
64
+
34
65
  /**
35
66
  * Manual refresh: forces one probe cycle now and answers with the fresh
36
67
  * overview. Same trust fence as the overview route.
@@ -20,7 +20,7 @@ import type { BalanceParse, PlanParse } from '../core/adapters.ts'
20
20
  import { foldAliasRoutes } from '../core/provider-routes.ts'
21
21
  import { deepseekModelSpend } from '../core/pricing.ts'
22
22
  import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays, totalTokens } from '../core/ledger.ts'
23
- import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
23
+ import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageDayView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
24
24
  import { addTotals, emptyTotals } from '../core/types.ts'
25
25
 
26
26
  /**
@@ -350,6 +350,9 @@ export class UsageService {
350
350
  totals: range.totals,
351
351
  providers: range.providers,
352
352
  },
353
+ // Every retained day, not just the trend window: the day picker's
354
+ // option list reaches back to the oldest day retention still keeps.
355
+ availableDays: allKeys,
353
356
  all: {
354
357
  from: allKeys[0] ?? todayKey,
355
358
  to: allKeys[allKeys.length - 1] ?? todayKey,
@@ -393,8 +396,19 @@ export class UsageService {
393
396
  }
394
397
  }
395
398
 
399
+ /**
400
+ * One retained local day aggregated per provider and model, as the day
401
+ * route serves it. A day the ledger never held — pruned by retention, or
402
+ * simply without usage — reads as a zeroed day rather than an error, so a
403
+ * picker selection that aged out degrades to "no usage" instead of a broken
404
+ * panel. The route gates the key's shape before it gets here.
405
+ */
406
+ day(dateKey: string): UsageDayView {
407
+ return this.daySummary(dateKey)
408
+ }
409
+
396
410
  /** One local day aggregated per provider. */
397
- private daySummary(dateKey: string): UsageOverviewView['usage']['today'] {
411
+ private daySummary(dateKey: string): UsageDayView {
398
412
  const day = this.ledger.days[dateKey] ?? {}
399
413
  const { totals, providers } = summarizeDays([day])
400
414
  return { date: dateKey, totals, providers }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
4
4
  import z from '@deepseek-ai/schemastery'
5
5
  import { mountOnce } from './mount-once.ts'
6
6
  import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
7
- import { makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
7
+ import { makeUsageDayRoute, makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
8
8
 
9
9
  declare module '@deepseek-ai/cordis' {
10
10
  interface Events {
@@ -126,9 +126,9 @@ export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: U
126
126
  /** True while this activation has a start queued behind a predecessor's flush. */
127
127
  let queued = false
128
128
 
129
- /** Register the service's two routes; the returned disposer unregisters them. */
129
+ /** Register the service's routes; the returned disposer unregisters them. */
130
130
  const mountRoutes = (next: UsageService): (() => void) => {
131
- const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
131
+ const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageDayRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
132
132
  .map((route) => ctx.webServer.register(route))
133
133
  return () => {
134
134
  for (const dispose of disposers) {