libero-mcp 0.2.17 → 0.2.19
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/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/dist/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/dist/mcp/server-core.js +16 -2
- package/dist/mcp/src/repo/in-memory.js +1 -0
- package/dist/mcp/src/repo/sqlite.js +1 -0
- package/dist/mcp/src/task/index.js +58 -10
- package/dist/mcp/src/timeline/index.js +145 -20
- package/package.json +1 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- S46 (2026-08-07): task difficulty/priority 持久化
|
|
2
|
+
-- 用于"快速启动 + 峰终"智能排程
|
|
3
|
+
-- 难度(easy/medium/hard,默认 medium)
|
|
4
|
+
ALTER TABLE tasks ADD COLUMN difficulty TEXT DEFAULT 'medium';
|
|
5
|
+
-- 优先级(high/medium/low,默认 medium)
|
|
6
|
+
ALTER TABLE tasks ADD COLUMN priority TEXT DEFAULT 'medium';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- S46 (2026-08-07): task difficulty/priority 持久化
|
|
2
|
+
-- 用于"快速启动 + 峰终"智能排程
|
|
3
|
+
-- 难度(easy/medium/hard,默认 medium)
|
|
4
|
+
ALTER TABLE tasks ADD COLUMN difficulty TEXT DEFAULT 'medium';
|
|
5
|
+
-- 优先级(high/medium/low,默认 medium)
|
|
6
|
+
ALTER TABLE tasks ADD COLUMN priority TEXT DEFAULT 'medium';
|
package/dist/mcp/server-core.js
CHANGED
|
@@ -824,7 +824,7 @@ function buildToolRegistry(ctx) {
|
|
|
824
824
|
// ============================================================
|
|
825
825
|
tools.push({
|
|
826
826
|
name: "task.create",
|
|
827
|
-
description: "建单个独立任务(不挂任何 timeblock)。⚠️ 拆解请用 decompose.split,本工具一次只能建一个任务、不能建子任务。",
|
|
827
|
+
description: "建单个独立任务(不挂任何 timeblock)。⚠️ 拆解请用 decompose.split,本工具一次只能建一个任务、不能建子任务。S46 支持 difficulty(easy/medium/hard,默认 medium)+ priority(high/medium/low,默认 medium),用于'快速启动+峰终'智能排程。",
|
|
828
828
|
// S33 v3: 用 z.object().strict() 显式拒 parent_id(zod 默认 strip 不报错,
|
|
829
829
|
// 严格模式让多余键触发 safeParse 失败 → MCP 返回 isError)。
|
|
830
830
|
// 底层 createTask 仍保留 parent_id 能力(task.test.ts 单测不破)。
|
|
@@ -834,6 +834,8 @@ function buildToolRegistry(ctx) {
|
|
|
834
834
|
target_user_id: z.string().min(1).optional(),
|
|
835
835
|
title: z.string().min(1),
|
|
836
836
|
est_minutes: z.number().optional(),
|
|
837
|
+
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
838
|
+
priority: z.enum(["high", "medium", "low"]).optional(),
|
|
837
839
|
})
|
|
838
840
|
.strict(),
|
|
839
841
|
needsUserIdInjection: true,
|
|
@@ -868,12 +870,14 @@ function buildToolRegistry(ctx) {
|
|
|
868
870
|
});
|
|
869
871
|
tools.push({
|
|
870
872
|
name: "task.update",
|
|
871
|
-
description: "更新任务(title/est_minutes/status;状态机校验非法流转)",
|
|
873
|
+
description: "更新任务(title/est_minutes/status/difficulty/priority;状态机校验非法流转)",
|
|
872
874
|
inputSchema: {
|
|
873
875
|
id: z.string().min(1),
|
|
874
876
|
title: z.string().optional(),
|
|
875
877
|
est_minutes: z.number().optional(),
|
|
876
878
|
status: z.string().optional(),
|
|
879
|
+
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
880
|
+
priority: z.enum(["high", "medium", "low"]).optional(),
|
|
877
881
|
},
|
|
878
882
|
handler: async ({ id, ...rest }) => asContent(await task.updateTask(id, rest)),
|
|
879
883
|
});
|
|
@@ -886,6 +890,16 @@ function buildToolRegistry(ctx) {
|
|
|
886
890
|
},
|
|
887
891
|
handler: async ({ task_id, block_id }) => asContent(await task.linkTimeblock(task_id, block_id)),
|
|
888
892
|
});
|
|
893
|
+
tools.push({
|
|
894
|
+
name: "task.updateDifficulty",
|
|
895
|
+
description: "S46: 仅更新 task 的 difficulty(easy/medium/hard)或 priority(high/medium/low)。用户主动调整时用,默认 medium。用于'快速启动+峰终'智能排程。",
|
|
896
|
+
inputSchema: {
|
|
897
|
+
task_id: z.string().min(1),
|
|
898
|
+
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
899
|
+
priority: z.enum(["high", "medium", "low"]).optional(),
|
|
900
|
+
},
|
|
901
|
+
handler: async ({ task_id, difficulty, priority }) => asContent(await task.updateDifficulty(task_id, { difficulty, priority })),
|
|
902
|
+
});
|
|
889
903
|
tools.push({
|
|
890
904
|
name: "task.findPlan",
|
|
891
905
|
description: "按事件名 exact-match 查拆解计划:返回根任务 + 第一块未完成子任务(record 开工时链 task_id,驱动闭环正反馈)",
|
|
@@ -298,6 +298,7 @@ export class InMemoryTimelineRepo {
|
|
|
298
298
|
end: b.end_time,
|
|
299
299
|
status: b.status,
|
|
300
300
|
title: b.title,
|
|
301
|
+
category_id: b.category_id ?? null,
|
|
301
302
|
category_emoji: cat?.emoji ?? null,
|
|
302
303
|
category_name: cat?.name ?? null,
|
|
303
304
|
category_color: cat?.color ?? null,
|
|
@@ -19,6 +19,13 @@ function nowStr() {
|
|
|
19
19
|
return (new Date().toISOString().replace("T", " ").replace("Z", "") +
|
|
20
20
|
String(Math.floor(performance.now() * 100000) % 100000).padStart(5, "0"));
|
|
21
21
|
}
|
|
22
|
+
// S46: difficulty/priority 归一化(不合法值 → medium)
|
|
23
|
+
function normalizeDiff(v) {
|
|
24
|
+
return v === "easy" || v === "medium" || v === "hard" ? v : "medium";
|
|
25
|
+
}
|
|
26
|
+
function normalizePrio(v) {
|
|
27
|
+
return v === "high" || v === "medium" || v === "low" ? v : "medium";
|
|
28
|
+
}
|
|
22
29
|
function rowToTask(row) {
|
|
23
30
|
return {
|
|
24
31
|
id: row.id,
|
|
@@ -30,6 +37,9 @@ function rowToTask(row) {
|
|
|
30
37
|
category_id: row.category_id ?? null,
|
|
31
38
|
linked_timeblock_id: row.linked_timeblock_id ?? null,
|
|
32
39
|
order_index: row.order_index ?? 0,
|
|
40
|
+
// S46: difficulty/priority(migration 014 加的列;旧库未 migrate 时回退 medium)
|
|
41
|
+
difficulty: row.difficulty ?? "medium",
|
|
42
|
+
priority: row.priority ?? "medium",
|
|
33
43
|
created_at: row.created_at,
|
|
34
44
|
updated_at: row.updated_at,
|
|
35
45
|
deleted_at: row.deleted_at ?? null,
|
|
@@ -81,8 +91,11 @@ export function createTaskTools(db) {
|
|
|
81
91
|
}
|
|
82
92
|
const id = crypto.randomUUID();
|
|
83
93
|
const now = nowStr();
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
// S46: difficulty/priority 默认 medium,校验入参合法值
|
|
95
|
+
const difficulty = normalizeDiff(input.difficulty);
|
|
96
|
+
const priority = normalizePrio(input.priority);
|
|
97
|
+
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
|
|
98
|
+
VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, 0, ?, ?, ?, ?, NULL)`).run(id, userId, input.parent_id ?? null, input.title, input.est_minutes ?? null, difficulty, priority, now, now);
|
|
86
99
|
const row = getTaskRow(id);
|
|
87
100
|
return {
|
|
88
101
|
...rowToTask(row),
|
|
@@ -142,6 +155,15 @@ export function createTaskTools(db) {
|
|
|
142
155
|
sets.push("status = ?");
|
|
143
156
|
params.push(patch.status);
|
|
144
157
|
}
|
|
158
|
+
// S46: difficulty/priority 可独立更新
|
|
159
|
+
if (patch.difficulty !== undefined) {
|
|
160
|
+
sets.push("difficulty = ?");
|
|
161
|
+
params.push(normalizeDiff(patch.difficulty));
|
|
162
|
+
}
|
|
163
|
+
if (patch.priority !== undefined) {
|
|
164
|
+
sets.push("priority = ?");
|
|
165
|
+
params.push(normalizePrio(patch.priority));
|
|
166
|
+
}
|
|
145
167
|
// Always refresh updated_at
|
|
146
168
|
sets.push("updated_at = ?");
|
|
147
169
|
params.push(nowStr());
|
|
@@ -168,6 +190,32 @@ export function createTaskTools(db) {
|
|
|
168
190
|
const row = getTaskRow(taskId);
|
|
169
191
|
return rowToTask(row);
|
|
170
192
|
},
|
|
193
|
+
/** S46: 仅更新 difficulty/priority(用户主动调整) */
|
|
194
|
+
async updateDifficulty(taskId, patch) {
|
|
195
|
+
const existing = getTaskRow(taskId);
|
|
196
|
+
if (!existing) {
|
|
197
|
+
throw new Error("任务不存在");
|
|
198
|
+
}
|
|
199
|
+
if (patch.difficulty === undefined && patch.priority === undefined) {
|
|
200
|
+
throw new Error("至少传一个字段 difficulty 或 priority");
|
|
201
|
+
}
|
|
202
|
+
const sets = [];
|
|
203
|
+
const params = [];
|
|
204
|
+
if (patch.difficulty !== undefined) {
|
|
205
|
+
sets.push("difficulty = ?");
|
|
206
|
+
params.push(normalizeDiff(patch.difficulty));
|
|
207
|
+
}
|
|
208
|
+
if (patch.priority !== undefined) {
|
|
209
|
+
sets.push("priority = ?");
|
|
210
|
+
params.push(normalizePrio(patch.priority));
|
|
211
|
+
}
|
|
212
|
+
sets.push("updated_at = ?");
|
|
213
|
+
params.push(nowStr());
|
|
214
|
+
params.push(taskId);
|
|
215
|
+
db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
216
|
+
const row = getTaskRow(taskId);
|
|
217
|
+
return rowToTask(row);
|
|
218
|
+
},
|
|
171
219
|
async findPlan(input) {
|
|
172
220
|
// exact-match a startable root task (aligned/executing) by title
|
|
173
221
|
const rootRow = db
|
|
@@ -241,13 +289,13 @@ export function createTaskTools(db) {
|
|
|
241
289
|
const result = db.transaction(() => {
|
|
242
290
|
const rootId = crypto.randomUUID();
|
|
243
291
|
const now = nowStr();
|
|
244
|
-
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
|
|
245
|
-
VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, NULL, 0, ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, now, now);
|
|
292
|
+
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
|
|
293
|
+
VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, NULL, 0, 'medium', 'medium', ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, now, now);
|
|
246
294
|
const children = plan.children.map((c, i) => {
|
|
247
295
|
const childId = crypto.randomUUID();
|
|
248
296
|
const cnow = nowStr();
|
|
249
|
-
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
|
|
250
|
-
VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
|
|
297
|
+
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
|
|
298
|
+
VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, 'medium', 'medium', ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
|
|
251
299
|
return rowToTask(getTaskRow(childId));
|
|
252
300
|
});
|
|
253
301
|
return { root: rowToTask(getTaskRow(rootId)), children };
|
|
@@ -334,13 +382,13 @@ export function createTaskTools(db) {
|
|
|
334
382
|
const result = db.transaction(() => {
|
|
335
383
|
const rootId = crypto.randomUUID();
|
|
336
384
|
const now = nowStr();
|
|
337
|
-
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
|
|
338
|
-
VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, ?, 0, ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, plan.timeblock_id, now, now);
|
|
385
|
+
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
|
|
386
|
+
VALUES (?, ?, NULL, ?, 'aligned', NULL, NULL, ?, 0, 'medium', 'medium', ?, ?, NULL)`).run(rootId, plan.user_id, plan.root_title, plan.timeblock_id, now, now);
|
|
339
387
|
const children = plan.children.map((c, i) => {
|
|
340
388
|
const childId = crypto.randomUUID();
|
|
341
389
|
const cnow = nowStr();
|
|
342
|
-
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, created_at, updated_at, deleted_at)
|
|
343
|
-
VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
|
|
390
|
+
db.prepare(`INSERT INTO tasks (id, user_id, parent_id, title, status, est_minutes, category_id, linked_timeblock_id, order_index, difficulty, priority, created_at, updated_at, deleted_at)
|
|
391
|
+
VALUES (?, ?, ?, ?, 'draft', ?, NULL, NULL, ?, 'medium', 'medium', ?, ?, NULL)`).run(childId, plan.user_id, rootId, c.title, c.est_minutes, i, cnow, cnow);
|
|
344
392
|
return {
|
|
345
393
|
id: childId,
|
|
346
394
|
title: c.title,
|
|
@@ -291,17 +291,20 @@ export function renderTimelineCells(input) {
|
|
|
291
291
|
// S9 v0.5(2026-08-07):表头移到矩阵上方,不再夹在明细前面
|
|
292
292
|
const textLines = [];
|
|
293
293
|
// ── 明细段(在前)──
|
|
294
|
-
// S9 v0.3: 按 symbol
|
|
294
|
+
// S9 v0.3: 按 symbol 分组紧凑展示
|
|
295
295
|
// S9 v0.4: completed → ✓ + ~~划线~~;unverified → ❓ 后缀;插入「📍 现在」分隔线
|
|
296
|
-
// S9 v0.5(2026-08-07):
|
|
297
|
-
//
|
|
298
|
-
//
|
|
296
|
+
// S9 v0.5(2026-08-07): 每条独占一行
|
|
297
|
+
// S45(2026-08-07): 同 category + 时间连续 → 合并成组;中间 ≤15min 短休息 → 吸收
|
|
298
|
+
// 组(>1 member 或吸收了休息):组头 + 子任务列表
|
|
299
|
+
// 组(仅 1 member 无休息):退化为 v0.5 单行(避免冗余)
|
|
299
300
|
const details = input.details ?? [];
|
|
300
301
|
if (details.length > 0) {
|
|
301
302
|
textLines.push("📋 明细");
|
|
302
303
|
const inWin = details.filter((d) => d.in_window);
|
|
303
304
|
const outWin = details.filter((d) => !d.in_window);
|
|
304
|
-
//
|
|
305
|
+
// S45: inWin 已按 time 排序,可直接分组
|
|
306
|
+
const inWinGroups = groupDetails(inWin);
|
|
307
|
+
// S9 v0.5: 单条 entry 格式
|
|
305
308
|
const buildEntry = (d) => {
|
|
306
309
|
let statusMark = " "; // 默认 2 空格占位(对齐 ✓ 和 ?)
|
|
307
310
|
let titlePart = d.title;
|
|
@@ -314,7 +317,28 @@ export function renderTimelineCells(input) {
|
|
|
314
317
|
}
|
|
315
318
|
return `${statusMark}${d.symbol} ${d.time} ${titlePart}`;
|
|
316
319
|
};
|
|
317
|
-
//
|
|
320
|
+
// S45: 组内子任务 entry(不带组 symbol,状态前缀照旧)
|
|
321
|
+
const buildSubEntry = (d) => {
|
|
322
|
+
let statusMark = " ";
|
|
323
|
+
let titlePart = d.title;
|
|
324
|
+
if (d.status === "completed") {
|
|
325
|
+
statusMark = "✓ ";
|
|
326
|
+
titlePart = `~~${d.title}~~`;
|
|
327
|
+
}
|
|
328
|
+
else if (d.status === "unverified") {
|
|
329
|
+
statusMark = "? ";
|
|
330
|
+
}
|
|
331
|
+
// 子任务缩进 2 空格,时间取自 d.time 的 "HH:MM-HH:MM"
|
|
332
|
+
return ` ${statusMark}${d.time} ${titlePart}`;
|
|
333
|
+
};
|
|
334
|
+
// S45: 组头格式
|
|
335
|
+
const buildGroupHeader = (g) => {
|
|
336
|
+
const emoji = g.category_emoji && g.category_emoji.trim() ? g.category_emoji : "•";
|
|
337
|
+
const name = g.category_name ?? "活动";
|
|
338
|
+
const breakNote = g.break_total_min > 0 ? `(含休息 ${g.break_total_min}min)` : "";
|
|
339
|
+
return `${emoji} ${name} ${g.start_time}-${g.end_time}${breakNote}`;
|
|
340
|
+
};
|
|
341
|
+
// S9 v0.4: 「📍 现在」分隔线判断
|
|
318
342
|
const parseDetailStart = (time) => {
|
|
319
343
|
const startStr = time.split("-")[0].trim();
|
|
320
344
|
const [h, m] = startStr.split(":").map(Number);
|
|
@@ -326,26 +350,34 @@ export function renderTimelineCells(input) {
|
|
|
326
350
|
const inWinAllPast = inWin.every((d) => parseDetailStart(d.time) <= nowHM);
|
|
327
351
|
const inWinAllFuture = inWin.every((d) => parseDetailStart(d.time) > nowHM);
|
|
328
352
|
const needNowMarker = inWin.length > 0 && !inWinAllPast && !inWinAllFuture;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
353
|
+
// S45: 渲染组——支持"现在"标识插在第一个未来组之前
|
|
354
|
+
const renderGroup = (g) => {
|
|
355
|
+
const isMulti = g.members.length > 1 || g.absorbed_breaks.length > 0;
|
|
356
|
+
if (!isMulti) {
|
|
357
|
+
// 退化为 v0.5 单行
|
|
358
|
+
return [buildEntry(g.members[0])];
|
|
336
359
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
360
|
+
const lines = [buildGroupHeader(g)];
|
|
361
|
+
for (const m of g.members) {
|
|
362
|
+
lines.push(buildSubEntry(m));
|
|
363
|
+
}
|
|
364
|
+
return lines;
|
|
365
|
+
};
|
|
366
|
+
for (let i = 0; i < inWinGroups.length; i++) {
|
|
367
|
+
const g = inWinGroups[i];
|
|
368
|
+
// 「📍 现在」标识插在第一个未来组之前
|
|
369
|
+
if (needNowMarker && parseDetailStart(`${g.start_time}-${g.end_time}`) > nowHM && i > 0 && !textLines.includes(`─── 📍 现在 ${nowLabel} ──`)) {
|
|
341
370
|
textLines.push(`─── 📍 现在 ${nowLabel} ──`);
|
|
342
371
|
}
|
|
343
|
-
textLines.push(
|
|
372
|
+
textLines.push(...renderGroup(g));
|
|
344
373
|
}
|
|
374
|
+
// 兜底:如果所有组都过去/未来,但 needNowMarker 仍 true(混合)且没插入过 → 不补
|
|
375
|
+
// (理论上不会发生,因为上面循环会处理)
|
|
345
376
|
if (outWin.length > 0) {
|
|
346
377
|
textLines.push("(窗口外)");
|
|
347
|
-
|
|
348
|
-
|
|
378
|
+
const outWinGroups = groupDetails(outWin);
|
|
379
|
+
for (const g of outWinGroups) {
|
|
380
|
+
textLines.push(...renderGroup(g));
|
|
349
381
|
}
|
|
350
382
|
}
|
|
351
383
|
}
|
|
@@ -389,6 +421,80 @@ export function renderTimelineCells(input) {
|
|
|
389
421
|
}
|
|
390
422
|
return { rows, rendered_text: textLines.join("\n"), legend };
|
|
391
423
|
}
|
|
424
|
+
const S45_GAP_THRESHOLD_MIN = 15;
|
|
425
|
+
const S45_BREAK_THRESHOLD_MIN = 15;
|
|
426
|
+
function isShortBreak(d) {
|
|
427
|
+
return !!d.is_break && (d.duration_min ?? 999) <= S45_BREAK_THRESHOLD_MIN;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* S45: 把已排序的 detail 列表分组成 DetailGroup[]。
|
|
431
|
+
* 输入必须按 start_ms 升序排好。
|
|
432
|
+
*/
|
|
433
|
+
export function groupDetails(details) {
|
|
434
|
+
if (details.length === 0)
|
|
435
|
+
return [];
|
|
436
|
+
const groups = [];
|
|
437
|
+
let current = null;
|
|
438
|
+
for (let i = 0; i < details.length; i++) {
|
|
439
|
+
const d = details[i];
|
|
440
|
+
if (current === null) {
|
|
441
|
+
// 首个块 → 开新组(休息块也开自己的"独立组",members 是它自己)
|
|
442
|
+
current = newGroup(d);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const gapMin = ((d.start_ms ?? 0) - (current.members[current.members.length - 1]?.end_ms ?? 0)) /
|
|
446
|
+
(60 * 1000);
|
|
447
|
+
const sameCategory = !!d.category_id &&
|
|
448
|
+
d.category_id === current.category_id &&
|
|
449
|
+
!!current.category_id;
|
|
450
|
+
// 情况 1:当前块是短休息,且后面(i+1)还有同 category 块 → 吸收到当前组
|
|
451
|
+
if (isShortBreak(d) && sameCategoryContinuesAfter(details, i, current.category_id)) {
|
|
452
|
+
current.absorbed_breaks.push(d);
|
|
453
|
+
current.break_total_min += d.duration_min ?? 0;
|
|
454
|
+
// 注意:休息块的 end_ms 不更新 current 的 last_end——组的 last_end 取最后一个非休息块
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// 情况 2:同 category + gap ≤15min → 加入组
|
|
458
|
+
if (sameCategory && gapMin >= 0 && gapMin <= S45_GAP_THRESHOLD_MIN) {
|
|
459
|
+
current.members.push(d);
|
|
460
|
+
// 更新组的 end_time
|
|
461
|
+
current.end_time = d.time.split("-")[1]?.trim() ?? current.end_time;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
// 情况 3:边界 → 结束当前组,开新组
|
|
465
|
+
groups.push(current);
|
|
466
|
+
current = newGroup(d);
|
|
467
|
+
}
|
|
468
|
+
if (current)
|
|
469
|
+
groups.push(current);
|
|
470
|
+
return groups;
|
|
471
|
+
}
|
|
472
|
+
function newGroup(d) {
|
|
473
|
+
return {
|
|
474
|
+
members: [d],
|
|
475
|
+
absorbed_breaks: [],
|
|
476
|
+
break_total_min: 0,
|
|
477
|
+
start_time: d.time.split("-")[0]?.trim() ?? "",
|
|
478
|
+
end_time: d.time.split("-")[1]?.trim() ?? "",
|
|
479
|
+
category_id: d.category_id ?? null,
|
|
480
|
+
category_name: d.category_name ?? null,
|
|
481
|
+
category_emoji: d.category_emoji ?? null,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/** 判断 details[i] 之后是否还有同 category_id 的非休息块(前视,用于休息吸收判定) */
|
|
485
|
+
function sameCategoryContinuesAfter(details, i, categoryId) {
|
|
486
|
+
if (!categoryId)
|
|
487
|
+
return false;
|
|
488
|
+
for (let j = i + 1; j < details.length; j++) {
|
|
489
|
+
const next = details[j];
|
|
490
|
+
if (next.is_break)
|
|
491
|
+
continue; // 跳过中间的休息块
|
|
492
|
+
if (next.category_id === categoryId)
|
|
493
|
+
return true;
|
|
494
|
+
return false; // 遇到非同 category 的非休息块 → 不算后续同 category
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
392
498
|
export function computeDefaultWindow(now) {
|
|
393
499
|
const hour = new Date(now);
|
|
394
500
|
hour.setMinutes(0, 0, 0);
|
|
@@ -514,12 +620,23 @@ export function buildTimelineFromEvents(opts) {
|
|
|
514
620
|
const sym = resolveCellSymbol(cell, format);
|
|
515
621
|
const bStart = new Date(b.start).getTime();
|
|
516
622
|
const bEnd = new Date(b.end).getTime();
|
|
623
|
+
// S45: is_break 判定——标题含「休息」/「break」/「放松」/「空闲」或 category 是闲散
|
|
624
|
+
const titleLower = b.title.toLowerCase();
|
|
625
|
+
const isBreak = /休息|break|放松|空闲|歇|摸鱼|发呆/.test(b.title) ||
|
|
626
|
+
(b.category_name ?? "").includes("闲散");
|
|
517
627
|
details.push({
|
|
518
628
|
time: detailTimeRange(b.start, b.end),
|
|
519
629
|
symbol: sym,
|
|
520
630
|
title: b.title,
|
|
521
631
|
status: b.status,
|
|
522
632
|
in_window: overlaps(bStart, bEnd, wStart, wEnd),
|
|
633
|
+
category_id: b.category_id ?? null,
|
|
634
|
+
category_name: b.category_name ?? null,
|
|
635
|
+
category_emoji: b.category_emoji ?? null,
|
|
636
|
+
is_break: isBreak,
|
|
637
|
+
start_ms: bStart,
|
|
638
|
+
end_ms: bEnd,
|
|
639
|
+
duration_min: Math.round((bEnd - bStart) / 60000),
|
|
523
640
|
});
|
|
524
641
|
}
|
|
525
642
|
for (const t of opts.timers) {
|
|
@@ -533,6 +650,14 @@ export function buildTimelineFromEvents(opts) {
|
|
|
533
650
|
title: t.event,
|
|
534
651
|
status: t.status === "running" ? "timer_running" : "timer_paused",
|
|
535
652
|
in_window: overlaps(startMs, endMs, wStart, wEnd),
|
|
653
|
+
// timer 通常不参与分组(用户主动计时,独立性强)
|
|
654
|
+
category_id: null,
|
|
655
|
+
category_name: null,
|
|
656
|
+
category_emoji: null,
|
|
657
|
+
is_break: false,
|
|
658
|
+
start_ms: startMs,
|
|
659
|
+
end_ms: endMs,
|
|
660
|
+
duration_min: Math.round((endMs - startMs) / 60000),
|
|
536
661
|
});
|
|
537
662
|
}
|
|
538
663
|
// sort: in_window first, then by time string
|