dsh-task-ledger 0.0.7 → 0.0.9

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,23 +1,70 @@
1
1
  import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from './types.js';
2
- /** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
3
- export declare const DEFAULT_HISTORY_LIMIT = 500;
4
- /** 首页统计卡的两个完成类计数。 */
5
- export interface ClosedCounts {
6
- /** 本周已完成(最近 2 个月文件内、创建时间落在本周)。 */
7
- readonly weekDone: number;
8
- /** 近一年完成(最近 13 个月文件行数;capped = 已触顶,值即下限)。 */
9
- readonly yearDone: number;
10
- readonly yearCapped: boolean;
2
+ /** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
3
+ export declare const STALE_DAYS = 14;
4
+ /** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
5
+ export declare const WEEKLY_WEEKS = 8;
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;
21
+ }
22
+ /** 趋势图一桶(一周):完成 + 新增。 */
23
+ export interface WeeklyBucket {
24
+ /** 该周周一(YYYY-MM-DD)。 */
25
+ readonly week: string;
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
+ };
35
+ }
36
+ /** 首页统计卡的六个面(阈值 / 天数全在 host 算,前端只渲染——单一真源)。 */
37
+ export interface BoardStats {
38
+ /** 我的 doing + blocked。 */
39
+ readonly myActive: number;
40
+ /** 我的 blocked。 */
41
+ readonly myBlocked: number;
42
+ /** 待认领(open)。 */
43
+ readonly open: number;
44
+ /** 全部 doing。 */
45
+ readonly doing: number;
46
+ /** 全部 blocked。 */
47
+ readonly blocked: number;
48
+ /** 停滞 = doing|blocked 且 ageDays > STALE_DAYS。 */
49
+ readonly stale: number;
50
+ /** 停滞阈值(天)——随响应下发,界面文案不硬编码(单一真源)。 */
51
+ readonly staleDays: number;
52
+ /** 最老一条待认领躺了多少天(没有待认领 = null)。 */
53
+ readonly oldestOpenDays: number | null;
54
+ }
55
+ /** 列表行附加工资字段(host 算,前端不判阈值)。 */
56
+ export interface BoardTask extends TaskRow {
57
+ /** 认领后未动天数(doing / blocked;open 与解析不了的时间为 null)。 */
58
+ readonly ageDays: number | null;
59
+ /** 停滞标记。 */
60
+ readonly stale: boolean;
11
61
  }
12
62
  export interface BoardSnapshot {
13
63
  readonly at: string;
14
64
  readonly workspaces: readonly LedgerWorkspace[];
15
65
  /** 全部在办任务(UI 自己筛「我的 / 待认领」——筛选是前端的事)。 */
16
- readonly tasks: readonly TaskRow[];
17
- readonly stats: {
18
- readonly myActive: number;
19
- readonly open: number;
20
- };
66
+ readonly tasks: readonly BoardTask[];
67
+ readonly stats: BoardStats;
21
68
  /**
22
69
  * 工作区 id → 该工作区的当前 git 身份(取不到为 null)。
23
70
  * 「我的待办」在前端按 `owner === identities[workspace.id]` 筛——身份只在 host 侧取得到
@@ -25,6 +72,12 @@ export interface BoardSnapshot {
25
72
  */
26
73
  readonly identities: Readonly<Record<string, string | null>>;
27
74
  }
75
+ /** 首页一次 `/board` 的全部聚合(**每个工作区只读一次** list + history + git 身份)。 */
76
+ export interface HomeAggregate {
77
+ readonly snapshot: BoardSnapshot;
78
+ readonly closed: ClosedSummary;
79
+ readonly trend: TrendPeriods;
80
+ }
28
81
  /**
29
82
  * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
30
83
  * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
@@ -32,15 +85,13 @@ export interface BoardSnapshot {
32
85
  */
33
86
  export declare function getCurrentIdentity(workspace: LedgerWorkspace): Promise<string | null>;
34
87
  /**
35
- * 首页快照(在办路径):并发跑全部工作区的 list + 身份解析。
36
- * 与历史量彻底无关——progress.json 只装没结案的任务(close 会移走),永远有界。
88
+ * 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 近 8 周趋势(完成 / 新增 × 所有 / 我的)
89
+ * + 四个自然周期计数。**每个工作区只读一次**(list + history + git 身份),
90
+ * 避免「快照读一遍、统计再读一遍」把 5 万行级工作区的 history 解析翻倍。
37
91
  */
