libero-mcp 0.2.13 → 0.2.14

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.
@@ -164,10 +164,14 @@ export class SqliteTimeBlockRepo {
164
164
  return (await this.getById(id));
165
165
  }
166
166
  async findOverlapping(input) {
167
+ // ⚠️ 时区安全:用 SQLite datetime() 规范化所有时间字段后再比较。
168
+ // datetime() 能解析多种 ISO 格式(含 +08:00 / Z),输出统一 'YYYY-MM-DD HH:MM:SS' UTC。
169
+ // 不能用裸字符串比较('2026-08-07T09:00:00+08:00' vs '2026-08-07T01:00:00Z' 字符串比较会误判)。
167
170
  return this.db
168
171
  .prepare(`SELECT * FROM time_blocks
169
172
  WHERE user_id = ? AND status = 'planned' AND deleted_at IS NULL
170
- AND start_time < ? AND end_time > ?
173
+ AND datetime(start_time) < datetime(?)
174
+ AND datetime(end_time) > datetime(?)
171
175
  ORDER BY start_time`)
172
176
  .all(input.user_id, input.end, input.start);
173
177
  }
@@ -287,69 +287,45 @@ export function renderTimelineCells(input) {
287
287
  i = j;
288
288
  }
289
289
  }
290
- // Build text lines: 表格在前(用户先看到具体内容),矩阵在后(时间分布概览)
291
- // pressure 反馈 2026-07-27:纯色块矩阵对单调数据认不出,表格更直观,应优先展示
290
+ // Build text lines: 明细在前(用户先看具体内容),矩阵在后(时间分布概览)
291
+ // S9 v0.5(2026-08-07):表头移到矩阵上方,不再夹在明细前面
292
292
  const textLines = [];
293
- textLines.push(" 00 10 20 30 40 50");
294
- // ── 表格段(在前)──
295
- // S9 v0.3: details 段按 symbol 分组紧凑展示
296
- // 用户痛点:移动端窄屏,原格式 "{time} {symbol} {title} [planned]" 长标题折行后 [planned] 跑到第三行,诡异
297
- // 新格式:同 symbol 合并到一行,用 · 分隔;移除 [status] 后缀(状态在矩阵方块色已表达)
298
- // S9 v0.4: completed → ✓ 前缀 + ~~划线~~ titleunverified❓ 后缀;插入「📍 现在」分隔线
293
+ // ── 明细段(在前)──
294
+ // S9 v0.3: 按 symbol 分组紧凑展示(合并同 symbol 到一行)
295
+ // S9 v0.4: completed + ~~划线~~;unverified → ❓ 后缀;插入「📍 现在」分隔线
296
+ // S9 v0.5(2026-08-07): 改为每条独占一行(用户反馈"挤在一起乱"
297
+ // 格式:`{status} {symbol} {time} {title}`
298
+ // status: completed → "";unverified "? "planned/timer_*" "(空格占位对齐)
299
299
  const details = input.details ?? [];
