libero-mcp 0.2.10 → 0.2.12

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.
@@ -300,6 +300,7 @@ export class InMemoryTimelineRepo {
300
300
  title: b.title,
301
301
  category_emoji: cat?.emoji ?? null,
302
302
  category_name: cat?.name ?? null,
303
+ category_color: cat?.color ?? null,
303
304
  };
304
305
  });
305
306
  const timers = this.seed.timer_sessions
@@ -401,7 +401,8 @@ export class SqliteTimelineRepo {
401
401
  tb.status as status,
402
402
  tb.title as title,
403
403
  c.emoji as category_emoji,
404
- c.name as category_name
404
+ c.name as category_name,
405
+ c.color as category_color
405
406
  FROM time_blocks tb
406
407
  LEFT JOIN categories c ON tb.category_id = c.id
407
408
  WHERE tb.user_id = ?
@@ -42,6 +42,71 @@ export function categoryAsciiToken(name) {
42
42
  return `[${latin[0].toUpperCase()}]`;
43
43
  return `[${name.trim()[0]}]`;
44
44
  }
45
+ /**
46
+ * S9 v0.2: hex 颜色 → 方块 emoji(粗色相映射,6-7 色)
47
+ *
48
+ * 设计意图(见 docs/specs/S9-v0.2-design-plan-colored-tiles.md):
49
+ * - 15 个分类压缩到 7 种方块色——保留视觉区分度,避免 15 色用户认不过来
50
+ * - 用 HSL hue 粗分(不用精确匹配 hex,避免脆弱)
51
+ * - 灰阶 / 深色 → ⬛(视觉暗色,区分显著)
52
+ * - 无效输入 → 🟦(默认蓝,跟原 spec v0.1 兼容)
53
+ */
54
+ export function colorToTile(hex) {
55
+ if (!hex || typeof hex !== "string" || !hex.startsWith("#"))
56
+ return "🟦";
57
+ const r = parseInt(hex.slice(1, 3), 16);
58
+ const g = parseInt(hex.slice(3, 5), 16);
59
+ const b = parseInt(hex.slice(5, 7), 16);
60
+ // 解析失败 → fallback
61
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b))
62
+ return "🟦";
63
+ const { h, s, l } = rgbToHsl(r, g, b);
64
+ // 灰阶(饱和度 < 12%)或过暗(亮度 < 0.35)→ ⬛
65
+ // 设计:睡眠(#34495E l=0.29 s=0.29)、内务(#95A5A6 s=0.09)、闲散(#BDC3C7) 等
66
+ // 深色 / 中性色都归黑,区分显著
67
+ if (s < 0.12 || l < 0.35)
68
+ return "⬛";
69
+ // 色相分段(0-360)
70
+ if (h < 15 || h >= 345)
71
+ return "🟥"; // 红
72
+ if (h < 45)
73
+ return "🟧"; // 橙
74
+ if (h < 70)
75
+ return "🟨"; // 黄
76
+ if (h < 165)
77
+ return "🟩"; // 绿
78
+ if (h < 240)
79
+ return "🟦"; // 蓝(含青)
80
+ if (h < 315)
81
+ return "🟪"; // 紫(240-315)
82
+ return "🟥"; // 粉红归红
83
+ }
84
+ /** RGB → HSL(h: 0-360, s/l: 0-1) */
85
+ function rgbToHsl(r, g, b) {
86
+ const rN = r / 255;
87
+ const gN = g / 255;
88
+ const bN = b / 255;
89
+ const max = Math.max(rN, gN, bN);
90
+ const min = Math.min(rN, gN, bN);
91
+ const l = (max + min) / 2;
92
+ let h = 0;
93
+ let s = 0;
94
+ if (max !== min) {
95
+ const d = max - min;
96
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
97
+ switch (max) {
98
+ case rN:
99
+ h = ((gN - bN) / d + (gN < bN ? 6 : 0)) * 60;
100
+ break;
101
+ case gN:
102
+ h = ((bN - rN) / d + 2) * 60;
103
+ break;
104
+ default:
105
+ h = ((rN - gN) / d + 4) * 60;
106
+ }
107
+ }
108
+ return { h, s, l };
109
+ }
45
110
  function timerKey(status) {
46
111
  return status === "paused" ? "timer_paused" : "timer_running";
47
112
  }
@@ -59,6 +124,17 @@ export function resolveCellSymbol(cell, format) {
59
124
  const key = timerKey(cell.status);
60
125
  return format === "emoji" ? FIXED_EMOJI[key] : FIXED_ASCII[key];
61
126
  }
127
+ // S9 v0.2: planned 块带 tile 时优先用分类色方块,否则 fallback 到默认 🟦
128
+ if (cell.kind === "planned") {
129
+ if (format === "emoji") {
130
+ if (cell.tile && cell.tile.trim())
131
+ return cell.tile;
132
+ return FIXED_EMOJI.planned;
133
+ }
134
+ // ascii 用 category 首字母(如 [W]),否则默认 [#]
135
+ return cell.ascii ?? FIXED_ASCII.planned;
136
+ }
137
+ // unverified / empty
62
138
  const key = cell.kind;
