dsh-scheduled-message 0.1.1 → 0.2.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/README.md CHANGED
@@ -19,23 +19,42 @@ their current meaning.
19
19
  | **Dialog** | Date, hour 1–12, minute, AM/PM, quick picks, a "Sends …" summary, and an optional **Repeat** row. Backdrop/Escape close, focus returns to the composer. |
20
20
  | **Strip** | Above the composer: `2 scheduled · next Fri, 14 Nov, 9:00 am`, expandable to rows with **Send now**, **Change time**, **Cancel**, and **Retry** for a failed delivery. |
21
21
  | **Repeat** | Creates a task in DSH's own Automation service and opens its task page. Blocked when the draft has images or files. |
22
+ | **Peak-price hint** | When the chosen time lands in DeepSeek's peak window and the session runs a DeepSeek model, the dialog adds one orange line naming the window in your own time zone. Weekends and off-peak hours are half price. |
22
23
  | **Phone** | Bottom sheet under 640 px, 44 px targets, 16 px inputs (no zoom-on-focus), safe-area padding. |
23
24
  | **Theme** | Host tokens for every surface and text colour; the accent orange is the only literal colour. English and Chinese dictionaries ship complete. |
24
25
 
25
26
  ## Install
26
27
 
27
- The package is a normal DSH plugin bundle: a Host half (`index.js`), a browser half
28
- (`client.js`), and one patch entry.
28
+ Published on npm as `dsh-scheduled-message`. The package is a normal DSH plugin bundle: a Host half
29
+ (`index.js`), a browser half (`client.js`), and one patch entry.
29
30
 
30
31
  ```sh
31
- # 1. put the package where the profile can resolve it
32
- cp -r dsh-scheduled-message ~/.dsh/profiles/desktop/node_modules/dsh-scheduled-message
32
+ dsh plugin --profile <profile> add dsh-scheduled-message
33
+ ```
34
+
35
+ Replace `<profile>` with the profile your host runs — `web` for a plain `dsh web`. DSH Desktop keeps
36
+ its own profile, so install there from **Settings → Plugins** instead of the command line.
37
+
38
+ The command forwards to pnpm in the profile directory and, because the package declares `dsh.bundle`,
39
+ adds it to `dsh.profile.bundles` as well. Then restart DSH.
40
+
41
+ The market card does the same thing in one click: **Plugins** → **Discover**, search *scheduled
42
+ message*.
43
+
44
+ Needs DSH `0.2.2-rc.2` or newer (`engines.dsh`).
33
45
 
34
- # 2. add it to the profile's dependencies and bundle list (package.json)
35
- # "dsh-scheduled-message": "file:./node_modules/dsh-scheduled-message"
36
- # "dsh": { "profile": { "bundles": [ …, "dsh-scheduled-message" ] } }
46
+ ### Without a package manager
47
+
48
+ Put the folder where the profile can resolve it, list it in that profile's `package.json`, and
49
+ restart:
50
+
51
+ ```sh
52
+ cp -r dsh-scheduled-message ~/.dsh/profiles/<profile>/node_modules/dsh-scheduled-message
53
+ ```
37
54
 
38
- # 3. restart DSH
55
+ ```jsonc
56
+ "dependencies": { "dsh-scheduled-message": "file:./node_modules/dsh-scheduled-message" },
57
+ "dsh": { "profile": { "bundles": [ …, "dsh-scheduled-message" ] } }
39
58
  ```
40
59
 
41
60
  With the plugin manager available, `install_bundle` with this directory as the target does the same
package/client.js CHANGED
@@ -475,8 +475,88 @@ function describeZone(instantMs, locale, timeZone) {
475
475
  return timeZone
476
476
  }
477
477
  }
