opencode-deepseek-peak 0.1.0 → 0.2.2

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/install.mjs CHANGED
@@ -24,28 +24,32 @@ async function install() {
24
24
  await mkdir(directory, { recursive: true })
25
25
  const jsoncPath = join(directory, "opencode.jsonc")
26
26
  const jsonc = await readOptional(jsoncPath)
27
+ const tuiJsoncPath = join(directory, "tui.jsonc")
28
+ const tuiJsonc = await readOptional(tuiJsoncPath)
29
+ // [path, $schema, list key]: V1 TUI plugins live in tui.json under `plugin`.
27
30
  const targets = [
28
- [jsonc !== undefined ? jsoncPath : join(directory, "opencode.json"), "https://opencode.ai/config.json"],
29
- [join(directory, "cli.json"), "https://opencode.ai/v2/cli.json"],
31
+ [jsonc !== undefined ? jsoncPath : join(directory, "opencode.json"), "https://opencode.ai/config.json", "plugins"],
32
+ [join(directory, "cli.json"), "https://opencode.ai/v2/cli.json", "plugins"],
33
+ [tuiJsonc !== undefined ? tuiJsoncPath : join(directory, "tui.json"), "https://opencode.ai/tui.json", "plugin"],
30
34
  ]
31
- // Validate both documents before writing either one. JSONC edits preserve
35
+ // Validate every document before writing any of them. JSONC edits preserve
32
36
  // comments, trailing commas and unrelated settings.
33
37
  const updates = []
34
- for (const [path, schema] of targets) {
35
- const text = await readOptional(path) ?? JSON.stringify({ $schema: schema, plugins: [] }, null, 2) + "\n"
38
+ for (const [path, schema, key] of targets) {
39
+ const text = await readOptional(path) ?? JSON.stringify({ $schema: schema, [key]: [] }, null, 2) + "\n"
36
40
  const errors = []
37
41
  const config = parse(text, errors, { allowTrailingComma: true })
38
42
  if (errors.length || !config || typeof config !== "object" || Array.isArray(config)) {
39
43
  throw new Error(`Invalid configuration: ${path}`)
40
44
  }
41
- const plugins = config.plugins ?? []
42
- if (!Array.isArray(plugins)) throw new Error(`Expected a plugins array: ${path}`)
45
+ const plugins = config[key] ?? []
46
+ if (!Array.isArray(plugins)) throw new Error(`Expected a ${key} array: ${path}`)
43
47
  const present = plugins.some((item) => {
44
48
  const spec = typeof item === "string" ? item : item?.package
45
49
  return spec === PLUGIN_SPEC || spec?.startsWith(`${PLUGIN_SPEC}@`)
46
50
  })
47
51
  if (present) continue
48
- const result = applyEdits(text, modify(text, ["plugins"], [...plugins, PLUGIN_SPEC], {
52
+ const result = applyEdits(text, modify(text, [key], [...plugins, PLUGIN_SPEC], {
49
53
  formattingOptions: { insertSpaces: true, tabSize: 2 },
50
54
  }))
51
55
  updates.push([path, result])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-deepseek-peak",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "OpenCode V2 plugin showing DeepSeek peak and off-peak pricing periods",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -51,6 +51,8 @@
51
51
  "jsonc-parser": "^3.3.1"
52
52
  },
53
53
  "devDependencies": {
54
+ "@opencode-ai/plugin": "^1.18.16",
55
+ "@opencode-ai/sdk": "^1.18.16",
54
56
  "@opencode/plugin": "^2.0.16",
55
57
  "@opentui/core": "^0.5.10",
56
58
  "@opentui/solid": "^0.5.10",
@@ -1,5 +1,6 @@
1
1
  import type { Plugin } from "@opencode/plugin/tui"
2
- import type { T, TranslationKey } from "../i18n.js"
2
+ import type { LangCode, T, TranslationKey } from "../i18n.js"
3
+ import { nextTransition } from "../schedule.js"
3
4
  import type { PeakStatus, TuiSettings } from "../types.js"
4
5
 
5
6
  export const INITIAL_SETTINGS: TuiSettings = {
@@ -9,6 +10,7 @@ export const INITIAL_SETTINGS: TuiSettings = {
9
10
  sidebarEvidence: true,
10
11
  toastEveryRequest: false,
11
12
  lang: "en",
13
+ timezone: "local",
12
14
  }
13
15
 
14
16
  export function statusLabel(status: PeakStatus, t: T) {
@@ -26,9 +28,65 @@ const HOLIDAY_KEYS: Record<string, TranslationKey> = {
26
28
  "Labour Day": "holiday.labourDay",
27
29
  }
28
30
 
31
+ export interface LabelOptions {
32
+ /** Reference instant for the next transition; defaults to now. */
33
+ now?: Date
34
+ lang?: LangCode
35
+ /** IANA time zone; undefined uses the system zone, "UTC" shows the official times. */
36
+ timeZone?: string
37
+ }
38
+
39
+ function zoneDate(date: Date, timeZone: string | undefined): string {
40
+ const parts = new Intl.DateTimeFormat("en-CA", {
41
+ timeZone,
42
+ year: "numeric",
43
+ month: "2-digit",
44
+ day: "2-digit",
45
+ }).formatToParts(date)
46
+ const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""
47
+ return `${value("year")}-${value("month")}-${value("day")}`
48
+ }
49
+
50
+ function formatClock(date: Date, timeZone: string | undefined): string {
51
+ const parts = new Intl.DateTimeFormat("en-GB", {
52
+ timeZone,
53
+ hour: "2-digit",
54
+ minute: "2-digit",
55
+ hourCycle: "h23",
56
+ }).formatToParts(date)
57
+ const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""
58
+ return `${value("hour")}:${value("minute")}`
59
+ }
60
+
61
+ /** "10:00 UTC" in UTC mode, "07:00" in the local zone; adds a short weekday on another day. */
62
+ function formatTransitionAt(at: Date, now: Date, lang: LangCode, timeZone: string | undefined): string {
63
+ const clock = timeZone === "UTC" ? `${formatClock(at, timeZone)} UTC` : formatClock(at, timeZone)
64
+ if (zoneDate(at, timeZone) === zoneDate(now, timeZone)) return clock
65
+ const weekday = new Intl.DateTimeFormat(lang, { weekday: "short", timeZone }).format(at)
66
+ return `${weekday} ${clock}`
67
+ }
68
+
69
+ /** Localized "peak ends 10:00 UTC" / "peak starts Mon 01:00" for the next change. */
70
+ export function transitionLabel(t: T, options: LabelOptions = {}): string {
71
+ const now = options.now ?? new Date()
72
+ const next = nextTransition(now)
73
+ return t(next.kind === "peakEnd" ? "evidence.peakEnds" : "evidence.peakStarts", {
74
+ when: formatTransitionAt(next.at, now, options.lang ?? "en", options.timeZone),
75
+ })
76
+ }
77
+
78
+ /** Compact sidebar form: ▲ peak starts / ▼ peak ends. Arrows need no translation. */
79
+ export function transitionCompact(options: LabelOptions = {}): string {
80
+ const now = options.now ?? new Date()
81
+ const next = nextTransition(now)
82
+ const arrow = next.kind === "peakEnd" ? "▼" : "▲"
83
+ return `${arrow} ${formatTransitionAt(next.at, now, options.lang ?? "en", options.timeZone)}`
84
+ }
85
+
29
86
  /** Short, sidebar-friendly, localized version of the status evidence. */
30
- export function evidenceLabel(status: PeakStatus, t: T): string {
87
+ export function evidenceLabel(status: PeakStatus, t: T, options: LabelOptions = {}): string {
31
88
  let label: string
89
+ let transition = ""
32
90
  // An API response header is the source of truth when present.
33
91
  if (status.source === "api-header") {
34
92
  label = t("evidence.apiHeader", { header: status.evidence.split(":")[0] })
@@ -37,10 +95,11 @@ export function evidenceLabel(status: PeakStatus, t: T): string {
37
95
  label = t("evidence.holiday", { name: key ? t(key) : status.holiday })
38
96
  } else {
39
97
  label = t("evidence.schedule")
98
+ transition = ` · ${transitionCompact(options)}`
40
99
  }
41
100
  if (status.evidence.endsWith("(cached)")) label += ` ${t("evidence.cached")}`
42
101
  if (status.evidence.includes("no request observed yet")) label += ` ${t("evidence.preview")}`
43
- return label
102
+ return label + transition
44
103
  }
45
104
 
46
105
  /**
package/src/i18n.ts CHANGED
@@ -10,11 +10,13 @@ export type LangCode = "en" | "pt" | "zh" | "ja" | "ko"
10
10
  const EN_T = {
11
11
  "period.peak": "PEAK",
12
12
  "period.offPeak": "OFF-PEAK",
13
- "evidence.schedule": "official schedule",
13
+ "evidence.schedule": "Official schedule",
14
14
  "evidence.apiHeader": "via {header}",
15
15
  "evidence.cached": "(cached)",
16
16
  "evidence.preview": "(no request yet)",
17
17
  "evidence.holiday": "Holiday: {name}",
18
+ "evidence.peakEnds": "peak ends {when}",
19
+ "evidence.peakStarts": "peak starts {when}",
18
20
  "label.mismatch": "mismatch",
19
21
  "toast.title": "DeepSeek pricing",
20
22
  "status.title": "DeepSeek Peak",
@@ -22,6 +24,8 @@ const EN_T = {
22
24
  "status.noRequest": "No DeepSeek request observed in this session yet.",
23
25
  "status.source": "Source",
24
26
  "status.evidence": "Evidence",
27
+ "status.windows": "Peak: 01:00–04:00 and 06:00–10:00 UTC, Mon–Fri, except Chinese public holidays",
28
+ "status.nextChange": "Next change",
25
29
  "status.unavailable": "DeepSeek Peak server plugin is unavailable.",
26
30
  "command.status.title": "DeepSeek Peak: Show Status",
27
31
  "command.status.description": "Show the last observed DeepSeek pricing period",
@@ -36,6 +40,9 @@ const EN_T = {
36
40
  "settings.evidence": "Sidebar evidence",
37
41
  "settings.frequency": "Toast frequency",
38
42
  "settings.lang": "Language",
43
+ "settings.timezone": "Timezone",
44
+ "timezone.local": "Local",
45
+ "timezone.utc": "UTC",
39
46
  "settings.on": "on",
40
47
  "settings.off": "off",
41
48
  "frequency.change": "on change",
@@ -54,11 +61,13 @@ export type TranslationKey = keyof Translation
54
61
  const PT_T: Translation = {
55
62
  "period.peak": "PICO",
56
63
  "period.offPeak": "FORA DE PICO",
57
- "evidence.schedule": "horário oficial",
64
+ "evidence.schedule": "Horário oficial",
58
65
  "evidence.apiHeader": "via {header}",
59
66
  "evidence.cached": "(cache)",
60
67
  "evidence.preview": "(sem requisição ainda)",
61
68
  "evidence.holiday": "Feriado: {name}",
69
+ "evidence.peakEnds": "pico termina {when}",
70
+ "evidence.peakStarts": "pico começa {when}",
62
71
  "label.mismatch": "divergente",
63
72
  "toast.title": "Preço DeepSeek",
64
73
  "status.title": "DeepSeek Peak",
@@ -66,6 +75,8 @@ const PT_T: Translation = {
66
75
  "status.noRequest": "Nenhuma requisição DeepSeek observada nesta sessão ainda.",
67
76
  "status.source": "Fonte",
68
77
  "status.evidence": "Evidência",
78
+ "status.windows": "Pico: 01:00–04:00 e 06:00–10:00 UTC, seg–sex, exceto feriados chineses",
79
+ "status.nextChange": "Próxima mudança",
69
80
  "status.unavailable": "O plugin de servidor do DeepSeek Peak está indisponível.",
70
81
  "command.status.title": "DeepSeek Peak: Mostrar status",
71
82
  "command.status.description": "Mostra o último período de preço observado da DeepSeek",
@@ -80,6 +91,9 @@ const PT_T: Translation = {
80
91
  "settings.evidence": "Evidência na barra lateral",
81
92
  "settings.frequency": "Frequência do toast",
82
93
  "settings.lang": "Idioma",
94
+ "settings.timezone": "Fuso horário",
95
+ "timezone.local": "Local",
96
+ "timezone.utc": "UTC",
83
97
  "settings.on": "ativado",
84
98
  "settings.off": "desativado",
85
99
  "frequency.change": "ao mudar",
@@ -100,6 +114,8 @@ const ZH_T: Translation = {
100
114
  "evidence.cached": "(缓存)",
101
115
  "evidence.preview": "(尚无请求)",
102
116
  "evidence.holiday": "节假日:{name}",
117
+ "evidence.peakEnds": "高峰于 {when} 结束",
118
+ "evidence.peakStarts": "高峰于 {when} 开始",
103
119
  "label.mismatch": "不一致",
104
120
  "toast.title": "DeepSeek 价格",
105
121
  "status.title": "DeepSeek Peak",
@@ -107,6 +123,8 @@ const ZH_T: Translation = {
107
123
  "status.noRequest": "此会话尚未观察到 DeepSeek 请求。",
108
124
  "status.source": "来源",
109
125
  "status.evidence": "证据",
126
+ "status.windows": "高峰时段:01:00–04:00、06:00–10:00 UTC,周一至周五,中国法定节假日除外",
127
+ "status.nextChange": "下次变化",
110
128
  "status.unavailable": "DeepSeek Peak 服务端插件不可用。",
111
129
  "command.status.title": "DeepSeek Peak:显示状态",
112
130
  "command.status.description": "显示最近观察到的 DeepSeek 价格时段",
@@ -121,6 +139,9 @@ const ZH_T: Translation = {
121
139
  "settings.evidence": "侧边栏证据",
122
140
  "settings.frequency": "通知频率",
123
141
  "settings.lang": "语言",
142
+ "settings.timezone": "时区",
143
+ "timezone.local": "本地",
144
+ "timezone.utc": "UTC",
124
145
  "settings.on": "开",
125
146
  "settings.off": "关",
126
147
  "frequency.change": "变化时",
@@ -141,6 +162,8 @@ const JA_T: Translation = {
141
162
  "evidence.cached": "(キャッシュ)",
142
163
  "evidence.preview": "(まだリクエストなし)",
143
164
  "evidence.holiday": "祝日:{name}",
165
+ "evidence.peakEnds": "ピークは {when} に終了",
166
+ "evidence.peakStarts": "ピークは {when} に開始",
144
167
  "label.mismatch": "不一致",
145
168
  "toast.title": "DeepSeek 料金",
146
169
  "status.title": "DeepSeek Peak",
@@ -148,6 +171,8 @@ const JA_T: Translation = {
148
171
  "status.noRequest": "このセッションではまだ DeepSeek リクエストがありません。",
149
172
  "status.source": "ソース",
150
173
  "status.evidence": "エビデンス",
174
+ "status.windows": "ピーク:01:00–04:00、06:00–10:00 UTC、月〜金、中国の祝日を除く",
175
+ "status.nextChange": "次の切り替え",
151
176
  "status.unavailable": "DeepSeek Peak サーバープラグインが利用できません。",
152
177
  "command.status.title": "DeepSeek Peak:ステータス表示",
153
178
  "command.status.description": "最後に観測した DeepSeek 料金期間を表示",
@@ -162,6 +187,9 @@ const JA_T: Translation = {
162
187
  "settings.evidence": "サイドバーのエビデンス",
163
188
  "settings.frequency": "トースト頻度",
164
189
  "settings.lang": "言語",
190
+ "settings.timezone": "タイムゾーン",
191
+ "timezone.local": "ローカル",
192
+ "timezone.utc": "UTC",
165
193
  "settings.on": "オン",
166
194
  "settings.off": "オフ",
167
195
  "frequency.change": "変更時",
@@ -182,6 +210,8 @@ const KO_T: Translation = {
182
210
  "evidence.cached": "(캐시)",
183
211
  "evidence.preview": "(아직 요청 없음)",
184
212
  "evidence.holiday": "공휴일: {name}",
213
+ "evidence.peakEnds": "피크 {when} 종료",
214
+ "evidence.peakStarts": "피크 {when} 시작",
185
215
  "label.mismatch": "불일치",
186
216
  "toast.title": "DeepSeek 요금",
187
217
  "status.title": "DeepSeek Peak",
@@ -189,6 +219,8 @@ const KO_T: Translation = {
189
219
  "status.noRequest": "이 세션에는 아직 DeepSeek 요청이 없습니다.",
190
220
  "status.source": "출처",
191
221
  "status.evidence": "근거",
222
+ "status.windows": "피크: 01:00–04:00, 06:00–10:00 UTC, 월–금, 중국 공휴일 제외",
223
+ "status.nextChange": "다음 전환",
192
224
  "status.unavailable": "DeepSeek Peak 서버 플러그인을 사용할 수 없습니다.",
193
225
  "command.status.title": "DeepSeek Peak: 상태 표시",
194
226
  "command.status.description": "마지막으로 관측된 DeepSeek 요금 기간 표시",
@@ -203,6 +235,9 @@ const KO_T: Translation = {
203
235
  "settings.evidence": "사이드바 근거",
204
236
  "settings.frequency": "토스트 빈도",
205
237
  "settings.lang": "언어",
238
+ "settings.timezone": "시간대",
239
+ "timezone.local": "로컬",
240
+ "timezone.utc": "UTC",
206
241
  "settings.on": "켜짐",
207
242
  "settings.off": "꺼짐",
208
243
  "frequency.change": "변경 시",
package/src/index.ts CHANGED
@@ -28,34 +28,41 @@ export default Plugin.define({
28
28
  const status = statuses.get(sessionID)
29
29
  return status ? { found: true, status } : { found: false }
30
30
  },
31
- // Lets the TUI show the current period as soon as a session opens with a
32
- // DeepSeek model selected, without waiting for the first request.
31
+ // Re-synthesizes the current period on every call so an idle TUI can
32
+ // refresh itself without waiting for a request. A recent API header
33
+ // still wins over the official schedule.
33
34
  preview: async (input) => {
34
35
  const { sessionID } = input as { sessionID: string }
35
- const observed = statuses.get(sessionID)
36
- if (observed) return { found: true, status: observed }
37
36
  try {
38
37
  const session = await context.session.get({ sessionID })
39
38
  const model = session.model
40
39
  if (!model || !isDeepSeek(model.providerID)) return { found: false }
41
- const schedule = scheduleInfo(new Date())
42
- const evidence = schedule.holiday
43
- ? `Chinese public holiday: ${schedule.holiday} (no request observed yet)`
44
- : "Current schedule (no request observed yet)"
40
+ const observed = statuses.get(sessionID)
41
+ const now = Date.now()
42
+ const schedule = scheduleInfo(new Date(now))
43
+ const cached = apiSignals.get(modelKey(model.providerID, model.id))
44
+ const api = cached && now - cached.observedAt <= apiSignalTtlMs ? cached : undefined
45
+ const evidence = api
46
+ ? `${api.evidence} (cached)`
47
+ : schedule.holiday
48
+ ? `Chinese public holiday: ${schedule.holiday}${observed ? "" : " (no request observed yet)"}`
49
+ : observed
50
+ ? "DeepSeek official UTC pricing schedule"
51
+ : "Current schedule (no request observed yet)"
45
52
  return {
46
53
  found: true,
47
54
  status: {
48
55
  sessionID,
49
56
  providerID: model.providerID,
50
57
  modelID: model.id,
51
- period: schedule.period,
58
+ period: api?.period ?? schedule.period,
52
59
  scheduledPeriod: schedule.period,
53
- source: "official-schedule",
54
- phase: "request",
55
- mismatch: false,
56
- observedAt: Date.now(),
60
+ source: api ? "api-header" : "official-schedule",
61
+ phase: observed?.phase ?? "request",
62
+ mismatch: api ? api.period !== schedule.period : false,
63
+ observedAt: now,
57
64
  evidence,
58
- holiday: schedule.holiday,
65
+ ...(schedule.holiday !== undefined ? { holiday: schedule.holiday } : {}),
59
66
  },
60
67
  }
61
68
  } catch {
@@ -93,7 +100,7 @@ export default Plugin.define({
93
100
  : schedule.holiday
94
101
  ? `Chinese public holiday: ${schedule.holiday}`
95
102
  : "DeepSeek official UTC pricing schedule",
96
- holiday: schedule.holiday,
103
+ ...(schedule.holiday !== undefined ? { holiday: schedule.holiday } : {}),
97
104
  })
98
105
  })
99
106
 
@@ -118,7 +125,7 @@ export default Plugin.define({
118
125
  mismatch: api.period !== schedule.period,
119
126
  observedAt: now,
120
127
  evidence: api.evidence,
121
- holiday: schedule.holiday,
128
+ ...(schedule.holiday !== undefined ? { holiday: schedule.holiday } : {}),
122
129
  })
123
130
  })
124
131
 
@@ -17,6 +17,7 @@ export interface IndicatorProps {
17
17
 
18
18
  export interface PanelProps extends IndicatorProps {
19
19
  evidence: () => boolean
20
+ timeZone: () => string | undefined
20
21
  }
21
22
 
22
23
  export function PeakPanel(props: PanelProps) {
@@ -51,7 +52,11 @@ export function PeakPanel(props: PanelProps) {
51
52
  <For each={segments()}>{(segment) => <span style={{ fg: segment.color }}>{segment.text}</span>}</For>
52
53
  </text>
53
54
  <Show when={props.evidence() ? props.status() : undefined}>
54
- {(status) => <text fg={themeColors(props.context.theme).muted}>{evidenceLabel(status(), t)}</text>}
55
+ {(status) => (
56
+ <text wrapMode="none" fg={themeColors(props.context.theme).muted}>
57
+ {evidenceLabel(status(), t, { lang: props.lang(), timeZone: props.timeZone() })}
58
+ </text>
59
+ )}
55
60
  </Show>
56
61
  </box>
57
62
  </Show>
package/src/schedule.ts CHANGED
@@ -56,6 +56,45 @@ export function scheduleInfo(
56
56
  return { period: peakWindow(date) }
57
57
  }
58
58
 
59
+ export interface Transition {
60
+ kind: "peakStart" | "peakEnd"
61
+ /** Instant of the next change of the official schedule. */
62
+ at: Date
63
+ }
64
+
65
+ function utcAt(date: Date, dayOffset: number, hour: number): Date {
66
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + dayOffset, hour))
67
+ }
68
+
69
+ function isPeakDay(date: Date, holidayName: (value: Date) => string | undefined): boolean {
70
+ const day = date.getUTCDay()
71
+ return day !== 0 && day !== 6 && holidayName(date) === undefined
72
+ }
73
+
74
+ /**
75
+ * Next instant at which the official schedule changes period, considering the
76
+ * two weekday peak windows and China's public holidays.
77
+ */
78
+ export function nextTransition(
79
+ date: Date,
80
+ holidayName: (value: Date) => string | undefined = chinesePublicHoliday,
81
+ ): Transition {
82
+ if (scheduleInfo(date, holidayName).period === "peak") {
83
+ return { kind: "peakEnd", at: utcAt(date, 0, date.getUTCHours() < 4 ? 4 : 10) }
84
+ }
85
+ // Off-peak: the earliest future peak start on a weekday without a holiday.
86
+ // A public holiday can run for several consecutive days, so scan further.
87
+ for (let offset = 0; offset < 15; offset += 1) {
88
+ if (!isPeakDay(utcAt(date, offset, 0), holidayName)) continue
89
+ for (const hour of [1, 6]) {
90
+ const candidate = utcAt(date, offset, hour)
91
+ if (candidate.getTime() > date.getTime()) return { kind: "peakStart", at: candidate }
92
+ }
93
+ }
94
+ // date-holidays always yields a working weekday within two weeks; keep a fallback.
95
+ return { kind: "peakStart", at: utcAt(date, 1, 1) }
96
+ }
97
+
59
98
  export function officialPricePeriod(
60
99
  date: Date,
61
100
  holidayCheck: (value: Date) => boolean = isChinesePublicHoliday,
package/src/tui.tsx CHANGED
@@ -21,6 +21,8 @@ const mod: Plugin.Definition = {
21
21
  // Stored settings may predate the `lang` field.
22
22
  const lang = () => (settings.lang ?? detectLang()) as LangCode
23
23
  const t = createT(lang)
24
+ // Local by default; `utc` pins the official schedule times.
25
+ const timeZone = () => (settings.timezone === "utc" ? "UTC" : undefined)
24
26
  const [statuses, setStatuses] = createSignal<Record<string, PeakStatus>>({})
25
27
  const lastToast = new Map<string, string>()
26
28
 
@@ -40,13 +42,19 @@ const mod: Plugin.Definition = {
40
42
  })
41
43
 
42
44
  const readStatus = async (sessionID: string) => {
43
- const result = await client.status({ sessionID }) as { found: boolean; status?: PeakStatus }
44
- if (result.found && result.status) return result.status
45
+ // Preview re-synthesizes the current schedule, so an idle session stays
46
+ // fresh; it already folds in a recent API header when one exists.
45
47
  try {
46
48
  const preview = await client.preview({ sessionID }) as { found: boolean; status?: PeakStatus }
47
- return preview.found ? preview.status : undefined
48
- } catch {
49
+ if (preview.found && preview.status) return preview.status
49
50
  return undefined
51
+ } catch {
52
+ try {
53
+ const result = await client.status({ sessionID }) as { found: boolean; status?: PeakStatus }
54
+ return result.found ? result.status : undefined
55
+ } catch {
56
+ return undefined
57
+ }
50
58
  }
51
59
  }
52
60
 
@@ -60,10 +68,22 @@ const mod: Plugin.Definition = {
60
68
  .catch(() => {})
61
69
  }
62
70
 
71
+ // Keep the period and the next-change label fresh while the session is idle.
72
+ const timer = setInterval(() => {
73
+ for (const sessionID of Object.keys(statuses())) refresh(sessionID)
74
+ }, 30_000)
75
+
63
76
  const unregisterCommands = context.ui.slot({
64
77
  append: "app",
65
78
  render: () => (
66
- <CommandRoot context={context} settings={settings} update={update} readStatus={readStatus} lang={lang} />
79
+ <CommandRoot
80
+ context={context}
81
+ settings={settings}
82
+ update={update}
83
+ readStatus={readStatus}
84
+ lang={lang}
85
+ timeZone={timeZone}
86
+ />
67
87
  ),
68
88
  })
69
89
  const unregisterSidebar = context.ui.slot({
@@ -77,6 +97,7 @@ const mod: Plugin.Definition = {
77
97
  enabled={() => settings.sidebarIndicator !== false}
78
98
  evidence={() => settings.sidebarEvidence !== false}
79
99
  lang={lang}
100
+ timeZone={timeZone}
80
101
  sessionID={sessionID}
81
102
  refresh={refresh}
82
103
  />
@@ -101,6 +122,7 @@ const mod: Plugin.Definition = {
101
122
  })
102
123
 
103
124
  return () => {
125
+ clearInterval(timer)
104
126
  unsubscribe()
105
127
  unregisterCommands()
106
128
  unregisterSidebar()
package/src/types.ts CHANGED
@@ -3,6 +3,7 @@ import type { LangCode } from "./i18n.js"
3
3
  export type PricePeriod = "peak" | "off-peak"
4
4
  export type StatusSource = "official-schedule" | "api-header"
5
5
  export type StatusPhase = "request" | "response"
6
+ export type TimezoneMode = "local" | "utc"
6
7
 
7
8
  export interface PeakStatus {
8
9
  sessionID: string
@@ -26,4 +27,5 @@ export interface TuiSettings {
26
27
  sidebarEvidence: boolean
27
28
  toastEveryRequest: boolean
28
29
  lang: LangCode
30
+ timezone: TimezoneMode
29
31
  }