92
+ export declare function getHomeAggregate(workspaces: readonly LedgerWorkspace[]): Promise<HomeAggregate>;
93
+ /** 只拿在办快照(薄封装:测试 / 其它调用方用;首页走 {@link getHomeAggregate} 一次读全)。 */
38
94
  export declare function getBoardSnapshot(workspaces: readonly LedgerWorkspace[]): Promise<BoardSnapshot>;
39
- /**
40
- * 完成类统计(数行封顶,与总量脱钩):本周 = 最近 2 个月文件 + 创建时间落在本周;
41
- * 近一年 = 最近 13 个月文件行数。任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
42
- */
43
- export declare function getClosedCounts(workspaces: readonly LedgerWorkspace[]): Promise<ClosedCounts>;
44
95
  /**
45
96
  * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
46
97
  * shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
@@ -1,20 +1,67 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readHistory, readTasks } from './ledger-read.js';
3
- import { runLedgerScript } from './script.js';
4
3
  /** 历史窗口:只读最近 13 个月的文件(2026-10-17 拍板;脚本默认读全部,窗口由插件显式传)。 */
5
4
  const RECENT_MONTHS = 13;
6
5
  /** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
7
- export const DEFAULT_HISTORY_LIMIT = 500;
8
- /** 完成类统计的封顶值:超出显示「1万+」(2026-10-17 拍板,显示级封顶,精确值走搜索)。 */
9
- const COUNT_CAP = 10_000;
10
- /** 周一所在日期(YYYY-MM-DD)。skill 的 --since 只给日期按当天 00:00 解析。 */
11
- function mondayDateString(now) {
12
- const date = new Date(now);
13
- const mondayOffset = (date.getDay() + 6) % 7;
14
- date.setDate(date.getDate() - mondayOffset);
15
- const pad = (value) => String(value).padStart(2, '0');
6
+ const DEFAULT_HISTORY_LIMIT = 500;
7
+ /** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
8
+ export const STALE_DAYS = 14;
9
+ /** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
10
+ export const WEEKLY_WEEKS = 8;
11
+ /* ── 日期工具(本机时区;skill 脚本同口径:周一为一周之始)───────────────── */
12
+ function pad(value) {
13
+ return String(value).padStart(2, '0');
14
+ }
15
+ /** Date → `YYYY-MM-DD`。 */
16
+ function ymd(date) {
16
17
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
17
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
+ }
55
+ /** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
56
+ function daysSince(stamp, now) {
57
+ if (stamp === null || stamp === undefined || stamp === '')
58
+ return null;
59
+ const parsed = new Date(String(stamp).replace(' ', 'T'));
60
+ if (Number.isNaN(parsed.getTime()))
61
+ return null;
62
+ const days = Math.floor((now.getTime() - parsed.getTime()) / 86_400_000);
63
+ return days > 0 ? days : 0;
64
+ }
18
65
  /**
19
66
  * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
20
67
  * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
@@ -32,47 +79,133 @@ export function getCurrentIdentity(workspace) {
32
79
  });
33
80
  });
34
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
+ }
35
95
  /**
36
- * 首页快照(在办路径):并发跑全部工作区的 list + 身份解析。
37
- * 与历史量彻底无关——progress.json 只装没结案的任务(close 会移走),永远有界。
96
+ * 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 近 8 周趋势(完成 / 新增 × 所有 / 我的)
97
+ * + 四个自然周期计数。**每个工作区只读一次**(list + history + git 身份),
98
+ * 避免「快照读一遍、统计再读一遍」把 5 万行级工作区的 history 解析翻倍。
38
99
  */
39
- export async function getBoardSnapshot(workspaces) {
40
- const perWorkspace = await Promise.all(workspaces.map(async (workspace) => ({
41
- workspace,
42
- tasks: await readTasks(workspace),
43
- identity: await getCurrentIdentity(workspace),
44
- })));
45
- const tasks = perWorkspace.flatMap((entry) => entry.tasks);
100
+ export async function getHomeAggregate(workspaces) {
101
+ const now = new Date();
102
+ const perWorkspace = await Promise.all(workspaces.map(readWorkspace));
103
+ // ── 快照(在办路径,与历史量无关)────────────────────────────────────
104
+ const withMeta = perWorkspace.flatMap((entry) => entry.tasks.map((task) => {
105
+ const working = task.status === 'doing' || task.status === 'blocked';
106
+ const ageDays = working ? daysSince(task.started ?? task.created, now) : null;
107
+ return { ...task, ageDays, stale: ageDays !== null && ageDays > STALE_DAYS };
108
+ }));
109
+ const openTasks = withMeta.filter((task) => task.status === 'open');
110
+ const blockedTasks = withMeta.filter((task) => task.status === 'blocked');
46
111
  const myActive = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && (task.status === 'doing' || task.status === 'blocked')).length, 0);
47
- const open = tasks.filter((task) => task.status === 'open').length;
112
+ const myBlocked = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && task.status === 'blocked').length, 0);
48
113
  const identities = {};