478
+
479
+ // ---------------------------------------------------------------- DeepSeek price clock
480
+
481
+ /**
482
+ * DeepSeek's peak-price windows as UTC minutes since midnight.
483
+ *
484
+ * DeepSeek documents the rule verbatim: "Off-peak rates are half of the peak
485
+ * rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through
486
+ * Friday, excluding Chinese public holidays. All other hours are off-peak,
487
+ * including weekends and Chinese public holidays in full."
488
+ * (https://api-docs.deepseek.com/quick_start/pricing, read 2026-11-14.)
489
+ *
490
+ * Chinese public holidays are not modelled: the notice speaks about the
491
+ * weekday clock, which is right for every ordinary working week.
492
+ */
493
+ const DEEPSEEK_PEAK_UTC_MINUTES = Object.freeze([[60, 240], [360, 600]])
494
+
495
+ /**
496
+ * Whether one instant is billed at DeepSeek's standard (peak) rate.
497
+ * @param instantMs - epoch milliseconds.
498
+ * @returns whether the instant sits inside a weekday peak window.
499
+ */
500
+ function isDeepSeekPeak(instantMs) {
501
+ if (!Number.isFinite(instantMs)) return false
502
+ const instant = new Date(instantMs)
503
+ const weekday = instant.getUTCDay()
504
+ if (weekday === 0 || weekday === 6) return false
505
+ const minutes = instant.getUTCHours() * 60 + instant.getUTCMinutes()
506
+ return DEEPSEEK_PEAK_UTC_MINUTES.some(([start, end]) => minutes >= start && minutes < end)
507
+ }
508
+
509
+ /**
510
+ * Whether a model id belongs to the DeepSeek family, whose clock is priced.
511
+ * @param modelName - provider model id, e.g. `deepseek-v4.1-flash`.
512
+ * @returns whether the name contains `deepseek`, case-insensitively.
513
+ */
514
+ function isDeepSeekModel(modelName) {
515
+ return typeof modelName === 'string' && /deepseek/i.test(modelName)
516
+ }
517
+
518
+ /**
519
+ * Render both peak windows in the reader's zone, e.g. `09:00–12:00 / 14:00–18:00`.
520
+ * A zone west of UTC shows a window on the neighbouring date, which is
521
+ * correct: the boundaries are instants, not wall-clock times.
522
+ * @param instantMs - the instant whose UTC date anchors the windows.
523
+ * @param timeZone - explicit IANA zone used for display.
524
+ * @returns the two windows joined by ` / `.
525
+ */
526
+ function describePeakWindows(instantMs, timeZone) {
527
+ const at = Number.isFinite(instantMs) ? instantMs : Date.now()
528
+ const stamp = new Date(at)
529
+ const dayStart = Date.UTC(stamp.getUTCFullYear(), stamp.getUTCMonth(), stamp.getUTCDate())
530
+ const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC'
531
+ const clock = (ms) => {
532
+ try {
533
+ return new Intl.DateTimeFormat('en-GB', {
534
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone,
535
+ }).format(new Date(ms))
536
+ } catch {
537
+ return new Date(ms).toISOString().slice(11, 16)
538
+ }
539
+ }
540
+ return DEEPSEEK_PEAK_UTC_MINUTES
541
+ .map(([start, end]) => `${clock(dayStart + start * 60_000)}–${clock(dayStart + end * 60_000)}`)
542
+ .join(' / ')
543
+ }
544
+
545
+ /**
546
+ * The peak-window notice the dialog shows, or `null` when it does not apply.
547
+ *
548
+ * One rule, decided in one place: only a DeepSeek model is billed on this
549
+ * clock, and only a chosen instant inside a peak window is worth mentioning.
550
+ * @param options - chosen instant, model id and display zone.
551
+ * @returns `{ windows }` with the windows in the reader's zone, or `null`.
552
+ */
553
+ function deepSeekPeakNotice({ instantMs, modelName, timeZone } = {}) {
554
+ if (!isDeepSeekModel(modelName)) return null
555
+ if (!isDeepSeekPeak(instantMs)) return null
556
+ return { windows: describePeakWindows(instantMs, timeZone) }
557
+ }
478
558
 