63
139
  return format === "emoji" ? FIXED_EMOJI[key] : FIXED_ASCII[key];
64
140
  }
@@ -92,6 +168,16 @@ export function mergeTimelineCells(candidates) {
92
168
  function legendMeaning(symbol) {
93
169
  if (LEGEND_ZH[symbol])
94
170
  return LEGEND_ZH[symbol];
171
+ // S9 v0.2: 方块色 = 计划块(按分类上色)
172
+ if (symbol === "🟦" ||
173
+ symbol === "🟪" ||
174
+ symbol === "🟩" ||
175
+ symbol === "🟥" ||
176
+ symbol === "🟧" ||
177
+ symbol === "🟨" ||
178
+ symbol === "⬛") {
179
+ return "计划";
180
+ }
95
181
  // completed category emoji / ascii letter
96
182
  if (symbol.startsWith("[") && symbol.endsWith("]"))
97
183
  return "实绩";
@@ -206,26 +292,45 @@ export function renderTimelineCells(input) {
206
292
  const textLines = [];
207
293
  textLines.push(" 00 10 20 30 40 50");
208
294
  // ── 表格段(在前)──
295
+ // S9 v0.3: details 段按 symbol 分组紧凑展示
296
+ // 用户痛点:移动端窄屏,原格式 "{time} {symbol} {title} [planned]" 长标题折行后 [planned] 跑到第三行,诡异
297
+ // 新格式:同 symbol 合并到一行,用 · 分隔;移除 [status] 后缀(状态在矩阵方块色已表达)
209
298
  const details = input.details ?? [];
210
299
  if (details.length > 0) {
211
- textLines.push("📋 细项:");
300
+ textLines.push("📋 明细");
212
301
  const inWin = details.filter((d) => d.in_window);
213
302
  const outWin = details.filter((d) => !d.in_window);
214
- for (const d of inWin) {
215
- mark(d.symbol);
216
- textLines.push(`${d.time} ${d.symbol} ${d.title} [${d.status}]`);
303
+ // symbol 分组(保留 inWin 内的原始顺序)
304
+ const groupBySymbol = (list) => {
305
+ const groups = [];
306
+ const symbolToGroup = new Map();
307
+ for (const d of list) {
308
+ mark(d.symbol);
309
+ const entry = `${d.time} ${d.title}`;
310
+ const idx = symbolToGroup.get(d.symbol);
311
+ if (idx === undefined) {
312
+ symbolToGroup.set(d.symbol, groups.length);
313
+ groups.push({ symbol: d.symbol, entries: [entry] });
314
+ }
315
+ else {
316
+ groups[idx].entries.push(entry);
317
+ }
318
+ }
319
+ return groups;
320
+ };
321
+ for (const g of groupBySymbol(inWin)) {
322
+ textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
217
323
  }
218
324
  if (outWin.length > 0) {
219
- textLines.push("(窗口外↓)");
220
- for (const d of outWin) {
221
- mark(d.symbol);
222
- textLines.push(`${d.time} ${d.symbol} ${d.title} [${d.status}]`);
325
+ textLines.push("(窗口外)");
326
+ for (const g of groupBySymbol(outWin)) {
327
+ textLines.push(`${g.symbol} ${g.entries.join(" · ")}`);
223
328
  }
224
329
  }
225
330
  }
226
331
  else {
227
332
  // pressure E-6: details 空 → 给"该时段无安排"提示,不留空段
228
- textLines.push("📋 细项:该时段无安排");
333
+ textLines.push("📋 明细:该时段无安排");
229
334
  }
230
335
  // ── 矩阵段(在后,作为时间分布概览)──
231
336
  const pushEllipsis = () => {
@@ -250,6 +355,15 @@ export function renderTimelineCells(input) {
250
355
  }
251
356
  if (boundary)
252
357
  pushEllipsis();
358
+ // S9 v0.2: 末尾拼图例(按本次实际出现的色)
359
+ // 设计:图例短一行,只列实际出现过的符号;颜色块统一标"计划·X 类"
360
+ if (Object.keys(legend).length > 1) {
361
+ const legendParts = [];
362
+ for (const [sym, meaning] of Object.entries(legend)) {
363
+ legendParts.push(`${sym} ${meaning}`);
364
+ }
365
+ textLines.push(`图例:${legendParts.join(" · ")}`);
366
+ }
253
367
  return { rows, rendered_text: textLines.join("\n"), legend };
254
368
  }
255
369
  export function computeDefaultWindow(now) {
@@ -272,6 +386,18 @@ function cellFromBlock(b) {
272
386
  label: b.title,
273
387
  };
274
388
  }
389
+ // S9 v0.2: planned 块按分类上色(不再统一 🟦)
390
+ // unverified 保持 ❓ 不上色(状态优先于分类)
391
+ if (b.status === "planned") {
392
+ const ascii = categoryAsciiToken(b.category_name);
393
+ const tile = colorToTile(b.category_color);
394
+ return {
395
+ kind: "planned",
396
+ tile,
397
+ ascii,
398
+ label: b.title,
399
+ };
400
+ }
275
401
  return { kind: b.status, label: b.title };
276
402
  }
277
403
  function cellFromTimer(t) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",