300
300
  if (details.length > 0) {
301
301
  textLines.push("📋 明细");
302
302
  const inWin = details.filter((d) => d.in_window);
303
303
  const outWin = details.filter((d) => !d.in_window);
304
- // S9 v0.4: 构造单条 entry,根据 status 加完成态视觉
305
- // - completed: "✓ {time} ~~{title}~~"
306
- // - unverified: "{time} {title} ❓"
307
- // - planned / timer_running / timer_paused: "{time} {title}"
304
+ // S9 v0.5: 每条独占一行的 entry 构造
308
305
  const buildEntry = (d) => {
306
+ let statusMark = " "; // 默认 2 空格占位(对齐 ✓ 和 ?)
307
+ let titlePart = d.title;
309
308
  if (d.status === "completed") {
310
- return `✓ ${d.time} ~~${d.title}~~`;
311
- }
312
- if (d.status === "unverified") {
313
- return `${d.time} ${d.title} ❓`;
309
+ statusMark = "✓ ";
310
+ titlePart = `~~${d.title}~~`;
314
311
  }
315
- return `${d.time} ${d.title}`;
316
- };
317
- // 按 symbol 分组(保留 inWin 内的原始顺序)
318
- const groupBySymbol = (list) => {
319
- const groups = [];
320
- const symbolToGroup = new Map();
321
- for (const d of list) {
322
- mark(d.symbol);
323
- const entry = buildEntry(d);
324
- const idx = symbolToGroup.get(d.symbol);
325
- if (idx === undefined) {
326
- symbolToGroup.set(d.symbol, groups.length);
327
- groups.push({ symbol: d.symbol, entries: [entry] });
328
- }
329
- else {
330
- groups[idx].entries.push(entry);
331
- }
312
+ else if (d.status === "unverified") {
313
+ statusMark = "? ";
332
314
  }
333
- return groups;
315
+ return `${statusMark}${d.symbol} ${d.time} ${titlePart}`;
334
316
  };
335
- // S9 v0.4: 计算「📍 现在」插入点
336
- // 在 inWin 列表中,找到第一条 start_time > now 的明细位置
337
- // 时间从 detail.time "HH:MM-HH:MM" 取 start
317
+ // S9 v0.4: 「📍 现在」分隔线判断(v0.5 保留同规则)
338
318
  const parseDetailStart = (time) => {
339
319
  const startStr = time.split("-")[0].trim();
340
320
  const [h, m] = startStr.split(":").map(Number);
341
321
  return h * 60 + m;
342
322
  };
343
- const nowMs = new Date(input.nowIso).getTime();
344
323
  const nowDate = new Date(input.nowIso);
345
324
  const nowHM = nowDate.getHours() * 60 + nowDate.getMinutes();
346
325
  const nowLabel = `${String(nowDate.getHours()).padStart(2, "0")}:${String(nowDate.getMinutes()).padStart(2, "0")}`;
347
- // 判断是否需要插「📍 现在」
348
- // 仅当 inWin 中存在 start > now 且存在 start <= now 时才插(即跨 now)
349
326
  const inWinAllPast = inWin.every((d) => parseDetailStart(d.time) <= nowHM);
350
327
  const inWinAllFuture = inWin.every((d) => parseDetailStart(d.time) > nowHM);
351
328
  const needNowMarker = inWin.length > 0 && !inWinAllPast && !inWinAllFuture;
352
- // 找到第一个 start > now 的 index(在该位置前插入分隔线)
353
329
  let firstFutureIdx = inWin.length;
354
330
  if (needNowMarker) {
355
331
  for (let i = 0; i < inWin.length; i++) {
@@ -359,30 +335,17 @@ export function renderTimelineCells(input) {
359
335
  }
360
336
  }
361
337
  }
362
- // S9 v0.4: 改写分组渲染——需要保留 inWin 原始顺序,在 firstFutureIdx 处插分隔线
363
- // 旧逻辑 groupBySymbol 一次处理整个 inWin,无法在中间插分隔线
364
- // 新逻辑:先按 firstFutureIdx 切成 past / future 两段,各自 groupBySymbol,中间插「📍 现在」
365
- if (needNowMarker) {
366
- const past = inWin.slice(0, firstFutureIdx);
367
- const future = inWin.slice(firstFutureIdx);
368
- for (const g of groupBySymbol(past)) {
369
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
370
- }
371
- textLines.push(`─── 📍 现在 ${nowLabel} ──`);
372
- for (const g of groupBySymbol(future)) {
373
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
374
- }
375
- }
376
- else {
377
- // 无需分隔线(全过 / 全未到),原逻辑
378
- for (const g of groupBySymbol(inWin)) {
379
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
338
+ // S9 v0.5: 每条独占一行,在 firstFutureIdx 处插分隔线
339
+ for (let i = 0; i < inWin.length; i++) {
340
+ if (needNowMarker && i === firstFutureIdx) {
341
+ textLines.push(`─── 📍 现在 ${nowLabel} ──`);
380
342
  }
343
+ textLines.push(buildEntry(inWin[i]));
381
344
  }
382
345
  if (outWin.length > 0) {
383
346
  textLines.push("(窗口外)");
384
- for (const g of groupBySymbol(outWin)) {
385
- textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
347
+ for (const d of outWin) {
348
+ textLines.push(buildEntry(d));
386
349
  }
387
350
  }
388
351
  }
@@ -397,6 +360,8 @@ export function renderTimelineCells(input) {
397
360
  };
398
361
  if (boundary)
399
362
  pushEllipsis();
363
+ // S9 v0.5: 时间表头紧贴矩阵上方(之前在明细前,造成"表头夹中间"错觉)
364
+ textLines.push(" 00 10 20 30 40 50");
400
365
  // 2026-07-27 UX 反馈:矩阵上下行距太挤,每行后加一个空行透气
401
366
  for (let k = 0; k < matrixLines.length; k++) {
402
367
  const line = matrixLines[k];
@@ -116,8 +116,41 @@ export function createTimerTools(repo, timeBlockRepo, db) {
116
116
  ended_at: endedAt,
117
117
  });
118
118
  let timeblockId;
119
- // Auto-persist to timeblock when rating is provided
120
- if (options?.rating !== undefined && timeBlockRepo) {
119
+ // S42 (2026-08-07): 回查匹配的 planned timeblock completed
120
+ // 用户场景:先排了 planned timeblock(如「羽毛球 9:00-11:00」),后开计时;
121
+ // 停计时期望自动标完成(带 ✓ + 划线),而不是 create 新的。
122
+ // 匹配规则:同 user_id + 同 title + 时间重叠 + status=planned
123
+ //
124
+ // ⚠️ 时区陷阱:planned timeblock 的 start_time/end_time 可能是 +08:00 格式(用户传入),
125
+ // 而 timer.session 的 started_at 是 Z 格式(nowISO())。findOverlapping 用字符串比较,
126
+ // 混合时区会误判("2026-08-07T09:00:00+08:00" vs "2026-08-07T05:00:00Z" 字符串比较 09 > 05)。
127
+ // 解法:调用前把 started_at/endedAt 都转 ISO UTC(Z 格式),与 line 578-581 其它 list 查询姿势一致。
128
+ if (timeBlockRepo) {
129
+ const startUtc = new Date(active.started_at).toISOString();
130
+ const endUtc = new Date(endedAt).toISOString();
131
+ const candidates = await timeBlockRepo.findOverlapping({
132
+ user_id: active.user_id,
133
+ start: startUtc,
134
+ end: endUtc,
135
+ });
136
+ const match = candidates.find((tb) => tb.status === "planned" &&
137
+ (tb.title === active.event ||
138
+ tb.title.includes(active.event) ||
139
+ active.event.includes(tb.title)));
140
+ if (match) {
141
+ const updated = await timeBlockRepo.update(match.id, {
142
+ end_time: endedAt,
143
+ status: "completed",
144
+ ...(options?.rating !== undefined ? { rating: options.rating } : {}),
145
+ ...(options?.rating_reason !== undefined
146
+ ? { rating_reason: options.rating_reason }
147
+ : {}),
148
+ });
149
+ timeblockId = updated.id;
150
+ }
151
+ }
152
+ // 没匹配到 planned → 走原 create 路径(仅 rating 时)
153
+ if (!timeblockId && options?.rating !== undefined && timeBlockRepo) {
121
154
  const tb = await timeBlockRepo.create({
122
155
  user_id: active.user_id,
123
156
  title: active.event,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",