479
- return { NS, DOMAIN_NAME, MIN_LEAD_MS, MAX_TEXT_CHARS, MAX_IMAGES, MAX_PENDING, MAX_TITLE_CHARS, MAX_TIMER_DELAY_MS, LATE_THRESHOLD_MS, RETENTION_MS, REPEAT_FREQUENCIES, pad2, toMeridiem, to24Hour, formatClock, parseLocalDate, isValidTimeZone, localParts, zoneOffsetMs, wallClockToInstant, buildRrule, skipsShortMonths, summariseTitle, validateCreateRequest, earliestPending, timerDelay, isLate, pruneTargets, pendingSummary, describeDue, describeZone }
559
+ return { NS, DOMAIN_NAME, MIN_LEAD_MS, MAX_TEXT_CHARS, MAX_IMAGES, MAX_PENDING, MAX_TITLE_CHARS, MAX_TIMER_DELAY_MS, LATE_THRESHOLD_MS, RETENTION_MS, REPEAT_FREQUENCIES, pad2, toMeridiem, to24Hour, formatClock, parseLocalDate, isValidTimeZone, localParts, zoneOffsetMs, wallClockToInstant, buildRrule, skipsShortMonths, summariseTitle, validateCreateRequest, earliestPending, timerDelay, isLate, pruneTargets, pendingSummary, describeDue, describeZone, DEEPSEEK_PEAK_UTC_MINUTES, isDeepSeekPeak, isDeepSeekModel, describePeakWindows, deepSeekPeakNotice }
480
560
  })()
481
561
 
