dsh-task-ledger 0.0.8 → 0.0.10

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.
@@ -1,18 +1,37 @@
1
1
  import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from './types.js';
2
2
  /** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
3
3
  export declare const STALE_DAYS = 14;
4
- /** 趋势图桶数:近 8 周(2026-10-17 定稿,与 dsh-context「近 7 天」同思路,按周更耐看)。 */
4
+ /** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
5
5
  export declare const WEEKLY_WEEKS = 8;
6
- /** 完成类统计(**按结案时间**落桶;旧实现走 `--since` 按创建时间过滤,数的是「本周创建且已结案」,口径错)。 */
7
- export interface ClosedSummary {
8
- /** 本周(周一至今)完成数。 */
9
- readonly weekDone: number;
10
- /** 近 8 周分周完成数,旧 → 新;`week` = 该周周一(YYYY-MM-DD)。 */
11
- readonly weekly: readonly WeeklyBucket[];
6
+ /** 一个自然周期内的两个计数(核心永远是「完成」,新增只作对照)。 */
7
+ export interface PeriodCounts {
8
+ readonly done: number;
9
+ readonly created: number;
10
+ /** 周期起(YYYY-MM-DD,含)。 */
11
+ readonly from: string;
12
+ /** 周期止(YYYY-MM-DD,含)。 */
13
+ readonly to: string;
14
+ }
15
+ /** 统计台四个自然周期(用户 2026-10-17 定稿:本周 / 上周 / 本月 / 上月,不用 7/30 天)。 */
16
+ export interface TrendPeriods {
17
+ readonly thisWeek: PeriodCounts;
18
+ readonly lastWeek: PeriodCounts;
19
+ readonly thisMonth: PeriodCounts;
20
+ readonly lastMonth: PeriodCounts;
12
21
  }
22
+ /** 趋势图一桶(一周):完成 + 新增。 */
13
23
  export interface WeeklyBucket {
24
+ /** 该周周一(YYYY-MM-DD)。 */
14
25
  readonly week: string;
15
- readonly count: number;
26
+ readonly done: number;
27
+ readonly created: number;
28
+ }
29
+ /** 趋势图数据按范围各一份(切换零请求)。 */
30
+ export interface ClosedSummary {
31
+ readonly weekly: {
32
+ readonly all: readonly WeeklyBucket[];
33
+ readonly mine: readonly WeeklyBucket[];
34
+ };
16
35
  }
17
36
  /** 首页统计卡的六个面(阈值 / 天数全在 host 算,前端只渲染——单一真源)。 */
18
37
  export interface BoardStats {
@@ -32,8 +51,6 @@ export interface BoardStats {
32
51
  readonly staleDays: number;
33
52
  /** 最老一条待认领躺了多少天(没有待认领 = null)。 */
34
53
  readonly oldestOpenDays: number | null;
35
- /** 卡住最久的 blocked 多少天(没有 blocked = null)。 */
36
- readonly longestBlockedDays: number | null;
37
54
  }
38
55
  /** 列表行附加工资字段(host 算,前端不判阈值)。 */
39
56
  export interface BoardTask extends TaskRow {
@@ -55,6 +72,12 @@ export interface BoardSnapshot {
55
72
  */
56
73
  readonly identities: Readonly<Record<string, string | null>>;
57
74
  }
75
+ /** 首页一次 `/board` 的全部聚合(**每个工作区只读一次** list + history + git 身份)。 */
76
+ export interface HomeAggregate {
77
+ readonly snapshot: BoardSnapshot;
78
+ readonly closed: ClosedSummary;
79
+ readonly trend: TrendPeriods;
80
+ }
58
81
  /**
59
82
  * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
60
83
  * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
@@ -62,15 +85,13 @@ export interface BoardSnapshot {
62
85
  */
63
86
  export declare function getCurrentIdentity(workspace: LedgerWorkspace): Promise<string | null>;
64
87
  /**
65
- * 首页快照(在办路径):并发跑全部工作区的 list + 身份解析。
66
- * 与历史量彻底无关——progress.json 只装没结案的任务(close 会移走),永远有界。
88
+ * 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 近 8 周趋势(完成 / 新增 × 所有 / 我的)
89
+ * + 四个自然周期计数。**每个工作区只读一次**(list + history + git 身份),
90
+ * 避免「快照读一遍、统计再读一遍」把 5 万行级工作区的 history 解析翻倍。
67
91
  */
92
+ export declare function getHomeAggregate(workspaces: readonly LedgerWorkspace[]): Promise<HomeAggregate>;
93
+ /** 只拿在办快照(薄封装:测试 / 其它调用方用;首页走 {@link getHomeAggregate} 一次读全)。 */
68
94
  export declare function getBoardSnapshot(workspaces: readonly LedgerWorkspace[]): Promise<BoardSnapshot>;
69
- /**
70
- * 完成类统计 + 近 8 周趋势:读一次 13 个月窗口的 history,按**结案时间**落周桶。
71
- * 任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
72
- */
73
- export declare function getClosedSummary(workspaces: readonly LedgerWorkspace[]): Promise<ClosedSummary>;
74
95
  /**
75
96
  * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
76
97
  * shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
@@ -6,21 +6,57 @@ const RECENT_MONTHS = 13;
6
6
  const DEFAULT_HISTORY_LIMIT = 500;
7
7
  /** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
8
8
  export const STALE_DAYS = 14;
9
- /** 趋势图桶数:近 8 周(2026-10-17 定稿,与 dsh-context「近 7 天」同思路,按周更耐看)。 */
9
+ /** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
10
10
  export const WEEKLY_WEEKS = 8;
11
- /** 周一所在日期(YYYY-MM-DD)。skill 的 --since 只给日期按当天 00:00 解析。 */
12
- function mondayDateString(now) {
13
- const date = new Date(now);
14
- const mondayOffset = (date.getDay() + 6) % 7;
15
- date.setDate(date.getDate() - mondayOffset);
16
- const pad = (value) => String(value).padStart(2, '0');
11
+ /* ── 日期工具(本机时区;skill 脚本同口径:周一为一周之始)───────────────── */
12
+ function pad(value) {
13
+ return String(value).padStart(2, '0');
14
+ }
15
+ /** Date → `YYYY-MM-DD`。 */
16
+ function ymd(date) {
17
17
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
18
18
  }
19
+ /** 时刻串(`YYYY-MM-DD HH:mm:ss` / `YYYY-MM-DD`)→ 日期部分;解析不了回 null。 */
20
+ function dayOf(stamp) {
21
+ if (stamp === null || stamp === undefined || stamp === '')
22
+ return null;
23
+ const day = String(stamp).slice(0, 10);
24
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
25
+ }
26
+ /** 某日期所在周的周一(00:00)。 */
27
+ function mondayOf(day) {
28
+ const date = new Date(`${day}T00:00:00`);
29
+ date.setDate(date.getDate() - ((date.getDay() + 6) % 7));
30
+ return date;
31
+ }
32
+ /** 第 offset 周的起止(offset 0 = 本周,-1 = 上周;返回日期串,含边界)。
33
+ * ⚠️ 本周只到今天(周还没结束),上周才是完整周一~周日——否则「本周」会把明天之后的
34
+ * 日子也画进区间,tooltip 里的截止日撒谎。 */
35
+ function weekWindow(offset, now) {
36
+ const monday = mondayOf(ymd(now));
37
+ monday.setDate(monday.getDate() + offset * 7);
38
+ const sunday = new Date(monday);
39
+ sunday.setDate(sunday.getDate() + 6);
40
+ const last = offset === 0 ? now : sunday;
41
+ return { from: ymd(monday), to: ymd(last < sunday ? last : sunday) };
42
+ }
43
+ /** 第 offset 月的起止(offset 0 = 本月,-1 = 上月;含边界)。
44
+ * 同 {@link weekWindow}:本月只到今天,上月才是完整自然月。 */
45
+ function monthWindow(offset, now) {
46
+ const first = new Date(now.getFullYear(), now.getMonth() + offset, 1);
47
+ const last = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0);
48
+ const end = offset === 0 ? now : last;
49
+ return { from: ymd(first), to: ymd(end < last ? end : last) };
50
+ }
51
+ /** 日期是否落在 [from, to](含边界,字符串比较即可——ISO 字典序即时间序)。 */
52
+ function inWindow(day, window) {
53
+ return day !== null && day >= window.from && day <= window.to;
54
+ }
19
55
  /** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
20
56
  function daysSince(stamp, now) {
21
57
  if (stamp === null || stamp === undefined || stamp === '')
22
58
  return null;
23
- const parsed = new Date(stamp.replace(' ', 'T'));
59
+ const parsed = new Date(String(stamp).replace(' ', 'T'));
24
60
  if (Number.isNaN(parsed.getTime()))
25
61
  return null;
26
62
  const days = Math.floor((now.getTime() - parsed.getTime()) / 86_400_000);
@@ -43,36 +79,41 @@ export function getCurrentIdentity(workspace) {
43
79
  });
44
80
  });
45
81
  }
82
+ /** 一个工作区读全(list + history + git 身份)——首页所有数字的共同原料。 */
83
+ async function readWorkspace(workspace) {
84
+ const [tasks, history, identity] = await Promise.all([
85
+ readTasks(workspace),
86
+ readHistory(workspace, { months: RECENT_MONTHS }),
87
+ getCurrentIdentity(workspace),
88
+ ]);
89
+ return { workspace, tasks, history, identity };
90
+ }
91
+ /** 计数一批记录 / 任务(按范围过滤 owner;`mine` 时 identity 为 null 的一律不计)。 */
92
+ function countPeriod(entries, window) {
93
+ return entries.filter((entry) => inWindow(entry.day, window)).length;
94
+ }
46
95
  /**
47
- * 首页快照(在办路径):并发跑全部工作区的 list + 身份解析。
48
- * 与历史量彻底无关——progress.json 只装没结案的任务(close 会移走),永远有界。
96
+ * 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 近 8 周趋势(完成 / 新增 × 所有 / 我的)
97
+ * + 四个自然周期计数。**每个工作区只读一次**(list + history + git 身份),
98
+ * 避免「快照读一遍、统计再读一遍」把 5 万行级工作区的 history 解析翻倍。
49
99
  */
50
- export async function getBoardSnapshot(workspaces) {
100
+ export async function getHomeAggregate(workspaces) {
51
101
  const now = new Date();
52
- const perWorkspace = await Promise.all(workspaces.map(async (workspace) => ({
53
- workspace,
54
- tasks: await readTasks(workspace),
55
- identity: await getCurrentIdentity(workspace),
56
- })));
102
+ const perWorkspace = await Promise.all(workspaces.map(readWorkspace));
103
+ // ── 快照(在办路径,与历史量无关)────────────────────────────────────
57
104
  const withMeta = perWorkspace.flatMap((entry) => entry.tasks.map((task) => {
58
105
  const working = task.status === 'doing' || task.status === 'blocked';
59
106
  const ageDays = working ? daysSince(task.started ?? task.created, now) : null;
60
107
  return { ...task, ageDays, stale: ageDays !== null && ageDays > STALE_DAYS };
61
108
  }));
62
- const myActive = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && (task.status === 'doing' || task.status === 'blocked')).length, 0);
63
- const myBlocked = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && task.status === 'blocked').length, 0);
64
109
  const openTasks = withMeta.filter((task) => task.status === 'open');
65
110
  const blockedTasks = withMeta.filter((task) => task.status === 'blocked');
66
- const oldestOpenDays = openTasks.length === 0
67
- ? null
68
- : openTasks.reduce((max, task) => Math.max(max, daysSince(task.created, now) ?? 0), 0);
69
- const longestBlockedDays = blockedTasks.length === 0
70
- ? null
71
- : blockedTasks.reduce((max, task) => Math.max(max, task.ageDays ?? 0), 0);
111
+ const myActive = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && (task.status === 'doing' || task.status === 'blocked')).length, 0);
112
+ const myBlocked = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && task.status === 'blocked').length, 0);
72
113
  const identities = {};
