dsh-later 1.1.0 → 1.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/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@
4
4
 
5
5
  ## 未发布
6
6
 
7
+ ## 1.2.0 — 2026-10-09
8
+
9
+ - **feat** 暴露跨插件「定时任务摘要」公共 API(issue #17)。
10
+ - 新增 `src/schedule-summary.ts`(纯函数,宿主/浏览器/`node --test` 共用):
11
+ `buildScheduleSummary` 把 `sessions.list` 折叠成
12
+ `Map<SessionId, { count, pausedCount, nextAt, state }>`;`summarizeScheduleGroup`
13
+ 按分组聚合(会话数/任务数/最早触发/最高优先级,`overdue > urgent > scheduled`)。
14
+ - 新增 `src/schedule-summary-store.ts`:可订阅 store(`getSnapshot` / `subscribe` /
15
+ `reload` / `dispose`)+ 全局登记表,**唯一数据权威**——内部负责 `sessions.list`
16
+ 订阅、30s 状态 tick 与 60s `session.list` 读 RPC(仅页面可见时)。
17
+ - 新增 `src/client/useScheduleSummary.ts`:React 订阅 hook(`useSyncExternalStore`,
18
+ store 迟到时自动重渲染)。
19
+ - `lib/client.js` 导出 `getScheduleSummaryStore` / `subscribeScheduleSummaryStore` /
20
+ `useScheduleSummary` / `buildScheduleSummary` / `schedulesOfProjection` /
21
+ `summarizeScheduleGroup` / `createScheduleSummaryStore` 及类型;消费方(如
22
+ dsh-group-status)声明 `dsh.client.external: ["dsh-later"]` 后
23
+ `require('dsh-later/client')` 即可使用。契约见
24
+ [API 参考 §7](./docs/technical/03-api-reference.md)。
25
+ - **refactor** 侧栏 badge 不再自己读 `sessions.list` / 周期 `refresh()`,改为消费同一个
26
+ store:外部插件读到的摘要与本插件行内 badge 必然同源,且 `session.list` 读 RPC
27
+ 不再被拉两次。
28
+ - 新增 15 个单测:纯逻辑(数量/状态分档/暂停语义/分组聚合/判等)、store
29
+ (列表变化、状态 tick、refresh 闸门、dispose、登记表)与构建产物契约
30
+ (`lib/client.js` 导出面 + apply 登记/注销 store)。
31
+
32
+ - **feat** 侧栏 badge 进度环(方案 A:track + prog 双环)。
33
+ - 新版时钟 glyph 从单环改为双环:`ss-clock-track` 恒定底环(总窗口参照),
34
+ `ss-clock-prog` 进度弧从 12 点顺时针填充(`stroke-dashoffset` 驱动)。
35
+ - 新增 `progressFrom` 字段(`PresenceSummary`):窗口起点时刻(epoch ms),
36
+ 只跟随 `nextAt` 那条任务。
37
+ - 新增 `windowStartOf` 推导逻辑,按优先级取窗口跨度:
38
+ 1. `window_seconds`(宿主/投影显式给)→ 最权威
39
+ 2. `after_seconds` / `every_seconds`(按 `kind` 推)
40
+ 3. `created_at`(`at` 类型回退)
41
+ 推不出则 `progressFrom` 缺省,视觉退回恒定整环(旧形状即退化态)。
42
+ - `progressOf(entry, now)` 返回 0–1 比例;超时停满环(不绕出去)。
43
+ - urgent 只脉冲底环(进度弧跟着闪会让比例不可读)。
44
+ - 减少动效模式:进度弧位移过渡一并停。
45
+ - 新增 11 个单测:after/every/at 窗口推导、window_seconds 优先、退化/非法值、
46
+ 多任务窗口跟随 nextAt、暂停项排除、camelCase 兼容、无窗口时形状不变。
47
+ - `sameScheduleSummary` 补 `progressFrom` 比对(暂停→恢复窗口换了但 nextAt 未变时
48
+ 仍需通知订阅者刷新进度环)。
49
+
7
50
  ## 1.1.0 — 2026-10-09
8
51
 
9
52
  - **feat** 设置页 UI 对齐官方 `SettingsForm`:从折叠卡片改为裸表单,字段直接平铺。
package/README.en.md CHANGED
@@ -191,6 +191,52 @@ Works out of the box. Adjust in **DSH Settings → Plugins → Session Scheduler
191
191
 
192
192
  ---
193
193
 
194
+ ## For plugin developers: the schedule summary API (cross-plugin)
195
+
196
+ dsh-later exposes a workspace-wide schedule summary as a public data face
197
+ ([issue #17](http://192.168.4.77:3000/dsh-plugins/dsh-later/issues/17)), so other client
198
+ plugins (e.g. dsh-group-status) can aggregate schedule state on folders/sidebar rows
199
+ **without** parsing the `userSchedules` projection themselves — and without drifting
200
+ from the inline row badge.
201
+
202
+ Declare the external module in `package.json` first:
203
+
204
+ ```json
205
+ { "dsh": { "client": { "platform": "web", "external": ["dsh-later"] } } }
206
+ ```
207
+
208
+ Then `require('dsh-later/client')` inside the client factory:
209
+
210
+ ```js
211
+ const { getScheduleSummaryStore, summarizeScheduleGroup, useScheduleSummary } = require('dsh-later/client')
212
+
213
+ // 1) Read the store directly: session id → { count, pausedCount, nextAt, state }
214
+ const store = getScheduleSummaryStore() // undefined when dsh-later is not loaded
215
+ const summary = store?.getSnapshot() ?? new Map()
216
+
217
+ // 2) Aggregate per group (used by the folded-folder badge)
218
+ const group = summarizeScheduleGroup(summary, groupSessionIds)
219
+ // → { sessions, count, pausedCount, nextAt?, state? }
220
+
221
+ // 3) Subscribe inside a React component (re-renders if the store arrives late)
222
+ const live = useScheduleSummary()
223
+ ```
224
+
225
+ | Field | Meaning |
226
+ |---|---|
227
+ | `count` | Number of schedules (**including** paused ones) |
228
+ | `pausedCount` | Paused schedules (they count toward `count` but **not** toward `nextAt`) |
229
+ | `nextAt` | Earliest fire time (epoch ms); `undefined` when everything is paused |
230
+ | `state` | `scheduled` / `urgent` (≤5 min) / `overdue` (due but not dispatched), judged at snapshot time |
231
+
232
+ Contract notes:
233
+
234
+ - The summary contains **only sessions with schedules**; consumers look up their own `sessionIds`.
235
+ - The store is the **single source of truth**: dsh-later's own sidebar badge reads it too, so the
236
+ two can never disagree. It owns the `sessions.list` subscription, the 30s state tick and the
237
+ 60s `session.list` read RPC (visible-page only).
238
+ - Consumers must tolerate `undefined` (dsh-later not installed/enabled) and degrade silently.
239
+
194
240
  ## Technical documentation
195
241
 
196
242
  Developer- and maintainer-oriented content has moved out of this README:
package/README.md CHANGED
@@ -191,6 +191,50 @@ dsh plugin --profile web add "file:/path/to/dsh-later"
191
191
 
192
192
  ---
193
193
 
194
+ ## 给插件开发者:定时摘要 API(跨插件)
195
+
196
+ dsh-later 把「全会话定时任务摘要」作为公共数据面暴露出来
197
+ ([issue #17](http://192.168.4.77:3000/dsh-plugins/dsh-later/issues/17)),其它客户端插件
198
+ (如 dsh-group-status)据此在分组/侧栏聚合显示定时状态,**无需**自己解析
199
+ `userSchedules` 投影,也不会与行内 badge 漂移。
200
+
201
+ 消费方先在 `package.json` 声明外部模块:
202
+
203
+ ```json
204
+ { "dsh": { "client": { "platform": "web", "external": ["dsh-later"] } } }
205
+ ```
206
+
207
+ 然后在客户端 factory 里 `require('dsh-later/client')`:
208
+
209
+ ```js
210
+ const { getScheduleSummaryStore, summarizeScheduleGroup, useScheduleSummary } = require('dsh-later/client')
211
+
212
+ // 1) 直接读 store:会话 id → { count, pausedCount, nextAt, state }
213
+ const store = getScheduleSummaryStore() // dsh-later 未加载时为 undefined
214
+ const summary = store?.getSnapshot() ?? new Map()
215
+
216
+ // 2) 按分组聚合(折叠分组徽标直接用它)
217
+ const group = summarizeScheduleGroup(summary, groupSessionIds)
218
+ // → { sessions, count, pausedCount, nextAt?, state? }
219
+
220
+ // 3) React 组件内订阅(store 迟到也会自动重渲染)
221
+ const live = useScheduleSummary()
222
+ ```
223
+
224
+ | 字段 | 含义 |
225
+ |---|---|
226
+ | `count` | 任务数(**含**暂停项) |
227
+ | `pausedCount` | 暂停项数量(暂停项计入 count,但**不参与** `nextAt`) |
228
+ | `nextAt` | 最早触发时刻(epoch ms);全部暂停时为 `undefined` |
229
+ | `state` | `scheduled` / `urgent`(≤5 分钟)/ `overdue`(已到期未派发),按快照时刻判定 |
230
+
231
+ 契约要点:
232
+
233
+ - 摘要**只包含有任务的会话**,无任务的会话不出现;消费方按自己的 `sessionIds` 查表即可。
234
+ - store 是**唯一数据权威**:dsh-later 侧栏 badge 也读它,因此两边必然同源;它内部负责
235
+ `sessions.list` 订阅、30s 状态 tick 与 60s `session.list` 读 RPC(仅页面可见时)。
236
+ - 消费方必须容忍 `undefined`(dsh-later 未安装/未启用时静默降级)。
237
+
194
238
  ## 技术文档
195
239
 
196
240
  以下内容面向开发者与维护者,已移出本 README:
package/lib/client.js CHANGED
@@ -26,8 +26,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
26
26
  var index_exports = {};
27
27
  __export(index_exports, {
28
28
  apply: () => apply,
29
+ buildScheduleSummary: () => buildScheduleSummary,
30
+ createScheduleSummaryStore: () => createScheduleSummaryStore,
31
+ getScheduleSummaryStore: () => getScheduleSummaryStore,
29
32
  inject: () => inject,
30
- name: () => name
33
+ name: () => name,
34
+ schedulesOfProjection: () => schedulesOfProjection,
35
+ subscribeScheduleSummaryStore: () => subscribeScheduleSummaryStore,
36
+ summarizeScheduleGroup: () => summarizeScheduleGroup,
37
+ useScheduleSummary: () => useScheduleSummary
31
38
  });
32
39
  module.exports = __toCommonJS(index_exports);
33
40
 
@@ -419,15 +426,22 @@ var COMPONENTS = [
419
426
  '.ss-presence[data-state="overdue"] .ss-presence-ring { animation: none; }',
420
427
  "",
421
428
  "/* \u65B0\u7248\u63CF\u8FB9\u65F6\u949F\uFF08modern\uFF09\u2014 \u5BF9\u9F50 DSH \u5B98\u65B9 spinner \u6837\u5F0F \u2500\u2500 */",
422
- ".ss-presence .ss-clock-face {",
429
+ "/* \u53CC\u73AF\uFF1Atrack \u6052\u5B9A\u4E3A\u5E95\uFF08\u7ED9\u51FA\u300C\u603B\u7A97\u53E3\u300D\u7684\u89C6\u89C9\u53C2\u7167\uFF09\uFF0Cprog \u662F\u4ECE 12 \u70B9\u8D77\u7684\u8FDB\u5EA6\u5F27\u3002",
430
+ " \u65E0\u4ECE\u7B97\u8FDB\u5EA6\u65F6 JS \u628A prog \u7F6E\u4E3A display:none \u2014\u2014 \u6B64\u65F6\u89C6\u89C9\u4E0E\u6539\u9020\u524D\u7684\u5355\u73AF\u5B8C\u5168\u4E00\u81F4\u3002 */",
431
+ ".ss-presence .ss-clock-track {",
423
432
  " fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;",
424
433
  " opacity: 0.25; animation: ss-clock-breathe 2.4s var(--ds-ease-in-out, ease-in-out) infinite;",
425
434
  "}",
435
+ ".ss-presence .ss-clock-prog {",
436
+ " fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;",
437
+ " opacity: 1; transition: stroke-dashoffset 0.5s var(--ds-ease-in-out, ease-in-out);",
438
+ "}",
426
439
  ".ss-presence .ss-clock-hand { stroke: currentColor; stroke-width: 2; stroke-linecap: round; }",
427
440
  ".ss-presence .ss-clock-hand-h { opacity: 1; }",
428
441
  ".ss-presence .ss-clock-hand-m { opacity: 0.85; }",
429
- '.ss-presence[data-state="urgent"] .ss-clock-face { animation-name: ss-clock-pulse; animation-duration: 0.9s; }',
430
- '.ss-presence[data-state="overdue"] .ss-clock-face { animation: none; opacity: 1; }',
442
+ "/* urgent \u53EA\u8109\u51B2\u5E95\u73AF\uFF1A\u8FDB\u5EA6\u5F27\u627F\u8F7D\u786E\u5B9A\u4FE1\u606F\uFF0C\u8DDF\u7740\u95EA\u4F1A\u8BA9\u6BD4\u4F8B\u4E0D\u53EF\u8BFB\u3002 */",
443
+ '.ss-presence[data-state="urgent"] .ss-clock-track { animation-name: ss-clock-pulse; animation-duration: 0.9s; }',
444
+ '.ss-presence[data-state="overdue"] .ss-clock-track { animation: none; opacity: 1; }',
431
445
  "",
432
446
  "/* \u2500\u2500 \u4E09\u6001\u989C\u8272 \u2500\u2500 */",
433
447
  '.ss-presence[data-state="urgent"] { color: var(--dsw-alias-state-warn-secondary); }',
@@ -440,7 +454,9 @@ var COMPONENTS = [
440
454
  "@keyframes ss-clock-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }",
441
455
  "@media (prefers-reduced-motion: reduce) {",
442
456
  " .ss-presence .ss-presence-ring { animation: none; }",
443
- " .ss-presence .ss-clock-face { animation: none; }",
457
+ " .ss-presence .ss-clock-track { animation: none; }",
458
+ ' /* \u51CF\u5C11\u52A8\u6548\uFF1A\u8FDB\u5EA6\u5F27\u7684\u4F4D\u79FB\u8FC7\u6E21\u4E5F\u4E00\u5E76\u505C\uFF0C\u907F\u514D"\u8FDB\u5EA6\u81EA\u5DF1\u5728\u722C"\u7684\u89C2\u611F\u3002 */',
459
+ " .ss-presence .ss-clock-prog { transition: none; }",
444
460
  "}",
445
461
  "",
446
462
  "/* ==================== \u60AC\u6D6E\u5361\u7247\u72B6\u6001\u884C\u589E\u8865\uFF08issue #4\uFF09 ==================== */",
@@ -2594,6 +2610,7 @@ function SchedulerSettingsCard({ scope, t }) {
2594
2610
  var PRESENCE_URGENT_WINDOW_MS = 5 * 6e4;
2595
2611
  function summarizeSchedules(schedules) {
2596
2612
  let nextAt;
2613
+ let progressFrom;
2597
2614
  let count = 0;
2598
2615
  let pausedCount = 0;
2599
2616
  for (const item of schedules ?? []) {
@@ -2606,10 +2623,44 @@ function summarizeSchedules(schedules) {
2606
2623
  pausedCount += 1;
2607
2624
  continue;
2608
2625
  }
2609
- if (nextAt === void 0 || epoch < nextAt) nextAt = epoch;
2626
+ if (nextAt === void 0 || epoch < nextAt) {
2627
+ nextAt = epoch;
2628
+ progressFrom = windowStartOf(item, epoch);
2629
+ }
2610
2630
  }
2611
2631
  if (count === 0) return { count: 0, pausedCount: 0 };
2612
- return nextAt === void 0 ? { count, pausedCount } : { count, nextAt, pausedCount };
2632
+ return nextAt === void 0 ? { count, pausedCount } : { count, nextAt, pausedCount, ...progressFrom !== void 0 ? { progressFrom } : {} };
2633
+ }
2634
+ function windowStartOf(item, scheduledEpoch) {
2635
+ const explicit = toFiniteSeconds(item.window_seconds ?? item.windowSeconds);
2636
+ if (explicit !== void 0) return scheduledEpoch - explicit * 1e3;
2637
+ const afterSec = toFiniteSeconds(item.after_seconds ?? item.afterSeconds);
2638
+ const everySec = toFiniteSeconds(item.every_seconds ?? item.everySeconds);
2639
+ const span = item.kind === "every" ? everySec : afterSec ?? everySec;
2640
+ if (span !== void 0) return scheduledEpoch - span * 1e3;
2641
+ const created = toEpochMs(item.created_at) ?? toEpochMs(item.createdAt);
2642
+ if (created !== null) return created;
2643
+ return void 0;
2644
+ }
2645
+ function toFiniteSeconds(value) {
2646
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
2647
+ }
2648
+ function toEpochMs(value) {
2649
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
2650
+ if (typeof value === "string" && value.length > 0) {
2651
+ const parsed = Date.parse(value);
2652
+ return Number.isFinite(parsed) ? parsed : null;
2653
+ }
2654
+ return null;
2655
+ }
2656
+ function progressOf(entry, now) {
2657
+ const { nextAt, progressFrom } = entry;
2658
+ if (nextAt === void 0 || progressFrom === void 0) return void 0;
2659
+ const span = nextAt - progressFrom;
2660
+ if (!(span > 0)) return void 0;
2661
+ const ratio = (now - progressFrom) / span;
2662
+ if (!Number.isFinite(ratio)) return void 0;
2663
+ return ratio <= 0 ? 0 : ratio >= 1 ? 1 : ratio;
2613
2664
  }
2614
2665
  function badgeStateFor(nextAt, now) {
2615
2666
  if (nextAt === void 0) return "scheduled";
@@ -2659,9 +2710,10 @@ var HOVER_ROW_CLASS = "ss-hover-status";
2659
2710
  var FIBER_MAX_DEPTH = 8;
2660
2711
  var SCAN_DEBOUNCE_MS = 60;
2661
2712
  var STATE_TICK_MS = 3e4;
2662
- var REFRESH_INTERVAL_MS = 6e4;
2663
2713
  var CLOCK_SVG_CLASSIC = '<svg width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges" aria-hidden="true"><g class="ss-presence-ring"><rect x="0" y="0" width="2" height="2"/><rect x="4" y="0" width="2" height="2"/><rect x="8" y="0" width="2" height="2"/><rect x="8" y="4" width="2" height="2"/><rect x="8" y="8" width="2" height="2"/><rect x="4" y="8" width="2" height="2"/><rect x="0" y="8" width="2" height="2"/><rect x="0" y="4" width="2" height="2"/></g><g class="ss-presence-hand"><rect x="4" y="2" width="2" height="2"/><rect x="6" y="4" width="2" height="2"/></g></svg>';
2664
- var CLOCK_SVG_MODERN = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><circle class="ss-clock-face" cx="12" cy="12" r="9.5"/><g class="ss-clock-hand"><line class="ss-clock-hand-h" x1="12" y1="12" x2="12" y2="6.5"/><line class="ss-clock-hand-m" x1="12" y1="12" x2="17" y2="12"/></g></svg>';
2714
+ var CLOCK_FACE_R = 9.5;
2715
+ var CLOCK_FACE_CIRCUMFERENCE = 2 * Math.PI * CLOCK_FACE_R;
2716
+ var CLOCK_SVG_MODERN = `<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><circle class="ss-clock-track" cx="12" cy="12" r="${CLOCK_FACE_R}"/><circle class="ss-clock-prog" cx="12" cy="12" r="${CLOCK_FACE_R}" stroke-dasharray="${CLOCK_FACE_CIRCUMFERENCE.toFixed(3)}" stroke-dashoffset="${CLOCK_FACE_CIRCUMFERENCE.toFixed(3)}" transform="rotate(-90 12 12)"/><g class="ss-clock-hand"><line class="ss-clock-hand-h" x1="12" y1="12" x2="12" y2="6.5"/><line class="ss-clock-hand-m" x1="12" y1="12" x2="17" y2="12"/></g></svg>`;
2665
2717
  var hhmmFormatter = new Intl.DateTimeFormat(void 0, { hour: "2-digit", minute: "2-digit", hour12: false });
2666
2718
  var dateFormatter = new Intl.DateTimeFormat(void 0, { month: "short", day: "numeric" });
2667
2719
  var formatHhmm2 = (epoch) => hhmmFormatter.format(epoch);
@@ -2684,14 +2736,9 @@ function sessionIdOfNode(element) {
2684
2736
  }
2685
2737
  return void 0;
2686
2738
  }
2687
- function buildIndex(state) {
2688
- const index = /* @__PURE__ */ new Map();
2689
- for (const [sessionId, summary] of Object.entries(state.byId)) {
2690
- const schedules = summary.projectionValues?.["userSchedules"]?.schedules;
2691
- const entry = summarizeSchedules(schedules);
2692
- if (entry.count > 0) index.set(sessionId, entry);
2693
- }
2694
- return index;
2739
+ function currentIndex(summary) {
2740
+ summary.reload();
2741
+ return summary.getSnapshot();
2695
2742
  }
2696
2743
  function findBadge(row) {
2697
2744
  for (const child of Array.from(row.children)) {
@@ -2712,7 +2759,7 @@ function labelsFor(dict) {
2712
2759
  };
2713
2760
  }
2714
2761
  function mountSessionPresence(options) {
2715
- const { sessions, locale, getClockIconStyle } = options;
2762
+ const { summary, locale, getClockIconStyle } = options;
2716
2763
  if (typeof document === "undefined" || typeof MutationObserver === "undefined") {
2717
2764
  return () => void 0;
2718
2765
  }
@@ -2726,7 +2773,7 @@ function mountSessionPresence(options) {
2726
2773
  const dictForNow = () => dictFor(locale?.getSnapshot().active);
2727
2774
  const scan = () => {
2728
2775
  if (disposed) return;
2729
- index = buildIndex(sessions.list.getSnapshot());
2776
+ index = currentIndex(summary);
2730
2777
  const dict = dictForNow();
2731
2778
  const now = Date.now();
2732
2779
  for (const row of Array.from(document.querySelectorAll('[role="treeitem"][draggable="true"]'))) {
@@ -2756,10 +2803,12 @@ function mountSessionPresence(options) {
2756
2803
  const allPaused = entry.nextAt === void 0 && entry.pausedCount > 0;
2757
2804
  const aria = allPaused ? format(dict.presencePausedAria, { n: entry.count }) : state === "overdue" ? format(dict.presenceOverdueAria, { n: entry.count }) : format(dict.presenceBadgeAria, { n: entry.count, time });
2758
2805
  const tooltip = allPaused ? format(dict.presencePausedTooltip, { n: entry.count }) : state === "overdue" ? format(dict.presenceOverdueTooltip, { n: entry.count }) : format(dict.presenceTooltip, { n: entry.count, time });
2806
+ const progress = progressOf(entry, now);
2759
2807
  if (existing !== null) {
2760
2808
  if (existing.dataset.state !== state) existing.dataset.state = state;
2761
2809
  if (existing.getAttribute("aria-label") !== aria) existing.setAttribute("aria-label", aria);
2762
2810
  if (existing.getAttribute("title") !== tooltip) existing.setAttribute("title", tooltip);
2811
+ applyProgress(existing, progress);
2763
2812
  return;
2764
2813
  }
2765
2814
  const slot = row.firstElementChild;
@@ -2772,9 +2821,20 @@ function mountSessionPresence(options) {
2772
2821
  badge.setAttribute("aria-label", aria);
2773
2822
  badge.setAttribute("title", tooltip);
2774
2823
  badge.innerHTML = getClockSvg();
2824
+ applyProgress(badge, progress);
2775
2825
  if (beside) slot.after(badge);
2776
2826
  else slot.append(badge);
2777
2827
  }
2828
+ function applyProgress(badge, ratio) {
2829
+ const arc = badge.querySelector(".ss-clock-prog");
2830
+ if (arc === null) return;
2831
+ if (ratio === void 0) {
2832
+ arc.style.display = "none";
2833
+ return;
2834
+ }
2835
+ if (arc.style.display === "none") arc.style.display = "";
2836
+ arc.style.strokeDashoffset = String(CLOCK_FACE_CIRCUMFERENCE * (1 - ratio));
2837
+ }
2778
2838
  function findHoverCard() {
2779
2839
  for (const card of Array.from(document.body.children)) {
2780
2840
  if (!(card instanceof HTMLElement)) continue;
@@ -2820,46 +2880,201 @@ function mountSessionPresence(options) {
2820
2880
  row.append(dot, text);
2821
2881
  statusHost.append(row);
2822
2882
  }
2823
- const unsubscribeList = sessions.list.subscribe(scheduleScan);
2883
+ const unsubscribeSummary = summary.subscribe(scheduleScan);
2824
2884
  const unsubscribeLocale = locale?.subscribe(scheduleScan);
2825
2885
  const observer = new MutationObserver(scheduleScan);
2826
2886
  observer.observe(document.body, { childList: true, subtree: true });
2827
- const refreshNow = () => {
2828
- if (disposed) return;
2829
- try {
2830
- void sessions.refresh().catch(() => void 0);
2831
- } catch {
2832
- }
2833
- };
2834
- const onVisible = () => {
2835
- if (document.visibilityState === "visible") {
2836
- refreshNow();
2837
- scheduleScan();
2838
- }
2839
- };
2840
- document.addEventListener("visibilitychange", onVisible);
2841
- window.addEventListener("online", onVisible);
2842
2887
  const stateTick = setInterval(scheduleScan, STATE_TICK_MS);
2843
- const refreshTick = setInterval(() => {
2844
- if (document.visibilityState === "visible") refreshNow();
2845
- }, REFRESH_INTERVAL_MS);
2846
- refreshNow();
2847
2888
  scheduleScan();
2848
2889
  return () => {
2849
2890
  disposed = true;
2850
2891
  if (scanTimer !== void 0) clearTimeout(scanTimer);
2851
2892
  clearInterval(stateTick);
2852
- clearInterval(refreshTick);
2853
2893
  observer.disconnect();
2854
- unsubscribeList();
2894
+ unsubscribeSummary();
2855
2895
  unsubscribeLocale?.();
2856
- document.removeEventListener("visibilitychange", onVisible);
2857
- window.removeEventListener("online", onVisible);
2858
2896
  for (const badge of Array.from(document.querySelectorAll(`.${BADGE_CLASS}`))) badge.remove();
2859
2897
  for (const row of Array.from(document.querySelectorAll(`.${HOVER_ROW_CLASS}`))) row.remove();
2860
2898
  };
2861
2899
  }
2862
2900
 
2901
+ // src/schedule-summary.ts
2902
+ var SCHEDULE_PROJECTION_KEY = "userSchedules";
2903
+ function schedulesOfProjection(projectionValues) {
2904
+ const value = projectionValues?.[SCHEDULE_PROJECTION_KEY];
2905
+ if (typeof value !== "object" || value === null) return void 0;
2906
+ const schedules = value.schedules;
2907
+ return Array.isArray(schedules) ? schedules : void 0;
2908
+ }
2909
+ function buildScheduleSummary(source, now) {
2910
+ const summary = /* @__PURE__ */ new Map();
2911
+ for (const [sessionId, row] of Object.entries(source.byId)) {
2912
+ if (row === void 0) continue;
2913
+ const entry = summarizeSchedules(schedulesOfProjection(row.projectionValues));
2914
+ if (entry.count === 0) continue;
2915
+ summary.set(sessionId, { ...entry, state: badgeStateFor(entry.nextAt, now) });
2916
+ }
2917
+ return summary;
2918
+ }
2919
+ function sameScheduleSummary(a, b) {
2920
+ if (a === b) return true;
2921
+ if (a.size !== b.size) return false;
2922
+ for (const [sessionId, left] of a) {
2923
+ const right = b.get(sessionId);
2924
+ if (right === void 0) return false;
2925
+ if (left.count !== right.count || left.pausedCount !== right.pausedCount || left.nextAt !== right.nextAt || left.progressFrom !== right.progressFrom || left.state !== right.state) {
2926
+ return false;
2927
+ }
2928
+ }
2929
+ return true;
2930
+ }
2931
+ var STATE_RANK = { scheduled: 0, urgent: 1, overdue: 2 };
2932
+ function summarizeScheduleGroup(summary, sessionIds) {
2933
+ let sessions = 0;
2934
+ let count = 0;
2935
+ let pausedCount = 0;
2936
+ let nextAt;
2937
+ let state;
2938
+ for (const sessionId of sessionIds) {
2939
+ const entry = summary.get(sessionId);
2940
+ if (entry === void 0) continue;
2941
+ sessions += 1;
2942
+ count += entry.count;
2943
+ pausedCount += entry.pausedCount;
2944
+ if (entry.nextAt !== void 0 && (nextAt === void 0 || entry.nextAt < nextAt)) nextAt = entry.nextAt;
2945
+ if (state === void 0 || STATE_RANK[entry.state] > STATE_RANK[state]) state = entry.state;
2946
+ }
2947
+ return nextAt === void 0 ? { sessions, count, pausedCount, state } : { sessions, count, pausedCount, nextAt, state };
2948
+ }
2949
+
2950
+ // src/schedule-summary-store.ts
2951
+ var SUMMARY_STATE_TICK_MS = 3e4;
2952
+ var SUMMARY_REFRESH_INTERVAL_MS = 6e4;
2953
+ function documentOf() {
2954
+ const doc = globalThis.document;
2955
+ return typeof doc === "object" && doc !== null ? doc : void 0;
2956
+ }
2957
+ function createScheduleSummaryStore(sessions, options = {}) {
2958
+ const now = options.now ?? Date.now;
2959
+ const stateTickMs = options.stateTickMs ?? SUMMARY_STATE_TICK_MS;
2960
+ const refreshIntervalMs = options.refreshIntervalMs ?? SUMMARY_REFRESH_INTERVAL_MS;
2961
+ const autoRefresh = options.autoRefresh ?? true;
2962
+ const canRefresh = options.canRefresh ?? (() => {
2963
+ const doc2 = documentOf();
2964
+ return doc2?.visibilityState === void 0 || doc2.visibilityState === "visible";
2965
+ });
2966
+ const listeners = /* @__PURE__ */ new Set();
2967
+ let snapshot = /* @__PURE__ */ new Map();
2968
+ let disposed = false;
2969
+ const notify = () => {
2970
+ for (const listener of [...listeners]) {
2971
+ try {
2972
+ listener();
2973
+ } catch {
2974
+ }
2975
+ }
2976
+ };
2977
+ const reload = () => {
2978
+ if (disposed) return;
2979
+ const next = buildScheduleSummary(sessions.list.getSnapshot(), now());
2980
+ if (sameScheduleSummary(snapshot, next)) return;
2981
+ snapshot = next;
2982
+ notify();
2983
+ };
2984
+ const unsubscribeList = sessions.list.subscribe(reload);
2985
+ const stateTick = setInterval(reload, stateTickMs);
2986
+ let refreshTick;
2987
+ let onVisible;
2988
+ const doc = documentOf();
2989
+ const target = globalThis;
2990
+ const refreshNow = () => {
2991
+ if (disposed || !canRefresh()) return;
2992
+ try {
2993
+ void sessions.refresh().catch(() => void 0);
2994
+ } catch {
2995
+ }
2996
+ };
2997
+ if (autoRefresh) {
2998
+ onVisible = () => {
2999
+ if (!disposed && canRefresh()) {
3000
+ refreshNow();
3001
+ reload();
3002
+ }
3003
+ };
3004
+ refreshTick = setInterval(() => {
3005
+ if (canRefresh()) refreshNow();
3006
+ }, refreshIntervalMs);
3007
+ doc?.addEventListener?.("visibilitychange", onVisible);
3008
+ target.addEventListener?.("online", onVisible);
3009
+ refreshNow();
3010
+ }
3011
+ reload();
3012
+ const dispose = () => {
3013
+ if (disposed) return;
3014
+ disposed = true;
3015
+ clearInterval(stateTick);
3016
+ if (refreshTick !== void 0) clearInterval(refreshTick);
3017
+ if (onVisible !== void 0) {
3018
+ doc?.removeEventListener?.("visibilitychange", onVisible);
3019
+ target.removeEventListener?.("online", onVisible);
3020
+ }
3021
+ unsubscribeList();
3022
+ listeners.clear();
3023
+ };
3024
+ return {
3025
+ getSnapshot: () => snapshot,
3026
+ subscribe: (listener) => {
3027
+ listeners.add(listener);
3028
+ return () => {
3029
+ listeners.delete(listener);
3030
+ };
3031
+ },
3032
+ reload,
3033
+ dispose
3034
+ };
3035
+ }
3036
+ var activeStore;
3037
+ var registryListeners = /* @__PURE__ */ new Set();
3038
+ function setActiveScheduleSummaryStore(store) {
3039
+ if (activeStore === store) return;
3040
+ activeStore = store;
3041
+ for (const listener of [...registryListeners]) {
3042
+ try {
3043
+ listener();
3044
+ } catch {
3045
+ }
3046
+ }
3047
+ }
3048
+ function getScheduleSummaryStore() {
3049
+ return activeStore;
3050
+ }
3051
+ function subscribeScheduleSummaryStore(listener) {
3052
+ registryListeners.add(listener);
3053
+ return () => {
3054
+ registryListeners.delete(listener);
3055
+ };
3056
+ }
3057
+
3058
+ // src/client/useScheduleSummary.ts
3059
+ var import_react9 = require("react");
3060
+ var EMPTY_SUMMARY = /* @__PURE__ */ new Map();
3061
+ var noopSubscribe2 = () => () => void 0;
3062
+ var emptySnapshot = () => EMPTY_SUMMARY;
3063
+ var missingSnapshot = () => void 0;
3064
+ function useScheduleSummary(store) {
3065
+ const registered = (0, import_react9.useSyncExternalStore)(subscribeScheduleSummaryStore, getScheduleSummaryStore, missingSnapshot);
3066
+ const target = store ?? registered;
3067
+ const subscribe = (0, import_react9.useMemo)(
3068
+ () => target === void 0 ? noopSubscribe2 : (listener) => target.subscribe(listener),
3069
+ [target]
3070
+ );
3071
+ const getSnapshot = (0, import_react9.useMemo)(
3072
+ () => target === void 0 ? emptySnapshot : () => target.getSnapshot(),
3073
+ [target]
3074
+ );
3075
+ return (0, import_react9.useSyncExternalStore)(subscribe, getSnapshot, emptySnapshot);
3076
+ }
3077
+
2863
3078
  // src/client/index.tsx
2864
3079
  var INPUT_RIGHT_SLOT = "conversation.input.right";
2865
3080
  var inject = ["slots", "sessions", "conversation", "locale"];
@@ -2874,9 +3089,18 @@ function apply(ctx) {
2874
3089
  ctx.effect(() => injectStyles(), "dsh-later: styles");
2875
3090
  let detectedVersion = "classic";
2876
3091
  let getClockIconStyle = () => "auto";
3092
+ const summary = createScheduleSummaryStore(sessions);
3093
+ setActiveScheduleSummaryStore(summary);
3094
+ ctx.effect(
3095
+ () => () => {
3096
+ if (getScheduleSummaryStore() === summary) setActiveScheduleSummaryStore(void 0);
3097
+ summary.dispose();
3098
+ },
3099
+ "dsh-later: schedule summary"
3100
+ );
2877
3101
  ctx.effect(
2878
3102
  () => mountSessionPresence({
2879
- sessions,
3103
+ summary,
2880
3104
  ...locale === void 0 ? {} : { locale },
2881
3105
  getClockIconStyle: () => {
2882
3106
  const style = getClockIconStyle();
package/lib/presence.js CHANGED
@@ -30,6 +30,7 @@ export const PRESENCE_URGENT_WINDOW_MS = 5 * 60_000;
30
30
  */
31
31
  export function summarizeSchedules(schedules) {
32
32
  let nextAt;
33
+ let progressFrom;
33
34
  let count = 0;
34
35
  let pausedCount = 0;
35
36
  for (const item of schedules ?? []) {
@@ -44,12 +45,81 @@ export function summarizeSchedules(schedules) {
44
45
  pausedCount += 1;
45
46
  continue; // 冻结项没有排定的触发时刻,不参与 nextAt
46
47
  }
47
- if (nextAt === undefined || epoch < nextAt)
48
+ if (nextAt === undefined || epoch < nextAt) {
48
49
  nextAt = epoch;
50
+ // 窗口起点只跟随被选中的那一条;并列最早时保留先出现者的窗口。
51
+ progressFrom = windowStartOf(item, epoch);
52
+ }
49
53
  }
50
54
  if (count === 0)
51
55
  return { count: 0, pausedCount: 0 };
52
- return nextAt === undefined ? { count, pausedCount } : { count, nextAt, pausedCount };
56
+ // 字段用条件展开:无进度时不产生 `progressFrom: undefined`,保持既有
57
+ // deepEqual 测试与 wire 形状不变。
58
+ return nextAt === undefined
59
+ ? { count, pausedCount }
60
+ : { count, nextAt, pausedCount, ...(progressFrom !== undefined ? { progressFrom } : {}) };
61
+ }
62
+ /**
63
+ * 推导进度窗口的起点时刻(epoch ms);无从推导时返回 `undefined`。
64
+ *
65
+ * 口径按优先级:
66
+ * 1. `window_seconds` —— 宿主/投影直接给的总窗口,最权威(暂停项的 wire 正是
67
+ * 用它携带"原 after 间隔",见 projection.ts 的 `pausedWireItemOf`)。
68
+ * 2. 按 `kind` 推:`after` 用 `after_seconds`,`every` 用 `every_seconds`。
69
+ * 两者都是"从触发时刻往回推一整个窗口",天然得到本周期起点。
70
+ * 3. `created_at` —— `at` 类型没有周期,只能拿任务创建时刻当起点。
71
+ *
72
+ * **推不出就返回 `undefined`**,绝不拿 `scheduledAt` 兜底:那会让 span 为 0,
73
+ * 进度恒为 100%,把"算不出来"伪装成"已经走完"。
74
+ */
75
+ function windowStartOf(item, scheduledEpoch) {
76
+ const explicit = toFiniteSeconds(item.window_seconds ?? item.windowSeconds);
77
+ if (explicit !== undefined)
78
+ return scheduledEpoch - explicit * 1000;
79
+ const afterSec = toFiniteSeconds(item.after_seconds ?? item.afterSeconds);
80
+ const everySec = toFiniteSeconds(item.every_seconds ?? item.everySeconds);
81
+ const span = item.kind === 'every' ? everySec : (afterSec ?? everySec);
82
+ if (span !== undefined)
83
+ return scheduledEpoch - span * 1000;
84
+ const created = toEpochMs(item.created_at) ?? toEpochMs(item.createdAt);
85
+ if (created !== null)
86
+ return created;
87
+ return undefined;
88
+ }
89
+ /** 秒数归一:只接受有限正数(0 / 负数 / NaN 一律视为"没给")。 */
90
+ function toFiniteSeconds(value) {
91
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
92
+ }
93
+ /** epoch ms(number)或 ISO 字符串统一成 epoch ms;无法解析返回 `null`。 */
94
+ function toEpochMs(value) {
95
+ if (typeof value === 'number')
96
+ return Number.isFinite(value) ? value : null;
97
+ if (typeof value === 'string' && value.length > 0) {
98
+ const parsed = Date.parse(value);
99
+ return Number.isFinite(parsed) ? parsed : null;
100
+ }
101
+ return null;
102
+ }
103
+ /**
104
+ * 进度环比例(0–1):`now` 在 `[progressFrom, nextAt]` 中的位置。
105
+ *
106
+ * 返回 `undefined` 表示**无从算起**(缺窗口起点,或窗口已退化),调用方此时应
107
+ * 退回「恒定整环」的旧表现,而不是画一个 0% 的空环 —— 后者是错误信息:
108
+ * 0% 是"刚开始",`undefined` 是"算不出来"。
109
+ *
110
+ * 两端夹住:`overdue` 时 `now > nextAt`,环停在满环而不是绕出去。
111
+ */
112
+ export function progressOf(entry, now) {
113
+ const { nextAt, progressFrom } = entry;
114
+ if (nextAt === undefined || progressFrom === undefined)
115
+ return undefined;
116
+ const span = nextAt - progressFrom;
117
+ if (!(span > 0))
118
+ return undefined;
119
+ const ratio = (now - progressFrom) / span;
120
+ if (!Number.isFinite(ratio))
121
+ return undefined;
122
+ return ratio <= 0 ? 0 : ratio >= 1 ? 1 : ratio;
53
123
  }
54
124
  /** 由当前时刻判定 badge 状态。 */
55
125
  export function badgeStateFor(nextAt, now) {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 定时摘要 store:把 {@link buildScheduleSummary} 的纯计算接到宿主 `sessions`
3
+ * 服务上,得到一个**可订阅的公共数据源**。
4
+ *
5
+ * 为什么需要它(issue #17):dsh-later 自己的侧栏 badge 与外部插件(如
6
+ * dsh-group-status)都需要「全会话定时任务摘要」。两条独立通路会各自订阅
7
+ * `sessions.list`、各自周期 `refresh()`,既浪费 RPC 又容易漂移。本 store 是
8
+ * **唯一**的数据权威:内部负责列表订阅、状态 tick(scheduled→urgent→overdue)
9
+ * 与 `session.list` 读 RPC 补时效;外部只读 {@link ScheduleSummaryStore.getSnapshot}
10
+ * 或订阅变更。
11
+ *
12
+ * 跨插件使用方式(消费方声明 `dsh.client.external: ["dsh-later"]` 后):
13
+ * ```js
14
+ * const later = require('dsh-later/client')
15
+ * const store = later.getScheduleSummaryStore()
16
+ * ```
17
+ *
18
+ * 本模块零 DOM / 零 React 依赖(`document` / `window` 一律经 `globalThis`
19
+ * 结构化探测),宿主 tsc 与浏览器 esbuild 都能编译,`node --test` 也能直接跑。
20
+ * @module dsh-later/schedule-summary-store
21
+ */
22
+ import { buildScheduleSummary, sameScheduleSummary } from './schedule-summary.js';
23
+ /** 摘要状态重算周期(本地时钟,零网络)。 */
24
+ export const SUMMARY_STATE_TICK_MS = 30_000;
25
+ /** `session.list` 重拉周期(读 RPC;仅页面可见时执行)。 */
26
+ export const SUMMARY_REFRESH_INTERVAL_MS = 60_000;
27
+ /** 取 `globalThis.document`(不引 DOM 类型,宿主编译期无 document 也能通过)。 */
28
+ function documentOf() {
29
+ const doc = globalThis.document;
30
+ return typeof doc === 'object' && doc !== null ? doc : undefined;
31
+ }
32
+ /**
33
+ * 创建定时摘要 store(宿主 `apply` 内创建一次,全插件共享)。
34
+ * @param sessions - 宿主 `sessions` 服务。
35
+ * @param options - 周期/时钟/刷新策略覆盖(单测用)。
36
+ * @returns 可订阅 store。
37
+ */
38
+ export function createScheduleSummaryStore(sessions, options = {}) {
39
+ const now = options.now ?? Date.now;
40
+ const stateTickMs = options.stateTickMs ?? SUMMARY_STATE_TICK_MS;
41
+ const refreshIntervalMs = options.refreshIntervalMs ?? SUMMARY_REFRESH_INTERVAL_MS;
42
+ const autoRefresh = options.autoRefresh ?? true;
43
+ const canRefresh = options.canRefresh ??
44
+ (() => {
45
+ const doc = documentOf();
46
+ return doc?.visibilityState === undefined || doc.visibilityState === 'visible';
47
+ });
48
+ const listeners = new Set();
49
+ let snapshot = new Map();
50
+ let disposed = false;
51
+ const notify = () => {
52
+ // 快照一份再遍历:订阅者可能在回调里退订。
53
+ for (const listener of [...listeners]) {
54
+ try {
55
+ listener();
56
+ }
57
+ catch {
58
+ /* 单个订阅者异常不影响其余订阅者与主流程 */
59
+ }
60
+ }
61
+ };
62
+ const reload = () => {
63
+ if (disposed)
64
+ return;
65
+ const next = buildScheduleSummary(sessions.list.getSnapshot(), now());
66
+ if (sameScheduleSummary(snapshot, next))
67
+ return;
68
+ snapshot = next;
69
+ notify();
70
+ };
71
+ const unsubscribeList = sessions.list.subscribe(reload);
72
+ const stateTick = setInterval(reload, stateTickMs);
73
+ let refreshTick;
74
+ let onVisible;
75
+ const doc = documentOf();
76
+ const target = globalThis;
77
+ const refreshNow = () => {
78
+ if (disposed || !canRefresh())
79
+ return;
80
+ try {
81
+ void sessions.refresh().catch(() => undefined);
82
+ }
83
+ catch {
84
+ /* 刷新失败不影响既有摘要 */
85
+ }
86
+ };
87
+ if (autoRefresh) {
88
+ onVisible = () => {
89
+ if (!disposed && canRefresh()) {
90
+ refreshNow();
91
+ reload();
92
+ }
93
+ };
94
+ refreshTick = setInterval(() => {
95
+ if (canRefresh())
96
+ refreshNow();
97
+ }, refreshIntervalMs);
98
+ doc?.addEventListener?.('visibilitychange', onVisible);
99
+ target.addEventListener?.('online', onVisible);
100
+ refreshNow();
101
+ }
102
+ reload();
103
+ const dispose = () => {
104
+ if (disposed)
105
+ return;
106
+ disposed = true;
107
+ clearInterval(stateTick);
108
+ if (refreshTick !== undefined)
109
+ clearInterval(refreshTick);
110
+ if (onVisible !== undefined) {
111
+ doc?.removeEventListener?.('visibilitychange', onVisible);
112
+ target.removeEventListener?.('online', onVisible);
113
+ }
114
+ unsubscribeList();
115
+ listeners.clear();
116
+ };
117
+ return {
118
+ getSnapshot: () => snapshot,
119
+ subscribe: (listener) => {
120
+ listeners.add(listener);
121
+ return () => {
122
+ listeners.delete(listener);
123
+ };
124
+ },
125
+ reload,
126
+ dispose,
127
+ };
128
+ }
129
+ /** 当前生效的 store(client 插件 `apply` 挂载时登记;跨插件只读入口)。 */
130
+ let activeStore;
131
+ /** store 登记变化的订阅者(消费方可能在 store 就绪前先渲染)。 */
132
+ const registryListeners = new Set();
133
+ /**
134
+ * 登记/撤销当前 store(由 dsh-later 自身 `apply` 调用;外部插件不要调用)。
135
+ * @param store - 生效的 store;undefined 表示撤销。
136
+ */
137
+ export function setActiveScheduleSummaryStore(store) {
138
+ if (activeStore === store)
139
+ return;
140
+ activeStore = store;
141
+ for (const listener of [...registryListeners]) {
142
+ try {
143
+ listener();
144
+ }
145
+ catch {
146
+ /* 忽略单个订阅者异常 */
147
+ }
148
+ }
149
+ }
150
+ /** 读取当前生效的 store;dsh-later 未加载时为 undefined。 */
151
+ export function getScheduleSummaryStore() {
152
+ return activeStore;
153
+ }
154
+ /** 订阅 store 登记变化(供 React hook 在 store 迟到时仍能重渲染)。 */
155
+ export function subscribeScheduleSummaryStore(listener) {
156
+ registryListeners.add(listener);
157
+ return () => {
158
+ registryListeners.delete(listener);
159
+ };
160
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 定时任务摘要:dsh-later 对外暴露的**公共数据面**(跨会话 / 跨插件)。
3
+ *
4
+ * 关键事实(本插件「路线 B」的数据源):`userSchedules` 是 client-visible 投影,
5
+ * 宿主在 `session.list` 基线里为**每个**会话携带 projections 块(live 会话走注册
6
+ * 表现值、冷会话走持久化投影缓存),客户端把它合入
7
+ * `SessionSummary.projectionValues`。因此**任何**持有 `sessions` 服务的客户端插件
8
+ * 都能读到全会话的定时任务,本模块只负责把它折叠成稳定形状。
9
+ *
10
+ * 契约形状(issue #17):`Map<SessionId, { count, nextAt, state }>`——即
11
+ * {@link ScheduleSummaryMap}。dsh-later 通过 `dsh-later/client` 导出
12
+ * {@link buildScheduleSummary}(纯函数)、`createScheduleSummaryStore`(可订阅
13
+ * store)与 `useScheduleSummary`(React hook),消费方按需选用。
14
+ *
15
+ * 本模块零 DOM / 零 React 依赖:宿主、浏览器与 `node --test` 都能直接跑。
16
+ * @module dsh-later/schedule-summary
17
+ */
18
+ import { badgeStateFor, summarizeSchedules, } from './presence.js';
19
+ /** `userSchedules` 投影值在 `projectionValues` 里的键名。 */
20
+ export const SCHEDULE_PROJECTION_KEY = 'userSchedules';
21
+ /**
22
+ * 从 `projectionValues` 里取出任务数组(容错:形状不对一律当作无任务)。
23
+ * @param projectionValues - 会话行的投影现值集合。
24
+ * @returns 任务数组;缺失或形状非法时 undefined。
25
+ */
26
+ export function schedulesOfProjection(projectionValues) {
27
+ const value = projectionValues?.[SCHEDULE_PROJECTION_KEY];
28
+ if (typeof value !== 'object' || value === null)
29
+ return undefined;
30
+ const schedules = value.schedules;
31
+ return Array.isArray(schedules) ? schedules : undefined;
32
+ }
33
+ /**
34
+ * 全会话归纳:把会话列表快照折叠成 {@link ScheduleSummaryMap}。
35
+ * @param source - 会话列表快照(`sessions.list.getSnapshot()`)。
36
+ * @param now - 判定 `state` 的当前时刻(epoch ms)。
37
+ * @returns 会话 id → 摘要;无任务的会话不出现在 map 里。
38
+ */
39
+ export function buildScheduleSummary(source, now) {
40
+ const summary = new Map();
41
+ for (const [sessionId, row] of Object.entries(source.byId)) {
42
+ if (row === undefined)
43
+ continue;
44
+ const entry = summarizeSchedules(schedulesOfProjection(row.projectionValues));
45
+ if (entry.count === 0)
46
+ continue;
47
+ summary.set(sessionId, { ...entry, state: badgeStateFor(entry.nextAt, now) });
48
+ }
49
+ return summary;
50
+ }
51
+ /**
52
+ * 两份摘要是否等价(逐条比对五个字段)。store 用它决定是否需要通知订阅者,
53
+ * 避免每次重算都产生新的 Map 引用导致 React 无谓重渲染。
54
+ *
55
+ * `progressFrom` 一并比对:它虽然通常随 `nextAt` 同进同退,但暂停→恢复会让
56
+ * 同一条任务重新拥有窗口起点,此时 `nextAt` 可能未变而窗口已换 —— 漏比会
57
+ * 让进度环停在上一个窗口的比例上。
58
+ * @param a - 旧摘要。
59
+ * @param b - 新摘要。
60
+ * @returns 键集合与每条内容都相同时为 true。
61
+ */
62
+ export function sameScheduleSummary(a, b) {
63
+ if (a === b)
64
+ return true;
65
+ if (a.size !== b.size)
66
+ return false;
67
+ for (const [sessionId, left] of a) {
68
+ const right = b.get(sessionId);
69
+ if (right === undefined)
70
+ return false;
71
+ if (left.count !== right.count ||
72
+ left.pausedCount !== right.pausedCount ||
73
+ left.nextAt !== right.nextAt ||
74
+ left.progressFrom !== right.progressFrom ||
75
+ left.state !== right.state) {
76
+ return false;
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+ /** 状态优先级(越大越紧急)。 */
82
+ const STATE_RANK = { scheduled: 0, urgent: 1, overdue: 2 };
83
+ /**
84
+ * 按**分组**聚合摘要:给定组内会话 id,折叠出可直接渲染的分组事实。
85
+ *
86
+ * 消费方(如 dsh-group-status 的 `GroupLiveStatus`)只需把组内会话 id 传进来,
87
+ * 无需理解 `nextAt`/暂停语义。
88
+ * @param summary - {@link ScheduleSummaryMap}。
89
+ * @param sessionIds - 组内会话 id(顺序无关;不在摘要里的会被忽略)。
90
+ * @returns 分组级摘要;组内无任务时 `sessions`/`count` 为 0、`state` 缺省。
91
+ */
92
+ export function summarizeScheduleGroup(summary, sessionIds) {
93
+ let sessions = 0;
94
+ let count = 0;
95
+ let pausedCount = 0;
96
+ let nextAt;
97
+ let state;
98
+ for (const sessionId of sessionIds) {
99
+ const entry = summary.get(sessionId);
100
+ if (entry === undefined)
101
+ continue;
102
+ sessions += 1;
103
+ count += entry.count;
104
+ pausedCount += entry.pausedCount;
105
+ if (entry.nextAt !== undefined && (nextAt === undefined || entry.nextAt < nextAt))
106
+ nextAt = entry.nextAt;
107
+ if (state === undefined || STATE_RANK[entry.state] > STATE_RANK[state])
108
+ state = entry.state;
109
+ }
110
+ return nextAt === undefined
111
+ ? { sessions, count, pausedCount, state }
112
+ : { sessions, count, pausedCount, nextAt, state };
113
+ }
@@ -23,6 +23,20 @@ export interface PresenceScheduleLike {
23
23
  readonly status?: string;
24
24
  /** 暂停冻结的剩余秒数;仅在 `status === 'paused'` 时有意义。 */
25
25
  readonly remaining_seconds?: number;
26
+ /** 任务类型。决定进度窗口起点按哪个口径推导(见 {@link windowStartOf})。 */
27
+ readonly kind?: string;
28
+ /** `after` 类型的总倒计时秒数(进度起点 = 触发时刻 − 该值)。 */
29
+ readonly after_seconds?: number;
30
+ readonly afterSeconds?: number;
31
+ /** `every` 类型的周期间隔秒数(进度起点 = 触发时刻 − 该值,即本周期起点)。 */
32
+ readonly every_seconds?: number;
33
+ readonly everySeconds?: number;
34
+ /** 宿主/投影直接给的显式总窗口秒数;给了就优先于按 kind 推导。 */
35
+ readonly window_seconds?: number;
36
+ readonly windowSeconds?: number;
37
+ /** `at` 类型的进度起点(任务创建时刻,ISO)。`at` 没有天然周期,只能用它。 */
38
+ readonly created_at?: string;
39
+ readonly createdAt?: number;
26
40
  }
27
41
  /** 由一组活动任务归纳的 badge 概要。 */
28
42
  export interface PresenceSummary {
@@ -32,6 +46,14 @@ export interface PresenceSummary {
32
46
  readonly nextAt?: number;
33
47
  /** 暂停中的任务数(用于文案区分「已冻结」与「等待发送」)。 */
34
48
  readonly pausedCount: number;
49
+ /**
50
+ * 进度环起点(epoch ms):`nextAt` 那条任务的窗口开始时刻。
51
+ *
52
+ * **只跟随 `nextAt` 那条**:环表达的是"距离最近一次触发还剩多少",若取别条
53
+ * 任务的窗口就与 `nextAt` 对不上了。无从推导时缺省,此时调用方应退回恒定
54
+ * 整环({@link progressOf} 返回 `undefined`)。
55
+ */
56
+ readonly progressFrom?: number;
35
57
  }
36
58
  /**
37
59
  * 归纳:取最早触发时刻 + 任务数量。
@@ -49,6 +71,16 @@ export interface PresenceSummary {
49
71
  * 「已到期」或"下次 X 分后"。
50
72
  */
51
73
  export declare function summarizeSchedules(schedules: readonly PresenceScheduleLike[] | undefined): PresenceSummary;
74
+ /**
75
+ * 进度环比例(0–1):`now` 在 `[progressFrom, nextAt]` 中的位置。
76
+ *
77
+ * 返回 `undefined` 表示**无从算起**(缺窗口起点,或窗口已退化),调用方此时应
78
+ * 退回「恒定整环」的旧表现,而不是画一个 0% 的空环 —— 后者是错误信息:
79
+ * 0% 是"刚开始",`undefined` 是"算不出来"。
80
+ *
81
+ * 两端夹住:`overdue` 时 `now > nextAt`,环停在满环而不是绕出去。
82
+ */
83
+ export declare function progressOf(entry: PresenceSummary, now: number): number | undefined;
52
84
  /** 由当前时刻判定 badge 状态。 */
53
85
  export declare function badgeStateFor(nextAt: number | undefined, now: number): PresenceBadgeState;
54
86
  /**
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 定时摘要 store:把 {@link buildScheduleSummary} 的纯计算接到宿主 `sessions`
3
+ * 服务上,得到一个**可订阅的公共数据源**。
4
+ *
5
+ * 为什么需要它(issue #17):dsh-later 自己的侧栏 badge 与外部插件(如
6
+ * dsh-group-status)都需要「全会话定时任务摘要」。两条独立通路会各自订阅
7
+ * `sessions.list`、各自周期 `refresh()`,既浪费 RPC 又容易漂移。本 store 是
8
+ * **唯一**的数据权威:内部负责列表订阅、状态 tick(scheduled→urgent→overdue)
9
+ * 与 `session.list` 读 RPC 补时效;外部只读 {@link ScheduleSummaryStore.getSnapshot}
10
+ * 或订阅变更。
11
+ *
12
+ * 跨插件使用方式(消费方声明 `dsh.client.external: ["dsh-later"]` 后):
13
+ * ```js
14
+ * const later = require('dsh-later/client')
15
+ * const store = later.getScheduleSummaryStore()
16
+ * ```
17
+ *
18
+ * 本模块零 DOM / 零 React 依赖(`document` / `window` 一律经 `globalThis`
19
+ * 结构化探测),宿主 tsc 与浏览器 esbuild 都能编译,`node --test` 也能直接跑。
20
+ * @module dsh-later/schedule-summary-store
21
+ */
22
+ import { type ScheduleSummaryMap, type ScheduleSummarySource } from './schedule-summary.js';
23
+ /** 摘要状态重算周期(本地时钟,零网络)。 */
24
+ export declare const SUMMARY_STATE_TICK_MS = 30000;
25
+ /** `session.list` 重拉周期(读 RPC;仅页面可见时执行)。 */
26
+ export declare const SUMMARY_REFRESH_INTERVAL_MS = 60000;
27
+ /** 宿主 `sessions` 服务的结构子集(只用到列表快照与 refresh)。 */
28
+ export interface ScheduleSummarySessionsLike {
29
+ /** 会话列表 store(快照 + 变更订阅)。 */
30
+ readonly list: {
31
+ getSnapshot(): ScheduleSummarySource;
32
+ subscribe(listener: () => void): () => void;
33
+ };
34
+ /** 重拉会话列表(读 RPC,不写会话日志)。 */
35
+ refresh(): Promise<unknown>;
36
+ }
37
+ /** 可订阅的定时摘要 store。 */
38
+ export interface ScheduleSummaryStore {
39
+ /** 当前摘要(引用稳定;内容变化时才换成新对象)。 */
40
+ getSnapshot(): ScheduleSummaryMap;
41
+ /** 订阅变更;返回退订函数。 */
42
+ subscribe(listener: () => void): () => void;
43
+ /** 立即按最新会话列表快照重算(内容有变化才通知)。 */
44
+ reload(): void;
45
+ /** 释放订阅/定时器(宿主卸载时调用)。 */
46
+ dispose(): void;
47
+ }
48
+ export interface ScheduleSummaryStoreOptions {
49
+ /** 状态重算周期(毫秒);缺省 {@link SUMMARY_STATE_TICK_MS}。 */
50
+ readonly stateTickMs?: number;
51
+ /** 会话列表重拉周期(毫秒);缺省 {@link SUMMARY_REFRESH_INTERVAL_MS}。 */
52
+ readonly refreshIntervalMs?: number;
53
+ /** 当前时刻注入(单测用);缺省 `Date.now`。 */
54
+ readonly now?: () => number;
55
+ /** 是否允许发起 refresh RPC;缺省「页面可见时允许」。 */
56
+ readonly canRefresh?: () => boolean;
57
+ /** 是否自动周期 refresh;缺省 true(单测可关)。 */
58
+ readonly autoRefresh?: boolean;
59
+ }
60
+ /**
61
+ * 创建定时摘要 store(宿主 `apply` 内创建一次,全插件共享)。
62
+ * @param sessions - 宿主 `sessions` 服务。
63
+ * @param options - 周期/时钟/刷新策略覆盖(单测用)。
64
+ * @returns 可订阅 store。
65
+ */
66
+ export declare function createScheduleSummaryStore(sessions: ScheduleSummarySessionsLike, options?: ScheduleSummaryStoreOptions): ScheduleSummaryStore;
67
+ /**
68
+ * 登记/撤销当前 store(由 dsh-later 自身 `apply` 调用;外部插件不要调用)。
69
+ * @param store - 生效的 store;undefined 表示撤销。
70
+ */
71
+ export declare function setActiveScheduleSummaryStore(store: ScheduleSummaryStore | undefined): void;
72
+ /** 读取当前生效的 store;dsh-later 未加载时为 undefined。 */
73
+ export declare function getScheduleSummaryStore(): ScheduleSummaryStore | undefined;
74
+ /** 订阅 store 登记变化(供 React hook 在 store 迟到时仍能重渲染)。 */
75
+ export declare function subscribeScheduleSummaryStore(listener: () => void): () => void;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 定时任务摘要:dsh-later 对外暴露的**公共数据面**(跨会话 / 跨插件)。
3
+ *
4
+ * 关键事实(本插件「路线 B」的数据源):`userSchedules` 是 client-visible 投影,
5
+ * 宿主在 `session.list` 基线里为**每个**会话携带 projections 块(live 会话走注册
6
+ * 表现值、冷会话走持久化投影缓存),客户端把它合入
7
+ * `SessionSummary.projectionValues`。因此**任何**持有 `sessions` 服务的客户端插件
8
+ * 都能读到全会话的定时任务,本模块只负责把它折叠成稳定形状。
9
+ *
10
+ * 契约形状(issue #17):`Map<SessionId, { count, nextAt, state }>`——即
11
+ * {@link ScheduleSummaryMap}。dsh-later 通过 `dsh-later/client` 导出
12
+ * {@link buildScheduleSummary}(纯函数)、`createScheduleSummaryStore`(可订阅
13
+ * store)与 `useScheduleSummary`(React hook),消费方按需选用。
14
+ *
15
+ * 本模块零 DOM / 零 React 依赖:宿主、浏览器与 `node --test` 都能直接跑。
16
+ * @module dsh-later/schedule-summary
17
+ */
18
+ import { type PresenceBadgeState, type PresenceScheduleLike, type PresenceSummary } from './presence.js';
19
+ /** `userSchedules` 投影值在 `projectionValues` 里的键名。 */
20
+ export declare const SCHEDULE_PROJECTION_KEY = "userSchedules";
21
+ /**
22
+ * 一条会话的定时摘要:数量 + 下次触发 + 视觉状态。
23
+ *
24
+ * `state` 是**快照时刻**由 {@link badgeStateFor} 判定的结果(`scheduled` /
25
+ * `urgent` ≤5min / `overdue` 已到期未派发),因此 store 每次重算/定时 tick 都会
26
+ * 刷新它;暂停项计入 `count`/`pausedCount`,但**不参与** `nextAt`(见 presence 模块
27
+ * 对暂停语义的说明)。
28
+ */
29
+ export interface ScheduleSummaryEntry extends PresenceSummary {
30
+ /** 该会话的 badge 状态(按快照时刻判定)。 */
31
+ readonly state: PresenceBadgeState;
32
+ }
33
+ /** 会话 id → 该会话的定时摘要(**仅**包含有任务的会话)。 */
34
+ export type ScheduleSummaryMap = ReadonlyMap<string, ScheduleSummaryEntry>;
35
+ /** 摘要所需的最小会话列表快照面(`SessionListState` 的结构子集)。 */
36
+ export interface ScheduleSummarySource {
37
+ /** 会话 id → 会话行事实(只用到 `projectionValues`)。 */
38
+ readonly byId: Readonly<Record<string, ScheduleSummaryRowLike | undefined>>;
39
+ }
40
+ /** 会话行事实的结构子集。 */
41
+ export interface ScheduleSummaryRowLike {
42
+ /** 该会话的投影现值(含 `userSchedules`)。 */
43
+ readonly projectionValues?: Readonly<Record<string, unknown>>;
44
+ }
45
+ /**
46
+ * 从 `projectionValues` 里取出任务数组(容错:形状不对一律当作无任务)。
47
+ * @param projectionValues - 会话行的投影现值集合。
48
+ * @returns 任务数组;缺失或形状非法时 undefined。
49
+ */
50
+ export declare function schedulesOfProjection(projectionValues: Readonly<Record<string, unknown>> | undefined): readonly PresenceScheduleLike[] | undefined;
51
+ /**
52
+ * 全会话归纳:把会话列表快照折叠成 {@link ScheduleSummaryMap}。
53
+ * @param source - 会话列表快照(`sessions.list.getSnapshot()`)。
54
+ * @param now - 判定 `state` 的当前时刻(epoch ms)。
55
+ * @returns 会话 id → 摘要;无任务的会话不出现在 map 里。
56
+ */
57
+ export declare function buildScheduleSummary(source: ScheduleSummarySource, now: number): ScheduleSummaryMap;
58
+ /**
59
+ * 两份摘要是否等价(逐条比对五个字段)。store 用它决定是否需要通知订阅者,
60
+ * 避免每次重算都产生新的 Map 引用导致 React 无谓重渲染。
61
+ *
62
+ * `progressFrom` 一并比对:它虽然通常随 `nextAt` 同进同退,但暂停→恢复会让
63
+ * 同一条任务重新拥有窗口起点,此时 `nextAt` 可能未变而窗口已换 —— 漏比会
64
+ * 让进度环停在上一个窗口的比例上。
65
+ * @param a - 旧摘要。
66
+ * @param b - 新摘要。
67
+ * @returns 键集合与每条内容都相同时为 true。
68
+ */
69
+ export declare function sameScheduleSummary(a: ScheduleSummaryMap, b: ScheduleSummaryMap): boolean;
70
+ /** 分组聚合结果(折叠的工作区分组徽标直接用这一份)。 */
71
+ export interface GroupScheduleSummary {
72
+ /** 组内**有任务**的会话数。 */
73
+ readonly sessions: number;
74
+ /** 组内任务总数(含暂停项)。 */
75
+ readonly count: number;
76
+ /** 组内暂停项数量。 */
77
+ readonly pausedCount: number;
78
+ /** 组内最早触发时刻(epoch ms);全部暂停或缺省时为 undefined。 */
79
+ readonly nextAt?: number;
80
+ /** 组内最高优先级状态(overdue > urgent > scheduled);无任务时为 undefined。 */
81
+ readonly state?: PresenceBadgeState;
82
+ }
83
+ /**
84
+ * 按**分组**聚合摘要:给定组内会话 id,折叠出可直接渲染的分组事实。
85
+ *
86
+ * 消费方(如 dsh-group-status 的 `GroupLiveStatus`)只需把组内会话 id 传进来,
87
+ * 无需理解 `nextAt`/暂停语义。
88
+ * @param summary - {@link ScheduleSummaryMap}。
89
+ * @param sessionIds - 组内会话 id(顺序无关;不在摘要里的会被忽略)。
90
+ * @returns 分组级摘要;组内无任务时 `sessions`/`count` 为 0、`state` 缺省。
91
+ */
92
+ export declare function summarizeScheduleGroup(summary: ScheduleSummaryMap, sessionIds: Iterable<string>): GroupScheduleSummary;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-later",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "给 DSH 加上定时发送:输入一句话,到点自动以你的身份送出,关掉网页也准时触发。定时发送 / 定时提醒 / 延迟发送。",
5
5
  "icon": "./icon.svg",
6
6
  "keywords": [