482
562
  const EN = {
@@ -500,6 +580,7 @@ const EN = {
500
580
  "dialog.meridiem": "AM or PM",
501
581
  "dialog.summary": "Sends {time} ({zone})",
502
582
  "dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
583
+ "dialog.peakHint": "DeepSeek's busy window is {windows} — a send there bills at 2× the off-peak rate. An evening or weekend slot is half price, so it's worth nudging this one later.",
503
584
  "dialog.repeat": "Repeat",
504
585
  "dialog.repeatHint": "Create an automation task that sends this text again on a rule.",
505
586
  "dialog.repeatLocked": "Repeating messages can't include images or files.",
@@ -583,6 +664,7 @@ const ZH = {
583
664
  "dialog.meridiem": "上午或下午",
584
665
  "dialog.summary": "将于 {time}({zone})发送",
585
666
  "dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
667
+ "dialog.peakHint": "DeepSeek 的高峰时段是 {windows}——此时段的价格是平时的 2 倍;挪到晚上或周末就是半价,晚一点发更划算。",
586
668
  "dialog.repeat": "重复",
587
669
  "dialog.repeatHint": "创建自动化任务,按规则重复发送这段文字。",
588
670
  "dialog.repeatLocked": "重复消息不能包含图片或文件。",
@@ -749,6 +831,56 @@ function createClientPlugin(require) {
749
831
  return useSyncExternalStore(subscribeRefresh, () => refreshSeq, () => 0)
750
832
  }
751
833
 
834
+ /**
835
+ * The session's current model id, read from the same shared directory the
836
+ * composer's model seat renders, so the dialog follows a model switch live.
837
+ * @param sessionId - owning conversation, or undefined outside a seat.
838
+ * @returns the model id, or null when the profile has no model selection.
839
+ */
840
+ /**
841
+ * The Session's durable model selection, read straight from its projection.
842
+ *
843
+ * A fallback for the first paint, or a profile whose model catalog failed to
844
+ * load, where the shared directory has no `current` yet. Every step is
845
+ * optional-chained: an unexpected shape answers null instead of throwing.
846
+ * @param sessionId - owning conversation.
847
+ * @returns the model id, or null when the projection cannot be read.
848
+ */
849
+ function projectSessionModel(sessionId) {
850
+ try {
851
+ const face = services.sessions?.binding?.(sessionId)?.session?.projections?.faceOf?.('modelSelection')
852
+ const projected = face?.getSnapshot?.()
853
+ const selection = projected?.next ?? projected?.lastUsed ?? null
854
+ return typeof selection?.model === 'string' ? selection.model : null
855
+ } catch {
856
+ return null
857
+ }
858
+ } function useSessionModelName(sessionId) {
859
+ const store = useMemo(() => {
860
+ const resolver = services.modelDirectories
861
+ if (resolver === undefined || sessionId === undefined || typeof resolver.directoryFor !== 'function') return null
862
+ try {
863
+ return resolver.directoryFor(sessionId)?.store ?? null
864
+ } catch {
865
+ return null
866
+ }
867
+ }, [sessionId])
868
+ const subscribe = useCallback(
869
+ (listener) => (store === null ? () => {} : store.subscribe(() => listener())),
870
+ [store],
871
+ )
872
+ const state = useSyncExternalStore(
873
+ subscribe,
874
+ () => (store === null ? null : store.getSnapshot()),
875
+ () => null,
876
+ )
877
+ const selection = state?.current ?? state?.pending ?? null
878
+ if (typeof selection?.model === 'string') return selection.model
879
+ // The directory only reports a selection once the shared catalog has
880
+ // loaded; the Session's own durable projection answers before that.
881
+ return projectSessionModel(sessionId)
882
+ }
883
+
752
884
  /** Mounted composers, used by the shortcut to find the focused conversation. */
753
885
  const composers = new Map()
754
886
  let lastTouchedSession = null
@@ -851,7 +983,7 @@ function createClientPlugin(require) {
851
983
  * service the scope never runs and the feature that needs it degrades to a
852
984
  * no-op.
853
985
  */
854
- const services = { sessions: undefined, layout: undefined }
986
+ const services = { sessions: undefined, layout: undefined, modelDirectories: undefined }
855
987
 
856
988
  function errorText(t, error) {
857
989
  const code = error?.code
@@ -914,6 +1046,19 @@ function createClientPlugin(require) {
914
1046
  h('path', { d: 'm4.5 4.5 7 7m0-7-7 7', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
915
1047
  }
916
1048
 
1049
+ /** Small upward-trend mark for the peak-window notice. */
1050
+ function PeakIcon() {
1051
+ return h('svg', { viewBox: '0 0 16 16', width: 13, height: 13, 'aria-hidden': 'true', focusable: 'false' },
1052
+ h('path', {
1053
+ d: 'M2.4 11.6 6 7.6l2.6 2.2 3.9-5',
1054
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
1055
+ }),
1056
+ h('path', {
1057
+ d: 'M10.6 4.4h2.6v2.6',
1058
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
1059
+ }))
1060
+ }
1061
+
917
1062
  // ---------------------------------------------------------------- dialog
918
1063
 
919
1064
  /**
@@ -924,7 +1069,7 @@ function createClientPlugin(require) {
924
1069
  * keep the dialog open with an inline error).
925
1070
  */
926
1071
  function ScheduleDialog(props) {
927
- const { t, zone, mode, initial, repeatLocked, previewText, attachmentCount, onSubmit, onClose } = props
1072
+ const { t, zone, mode, initial, repeatLocked, previewText, attachmentCount, modelName, onSubmit, onClose } = props
928
1073
  const locale = currentLocale()
929
1074
  const titleId = useId()
930
1075
  const panel = useRef(null)
@@ -941,6 +1086,16 @@ function createClientPlugin(require) {
941
1086
 
942
1087
  const quickPicks = useMemo(() => listQuickPicks(t, zone, now), [t, zone, Math.floor(now / 60_000)])
943
1088
 
1089
+ /** Peak-price notice: DeepSeek models only, and only inside a peak window. */
1090
+ const peak = useMemo(
1091
+ () => core.deepSeekPeakNotice({
1092
+ instantMs: resolved.ok === true ? resolved.instantMs : null,
1093
+ modelName,
1094
+ timeZone: zone,
1095
+ }) ?? null,
1096
+ [resolved, modelName, zone],
1097
+ )
1098
+
944
1099
  useEffect(() => {
945
1100
  restore.current = typeof document !== 'undefined' ? document.activeElement : null
946
1101
  const first = panel.current?.querySelector('button, select, input')
@@ -1083,6 +1238,11 @@ function createClientPlugin(require) {
1083
1238
  }, meridiem)))),
1084
1239
 
1085
1240
  h('p', { className: `dsm-summary${tooSoon ? ' dsm-summary-bad' : ''}`, role: 'status' }, summary),
1241
+ peak === null
1242
+ ? null
1243
+ : h('p', { className: 'dsm-peak' },
1244
+ h(PeakIcon),
1245
+ h('span', null, t('dialog.peakHint', { windows: peak.windows }))),
1086
1246
 
1087
1247
  mode === 'create'
1088
1248
  ? h('div', { className: 'dsm-repeat' },
@@ -1275,6 +1435,7 @@ function createClientPlugin(require) {
1275
1435
  const attachmentIds = Array.isArray(input?.attachmentIds) ? input.attachmentIds : []
1276
1436
  const phase = typeof input?.phase === 'string' ? input.phase : 'plain'
1277
1437
  const openSeq = useOpenRequest(sessionId)
1438
+ const modelName = useSessionModelName(sessionId)
1278
1439
  const [open, setOpen] = useState(false)
1279
1440
  const zone = useMemo(currentZone, [])
1280
1441
 
@@ -1324,6 +1485,7 @@ function createClientPlugin(require) {
1324
1485
  t,
1325
1486
  zone,
1326
1487
  mode: 'create',
1488
+ modelName,
1327
1489
  initial: defaultSelection(zone),
1328
1490
  repeatLocked: attachmentIds.length > 0,
1329
1491
  previewText: draft,
@@ -1346,6 +1508,7 @@ function createClientPlugin(require) {
1346
1508
  const refresh = useRefreshSignal()
1347
1509
  const zone = useMemo(currentZone, [])
1348
1510
  const locale = useMemo(currentLocale, [])
1511
+ const modelName = useSessionModelName(sessionId)
1349
1512
  const [items, setItems] = useState([])
1350
1513
  const [failure, setFailure] = useState(null)
1351
1514
  const [expanded, setExpanded] = useState(false)
@@ -1474,6 +1637,7 @@ function createClientPlugin(require) {
1474
1637
  : defaultSelection(zone),
1475
1638
  repeatLocked: true,
1476
1639
  previewText: '',
1640
+ modelName,
1477
1641
  attachmentCount: 0,
1478
1642
  onClose: () => setEditing(null),
1479
1643
  onSubmit: async (plan) => {
@@ -1596,6 +1760,13 @@ function createClientPlugin(require) {
1596
1760
  .dsm-seg-on{border-color:${ORANGE};color:#fff;background:${ORANGE}}
1597
1761
  .dsm-summary{margin:0;font-size:12px;line-height:1.45;color:var(--dsw-alias-label-secondary,#52525b)}
1598
1762
  .dsm-summary-bad{color:var(--dsw-alias-state-error-primary,#dc2626)}
1763
+ .dsm-peak{display:flex;align-items:flex-start;gap:6px;margin:0;padding:7px 9px;border-radius:8px;
1764
+ font-size:12px;line-height:1.45;color:#C2410C;background:rgba(249,115,22,.10);
1765
+ border:1px solid rgba(249,115,22,.32)}
1766
+ .dsm-peak svg{flex:none;margin-top:1px}
1767
+ @media (prefers-color-scheme:dark){
1768
+ .dsm-peak{color:#FDBA74;background:rgba(249,115,22,.16);border-color:rgba(253,186,116,.34)}
1769
+ }
1599
1770
  .dsm-repeat{display:flex;flex-direction:column;gap:8px;padding-top:4px;border-top:1px solid var(--dsw-alias-border-l1,#f0f0f1)}
1600
1771
  .dsm-check{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;cursor:pointer;min-height:32px}
1601
1772
  .dsm-check-off{opacity:.55;cursor:not-allowed}
@@ -1698,6 +1869,16 @@ function createClientPlugin(require) {
1698
1869
  }
1699
1870
  })
1700
1871
 
1872
+ // The composer's model seat and the /model popup share one directory; the
1873
+ // dialog reads that same selection to know whether the DeepSeek price clock
1874
+ // applies. A profile without it simply never shows the notice.
1875
+ ctx.inject(['modelDirectories'], (scope) => {
1876
+ services.modelDirectories = scope.modelDirectories
1877
+ return () => {
1878
+ services.modelDirectories = undefined
1879
+ }
1880
+ })
1881
+
1701
1882
  ctx.inject(['shortcuts'], (scope) => {
1702
1883
  scope.effect(() => scope.shortcuts.register({
1703
1884
  id: SHORTCUT_ID,
package/docs/DETAIL.md CHANGED
@@ -257,6 +257,19 @@ overlays, so a portal is required, not cosmetic. It holds:
257
257
  shorter months are skipped;
258
258
  - footer with Cancel and the primary action, which becomes **Create automation** when Repeat is on.
259
259
 
260
+ **Peak-price notice.** The dialog also reads the Session's model selection —
261
+ `ctx.modelDirectories.directoryFor(sessionId).store`, the per-session directory the composer's model
262
+ seat and the `/model` popup already share — and, when that model id contains `deepseek` *and* the
263
+ chosen instant falls inside DeepSeek's published peak window, adds one orange line naming both
264
+ windows in the reader's zone. The published rule (`api-docs.deepseek.com/quick_start/pricing`, read
265
+ 2026-11-14) is: off-peak rates are half the peak rates, peak hours are 01:00–04:00 and 06:00–10:00
266
+ UTC Monday to Friday excluding Chinese public holidays, and every other hour is off-peak. The
267
+ decision is `core.deepSeekPeakNotice` — model family, instant, zone in, notice or `null` out — so it
268
+ is unit-tested without a browser; the seat only renders what it returns. Two honest limits: Chinese
269
+ public holidays are not modelled (the copy speaks about the weekday clock rather than promising a
270
+ price), and a scheduled message carries no model of its own, so the note reflects the model selected
271
+ when the time was chosen.
272
+
260
273
  Escape, the backdrop and the header button close it; Tab is trapped; focus returns to whatever was
261
274
  focused before it opened. Submitting converts the selection with `core.wallClockToInstant` in the
262
275
  browser's zone and sends one absolute instant, so the Host never has to guess a zone.
package/lib/core.js CHANGED
@@ -464,3 +464,83 @@ export function describeZone(instantMs, locale, timeZone) {
464
464
  return timeZone
465
465
  }
466
466
  }
467
+
468
+ // ---------------------------------------------------------------- DeepSeek price clock
469
+
470
+ /**
471
+ * DeepSeek's peak-price windows as UTC minutes since midnight.
472
+ *
473
+ * DeepSeek documents the rule verbatim: "Off-peak rates are half of the peak
474
+ * rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through
475
+ * Friday, excluding Chinese public holidays. All other hours are off-peak,
476
+ * including weekends and Chinese public holidays in full."
477
+ * (https://api-docs.deepseek.com/quick_start/pricing, read 2026-11-14.)
478
+ *
479
+ * Chinese public holidays are not modelled: the notice speaks about the
480
+ * weekday clock, which is right for every ordinary working week.
481
+ */
482
+ export const DEEPSEEK_PEAK_UTC_MINUTES = Object.freeze([[60, 240], [360, 600]])
483
+
484
+ /**
485
+ * Whether one instant is billed at DeepSeek's standard (peak) rate.
486
+ * @param instantMs - epoch milliseconds.
487
+ * @returns whether the instant sits inside a weekday peak window.
488
+ */
489
+ export function isDeepSeekPeak(instantMs) {
490
+ if (!Number.isFinite(instantMs)) return false
491
+ const instant = new Date(instantMs)
492
+ const weekday = instant.getUTCDay()
493
+ if (weekday === 0 || weekday === 6) return false
494
+ const minutes = instant.getUTCHours() * 60 + instant.getUTCMinutes()
495
+ return DEEPSEEK_PEAK_UTC_MINUTES.some(([start, end]) => minutes >= start && minutes < end)
496
+ }
497
+
498
+ /**
499
+ * Whether a model id belongs to the DeepSeek family, whose clock is priced.
500
+ * @param modelName - provider model id, e.g. `deepseek-v4.1-flash`.
501
+ * @returns whether the name contains `deepseek`, case-insensitively.
502
+ */
503
+ export function isDeepSeekModel(modelName) {
504
+ return typeof modelName === 'string' && /deepseek/i.test(modelName)
505
+ }
506
+
507
+ /**
508
+ * Render both peak windows in the reader's zone, e.g. `09:00–12:00 / 14:00–18:00`.
509
+ * A zone west of UTC shows a window on the neighbouring date, which is
510
+ * correct: the boundaries are instants, not wall-clock times.
511
+ * @param instantMs - the instant whose UTC date anchors the windows.
512
+ * @param timeZone - explicit IANA zone used for display.
513
+ * @returns the two windows joined by ` / `.
514
+ */
515
+ export function describePeakWindows(instantMs, timeZone) {
516
+ const at = Number.isFinite(instantMs) ? instantMs : Date.now()
517
+ const stamp = new Date(at)
518
+ const dayStart = Date.UTC(stamp.getUTCFullYear(), stamp.getUTCMonth(), stamp.getUTCDate())
519
+ const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC'
520
+ const clock = (ms) => {
521
+ try {
522
+ return new Intl.DateTimeFormat('en-GB', {
523
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone,
524
+ }).format(new Date(ms))
525
+ } catch {
526
+ return new Date(ms).toISOString().slice(11, 16)
527
+ }
528
+ }
529
+ return DEEPSEEK_PEAK_UTC_MINUTES
530
+ .map(([start, end]) => `${clock(dayStart + start * 60_000)}–${clock(dayStart + end * 60_000)}`)
531
+ .join(' / ')
532
+ }
533
+
534
+ /**
535
+ * The peak-window notice the dialog shows, or `null` when it does not apply.
536
+ *
537
+ * One rule, decided in one place: only a DeepSeek model is billed on this
538
+ * clock, and only a chosen instant inside a peak window is worth mentioning.
539
+ * @param options - chosen instant, model id and display zone.
540
+ * @returns `{ windows }` with the windows in the reader's zone, or `null`.
541
+ */
542
+ export function deepSeekPeakNotice({ instantMs, modelName, timeZone } = {}) {
543
+ if (!isDeepSeekModel(modelName)) return null
544
+ if (!isDeepSeekPeak(instantMs)) return null
545
+ return { windows: describePeakWindows(instantMs, timeZone) }
546
+ }
package/locale/en.json CHANGED
@@ -21,7 +21,8 @@
21
21
  "dialog.minute": "Minute",
22
22
  "dialog.meridiem": "AM or PM",
23
23
  "dialog.summary": "Sends {time} ({zone})",
24
- "dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
24
+ "dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
25
+ "dialog.peakHint": "DeepSeek's busy window is {windows} — a send there bills at 2× the off-peak rate. An evening or weekend slot is half price, so it's worth nudging this one later.",
25
26
  "dialog.repeat": "Repeat",
26
27
  "dialog.repeatHint": "Create an automation task that sends this text again on a rule.",
27
28
  "dialog.repeatLocked": "Repeating messages can't include images or files.",
package/locale/zh.json CHANGED
@@ -21,7 +21,8 @@
21
21
  "dialog.minute": "分钟",
22
22
  "dialog.meridiem": "上午或下午",
23
23
  "dialog.summary": "将于 {time}({zone})发送",
24
- "dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
24
+ "dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
25
+ "dialog.peakHint": "DeepSeek 的高峰时段是 {windows}——此时段的价格是平时的 2 倍;挪到晚上或周末就是半价,晚一点发更划算。",
25
26
  "dialog.repeat": "重复",
26
27
  "dialog.repeatHint": "创建自动化任务,按规则重复发送这段文字。",
27
28
  "dialog.repeatLocked": "重复消息不能包含图片或文件。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scheduled-message",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Schedule the message you are writing to send later, or turn it into a repeating automation task.",
5
5
  "license": "MIT",
6
6
  "author": "intelfabs",