49
114
  for (const entry of perWorkspace)
50
115
  identities[entry.workspace.id] = entry.identity;
51
- return { at: new Date().toISOString(), workspaces, tasks, stats: { myActive, open }, identities };
52
- }
53
- /**
54
- * 完成类统计(数行封顶,与总量脱钩):本周 = 最近 2 个月文件 + 创建时间落在本周;
55
- * 近一年 = 最近 13 个月文件行数。任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
56
- */
57
- export async function getClosedCounts(workspaces) {
58
- const monday = mondayDateString(new Date());
59
- const perWorkspace = await Promise.all(workspaces.map(async (workspace) => {
60
- const [week, year] = await Promise.all([
61
- runLedgerScript('history', ['--repo', workspace.path, '--json', '--count-only', '--months', '2', '--since', monday]),
62
- runLedgerScript('history', ['--repo', workspace.path, '--json', '--count-only', '--months', String(RECENT_MONTHS)]),
63
- ]);
64
- if (!week.ok)
65
- throw new Error(`${workspace.title}: ${week.error}`);
66
- if (!year.ok)
67
- throw new Error(`${workspace.title}: ${year.error}`);
68
- return { week: week.data.count ?? 0, year: year.data.count ?? 0, yearCapped: year.data.capped === true };
69
- }));
70
- const yearCapped = perWorkspace.some((entry) => entry.yearCapped);
71
- return {
72
- weekDone: perWorkspace.reduce((sum, entry) => sum + entry.week, 0),
73
- yearDone: yearCapped ? COUNT_CAP : perWorkspace.reduce((sum, entry) => sum + entry.year, 0),
74
- yearCapped,
116
+ const snapshot = {
117
+ at: now.toISOString(),
118
+ workspaces,
119
+ tasks: withMeta,
120
+ stats: {
121
+ myActive,
122
+ myBlocked,
123
+ open: openTasks.length,
124
+ doing: withMeta.filter((task) => task.status === 'doing').length,
125
+ blocked: blockedTasks.length,
126
+ stale: withMeta.filter((task) => task.stale).length,
127
+ staleDays: STALE_DAYS,
128
+ oldestOpenDays: openTasks.length === 0
129
+ ? null
130
+ : openTasks.reduce((max, task) => Math.max(max, daysSince(task.created, now) ?? 0), 0),
131
+ },
132
+ identities,
75
133
  };
134
+ // ── 趋势:近 8 周(完成按结案时间、新增按创建时间)× 所有 / 我的 ────────
135
+ // 新增必须**合并两个来源**:已结案的任务被 close 移出 progress.json,只在 history 里;
136
+ // 在办的只在 progress.json 里。两者不相交(close 是移动),合并即全集、不会重复计。
137
+ const weeks = [];
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);
144
+ if (bucket !== undefined)
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
+ }
173
+ }
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),
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;
76
209
  }
77
210
  /**
78
211
  * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
@@ -1,4 +1,4 @@
1
- import { getBoardSnapshot, getClosedCounts, 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
- // 首页一次拿全:在办快照 + 两个完成类统计。
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), getClosedCounts(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.7",
3
+ "version": "0.0.9",
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",