73
114
  for (const entry of perWorkspace)
74
115
  identities[entry.workspace.id] = entry.identity;
75
- return {
116
+ const snapshot = {
76
117
  at: now.toISOString(),
77
118
  workspaces,
78
119
  tasks: withMeta,
@@ -84,45 +125,87 @@ export async function getBoardSnapshot(workspaces) {
84
125
  blocked: blockedTasks.length,
85
126
  stale: withMeta.filter((task) => task.stale).length,
86
127
  staleDays: STALE_DAYS,
87
- oldestOpenDays,
88
- longestBlockedDays,
128
+ oldestOpenDays: openTasks.length === 0
129
+ ? null
130
+ : openTasks.reduce((max, task) => Math.max(max, daysSince(task.created, now) ?? 0), 0),
89
131
  },
90
132
  identities,
91
133
  };
92
- }
93
- /** 取一条记录的结案日期部分(history 首列 `time`;旧行 / 缺列返回 null)。 */
94
- function closedDateOf(record) {
95
- const value = record.time;
96
- if (value === undefined || value === null || value === '')
97
- return null;
98
- return String(value).slice(0, 10);
99
- }
100
- /**
101
- * 完成类统计 + 近 8 周趋势:读一次 13 个月窗口的 history,按**结案时间**落周桶。
102
- * 任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
103
- */
104
- export async function getClosedSummary(workspaces) {
105
- const now = new Date();
106
- const thisMonday = mondayDateString(now);
134
+ // ── 趋势:近 8 周(完成按结案时间、新增按创建时间)× 所有 / 我的 ────────
135
+ // 新增必须**合并两个来源**:已结案的任务被 close 移出 progress.json,只在 history 里;
136
+ // 在办的只在 progress.json 里。两者不相交(close 是移动),合并即全集、不会重复计。
107
137
  const weeks = [];
108
- for (let offset = WEEKLY_WEEKS - 1; offset >= 0; offset -= 1) {
109
- weeks.push(mondayDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset * 7)));
110
- }
111
- const perWorkspace = await Promise.all(workspaces.map((workspace) => readHistory(workspace, { months: RECENT_MONTHS })));
112
- const counts = new Map(weeks.map((week) => [week, 0]));
113
- for (const record of perWorkspace.flat()) {
114
- const date = closedDateOf(record);
115
- if (date === null)
116
- continue;
117
- const monday = mondayDateString(new Date(`${date}T00:00:00`));
118
- const bucket = counts.get(monday);
138
+ for (let offset = WEEKLY_WEEKS - 1; offset >= 0; offset -= 1)
139
+ weeks.push(weekWindow(-offset, now).from);
140
+ const bucketsAll = new Map(weeks.map((week) => [week, { done: 0, created: 0 }]));
141
+ const bucketsMine = new Map(weeks.map((week) => [week, { done: 0, created: 0 }]));
142
+ const bump = (map, week, field) => {
143
+ const bucket = map.get(week);
119
144
  if (bucket !== undefined)
120
- counts.set(monday, bucket + 1);
145
+ map.set(week, { ...bucket, [field]: bucket[field] + 1 });
146
+ };
147
+ for (const entry of perWorkspace) {
148
+ for (const task of entry.tasks) {
149
+ const created = dayOf(task.created);
150
+ if (created === null)
151
+ continue;
152
+ const week = ymd(mondayOf(created));
153
+ bump(bucketsAll, week, 'created');
154
+ if (entry.identity !== null && task.owner === entry.identity)
155
+ bump(bucketsMine, week, 'created');
156
+ }
157
+ for (const record of entry.history) {
158
+ const closed = dayOf(record.time);
159
+ if (closed !== null) {
160
+ const week = ymd(mondayOf(closed));
161
+ bump(bucketsAll, week, 'done');
162
+ if (entry.identity !== null && record.owner === entry.identity)
163
+ bump(bucketsMine, week, 'done');
164
+ }
165
+ const created = dayOf(record.created);
166
+ if (created !== null) {
167
+ const week = ymd(mondayOf(created));
168
+ bump(bucketsAll, week, 'created');
169
+ if (entry.identity !== null && record.owner === entry.identity)
170
+ bump(bucketsMine, week, 'created');
171
+ }
172
+ }
121
173
  }
122
- return {
123
- weekDone: counts.get(thisMonday) ?? 0,
124
- weekly: weeks.map((week) => ({ week, count: counts.get(week) ?? 0 })),
174
+ const closed = {
175
+ weekly: {
176
+ all: weeks.map((week) => ({ week, ...bucketsAll.get(week) ?? { done: 0, created: 0 } })),
177
+ mine: weeks.map((week) => ({ week, ...bucketsMine.get(week) ?? { done: 0, created: 0 } })),
178
+ },
179
+ };
180
+ // ── 统计台:四个自然周期(只算「全部工作区」范围;图表切换不带动卡片)────
181
+ const windows = {
182
+ thisWeek: weekWindow(0, now),
183
+ lastWeek: weekWindow(-1, now),
184
+ thisMonth: monthWindow(0, now),
185
+ lastMonth: monthWindow(-1, now),
125
186
  };
187
+ const doneEntries = perWorkspace.flatMap((entry) => entry.history.map((record) => ({ day: dayOf(record.time), owner: record.owner ?? null })));
188
+ const createdEntries = perWorkspace.flatMap((entry) => [
189
+ ...entry.tasks.map((task) => ({ day: dayOf(task.created), owner: task.owner ?? null })),
190
+ ...entry.history.map((record) => ({ day: dayOf(record.created), owner: record.owner ?? null })),
191
+ ]);
192
+ const period = (window) => ({
193
+ done: countPeriod(doneEntries, window),
194
+ created: countPeriod(createdEntries, window),
195
+ from: window.from,
196
+ to: window.to,
197
+ });
198
+ const trend = {
199
+ thisWeek: period(windows.thisWeek),
200
+ lastWeek: period(windows.lastWeek),
201
+ thisMonth: period(windows.thisMonth),
202
+ lastMonth: period(windows.lastMonth),
203
+ };
204
+ return { snapshot, closed, trend };
205
+ }
206
+ /** 只拿在办快照(薄封装:测试 / 其它调用方用;首页走 {@link getHomeAggregate} 一次读全)。 */
207
+ export async function getBoardSnapshot(workspaces) {
208
+ return (await getHomeAggregate(workspaces)).snapshot;
126
209
  }
127
210
  /**
128
211
  * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
@@ -1,4 +1,4 @@
1
- import { getBoardSnapshot, getClosedSummary, getFilterOptions, queryClosed } from './board.js';
1
+ import { getHomeAggregate, getFilterOptions, queryClosed } from './board.js';
2
2
  import { getTask, readTasks, readWorklog } from './ledger-read.js';
3
3
  import { resolveWorkspaces } from './workspace.js';
4
4
  /** API 前缀(唯一真源就在这一处)。 */
@@ -57,15 +57,14 @@ export function registerRoutes(webServer, deps) {
57
57
  path: `${API_PREFIX}/workspaces`,
58
58
  handler: (_req, res) => { sendJson(res, 200, { workspaces: workspacesOf(deps) }); },
59
59
  });
60
- // 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 完成类统计与近 8 周趋势。
60
+ // 首页一次拿全:在办快照 + 近 8 周趋势 + 四个自然周期计数(host 每个工作区只读一次)。
61
61
  webServer.register({
62
62
  kind: 'exact',
63
63
  path: `${API_PREFIX}/board`,
64
64
  handler: async (_req, res) => {
65
65
  try {
66
- const workspaces = workspacesOf(deps);
67
- const [snapshot, closed] = await Promise.all([getBoardSnapshot(workspaces), getClosedSummary(workspaces)]);
68
- sendJson(res, 200, { ...snapshot, closed });
66
+ const home = await getHomeAggregate(workspacesOf(deps));
67
+ sendJson(res, 200, { ...home.snapshot, closed: home.closed, trend: home.trend });
69
68
  }
70
69
  catch (error) {
71
70
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-task-ledger",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Task ledger board for dsh (DeepSeek Harness): read-only board over task-ledger skill workspaces. The task-ledger skill is bundled — install the plugin, get the skill globally.",
5
5
  "keywords": [
6
6
  "deepseek",