libero-mcp 0.1.0

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.
@@ -0,0 +1,57 @@
1
+ export function nowUTC() {
2
+ return new Date().toISOString();
3
+ }
4
+ export function nowLocal(offsetMinutes) {
5
+ const d = new Date();
6
+ const offset = offsetMinutes ?? -d.getTimezoneOffset();
7
+ if (offset < -720 || offset > 720) {
8
+ throw new Error(`Timezone offset ${offset} out of range (must be ±720 minutes)`);
9
+ }
10
+ const sign = offset >= 0 ? "+" : "-";
11
+ const abs = Math.abs(offset);
12
+ const h = String(Math.floor(abs / 60)).padStart(2, "0");
13
+ const m = String(abs % 60).padStart(2, "0");
14
+ const utc = new Date(d.getTime() + d.getTimezoneOffset() * 60000);
15
+ const local = new Date(utc.getTime() + offset * 60000);
16
+ const iso = local.toISOString().replace("Z", "");
17
+ return `${iso}${sign}${h}:${m}`;
18
+ }
19
+ export function formatTime(isoString, format) {
20
+ const p = parseTime(isoString);
21
+ const pad = (n) => String(n).padStart(2, "0");
22
+ return format
23
+ .replace("YYYY", String(p.year))
24
+ .replace("MM", pad(p.month))
25
+ .replace("DD", pad(p.day))
26
+ .replace("HH", pad(p.hour))
27
+ .replace("mm", pad(p.minute))
28
+ .replace("ss", pad(p.second));
29
+ }
30
+ export function parseTime(isoString) {
31
+ // Match ISO 8601 with timezone offset
32
+ const m = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([+-]\d{2}:\d{2}|Z)$/);
33
+ if (!m) {
34
+ throw new Error(`Invalid ISO time string: ${isoString}`);
35
+ }
36
+ let offsetMinutes = 0;
37
+ if (m[7] === "Z") {
38
+ offsetMinutes = 0;
39
+ }
40
+ else {
41
+ const sign = m[7][0] === "+" ? 1 : -1;
42
+ const [h, min] = m[7].slice(1).split(":").map(Number);
43
+ offsetMinutes = sign * (h * 60 + min);
44
+ }
45
+ return {
46
+ year: parseInt(m[1]),
47
+ month: parseInt(m[2]),
48
+ day: parseInt(m[3]),
49
+ hour: parseInt(m[4]),
50
+ minute: parseInt(m[5]),
51
+ second: parseInt(m[6]),
52
+ offsetMinutes,
53
+ };
54
+ }
55
+ export function getLocalOffset() {
56
+ return -new Date().getTimezoneOffset();
57
+ }
@@ -0,0 +1,15 @@
1
+ export function createMatrixTools(repo) {
2
+ return {
3
+ async renderMatrix(filters) {
4
+ if (!filters.user_id) {
5
+ throw new Error("user_id 为必填参数");
6
+ }
7
+ if (filters.from !== undefined &&
8
+ filters.to !== undefined &&
9
+ filters.from > filters.to) {
10
+ throw new Error("开始时间不能晚于结束时间");
11
+ }
12
+ return repo.renderMatrix(filters);
13
+ },
14
+ };
15
+ }
@@ -0,0 +1,18 @@
1
+ /** Validate and delegate profile operations to the repository.
2
+ * ADR-0001: Dependency-injected — the caller provides the repo. */
3
+ export function createProfileTools(repo) {
4
+ return {
5
+ async get(userId) {
6
+ if (!userId) {
7
+ throw new Error("user_id 为必填参数");
8
+ }
9
+ return repo.getById(userId);
10
+ },
11
+ async upsert(input) {
12
+ if (!input.user_id) {
13
+ throw new Error("user_id 为必填参数");
14
+ }
15
+ return repo.upsert(input);
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,15 @@
1
+ /** In-memory CategoryRepo for testing / contract verification.
2
+ * Receives seed data via constructor — no global state, no getDb(). */
3
+ export class InMemoryCategoryRepo {
4
+ categories;
5
+ constructor(seed) {
6
+ this.categories = [...seed];
7
+ }
8
+ async getAll() {
9
+ // Return defensive copy so callers can't mutate internal state
10
+ return [...this.categories];
11
+ }
12
+ async getById(id) {
13
+ return this.categories.find((c) => c.id === id) ?? null;
14
+ }
15
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * SQLite-specific WHERE-clause builder for time-range filtering.
3
+ *
4
+ * Eliminates duplicated time-filter logic previously inlined in:
5
+ * timeblock.list / stats.summarize / stats.dailyBreakdown / matrix.renderMatrix
6
+ *
7
+ * ADR-0001: SQLite dialect helpers belong in this file, not in the generic repo interface.
8
+ */
9
+ /**
10
+ * Build WHERE sub-clauses for time-range filtering.
11
+ *
12
+ * Date mode (date is set):
13
+ * `column >= :dateT00:00:00 AND column < :dateT23:59:59`
14
+ * Range mode (date absent, from/to optional):
15
+ * `column >= :from` / `column <= :to`
16
+ * Neither:
17
+ * returns empty clauses.
18
+ */
19
+ export function buildTimeRangeWhere(input) {
20
+ const clauses = [];
21
+ const params = [];
22
+ if (input.date) {
23
+ // Single-day mode — timeblock.list legacy contract:
24
+ // start_time >= dateT00:00:00 AND start_time < dateT23:59:59
25
+ clauses.push(`${input.column} >= ?`, `${input.column} < ?`);
26
+ params.push(`${input.date}T00:00:00`, `${input.date}T23:59:59`);
27
+ }
28
+ else {
29
+ if (input.from) {
30
+ clauses.push(`${input.column} >= ?`);
31
+ params.push(input.from);
32
+ }
33
+ if (input.to) {
34
+ clauses.push(`${input.column} <= ?`);
35
+ params.push(input.to);
36
+ }
37
+ }
38
+ return { clauses, params };
39
+ }
@@ -0,0 +1,461 @@
1
+ import { buildTimeRangeWhere } from "./sqlite-helpers.js";
2
+ // ============================================================
3
+ // SqliteCategoryRepo (R1-R3)
4
+ // ============================================================
5
+ export class SqliteCategoryRepo {
6
+ db;
7
+ constructor(db) {
8
+ this.db = db;
9
+ }
10
+ async getAll() {
11
+ return this.db
12
+ .prepare("SELECT id, name, emoji, color FROM categories WHERE deleted_at IS NULL ORDER BY id")
13
+ .all();
14
+ }
15
+ async getById(id) {
16
+ const row = this.db
17
+ .prepare("SELECT id, name, emoji, color FROM categories WHERE id = ? AND deleted_at IS NULL")
18
+ .get(id);
19
+ return row ?? null;
20
+ }
21
+ }
22
+ // ============================================================
23
+ // SqliteTimeBlockRepo (R2)
24
+ // ============================================================
25
+ export class SqliteTimeBlockRepo {
26
+ db;
27
+ constructor(db) {
28
+ this.db = db;
29
+ }
30
+ async create(input) {
31
+ const id = crypto.randomUUID();
32
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
33
+ const durationMinutes = input.duration_minutes ??
34
+ Math.round((new Date(input.end_time).getTime() -
35
+ new Date(input.start_time).getTime()) /
36
+ 60000);
37
+ this.db
38
+ .prepare(`INSERT INTO time_blocks (id, user_id, title, category_id, start_time, end_time, duration_minutes, rating, rating_reason, notes, status, created_at, updated_at)
39
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
40
+ .run(id, input.user_id, input.title.trim(), input.category_id ?? null, input.start_time, input.end_time, durationMinutes, input.rating ?? null, input.rating_reason ?? null, input.notes ?? null, input.status ?? "completed", now, now);
41
+ return (await this.getById(id));
42
+ }
43
+ async getById(id) {
44
+ const row = this.db
45
+ .prepare("SELECT * FROM time_blocks WHERE id = ? AND deleted_at IS NULL")
46
+ .get(id);
47
+ return row ?? null;
48
+ }
49
+ async list(filters) {
50
+ const conditions = ["user_id = ?"];
51
+ const params = [filters.user_id];
52
+ if (!filters.include_deleted) {
53
+ conditions.push("deleted_at IS NULL");
54
+ }
55
+ const range = buildTimeRangeWhere({
56
+ column: "start_time",
57
+ date: filters.date,
58
+ from: filters.from,
59
+ to: filters.to,
60
+ });
61
+ conditions.push(...range.clauses);
62
+ params.push(...range.params);
63
+ if (filters.category_id) {
64
+ conditions.push("category_id = ?");
65
+ params.push(filters.category_id);
66
+ }
67
+ let limit = filters.limit ?? 50;
68
+ if (limit > 100)
69
+ limit = 100;
70
+ const offset = filters.offset ?? 0;
71
+ const sql = `SELECT * FROM time_blocks WHERE ${conditions.join(" AND ")} ORDER BY start_time DESC LIMIT ? OFFSET ?`;
72
+ params.push(limit, offset);
73
+ return this.db.prepare(sql).all(...params);
74
+ }
75
+ async update(id, input) {
76
+ const existing = this.db
77
+ .prepare("SELECT * FROM time_blocks WHERE id = ? AND deleted_at IS NULL")
78
+ .get(id);
79
+ if (!existing) {
80
+ throw new Error("时间块不存在");
81
+ }
82
+ const newStart = input.start_time ?? existing.start_time;
83
+ const newEnd = input.end_time ?? existing.end_time;
84
+ let newDuration;
85
+ if (input.duration_minutes !== undefined) {
86
+ newDuration = input.duration_minutes;
87
+ }
88
+ else if (input.start_time !== undefined ||
89
+ input.end_time !== undefined) {
90
+ newDuration = Math.round((new Date(newEnd).getTime() - new Date(newStart).getTime()) / 60000);
91
+ }
92
+ else {
93
+ newDuration = existing.duration_minutes;
94
+ }
95
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "") +
96
+ String(Math.floor(performance.now() * 100000) % 100000).padStart(5, "0");
97
+ this.db
98
+ .prepare(`UPDATE time_blocks SET
99
+ title = ?,
100
+ category_id = ?,
101
+ start_time = ?,
102
+ end_time = ?,
103
+ duration_minutes = ?,
104
+ rating = ?,
105
+ rating_reason = ?,
106
+ notes = ?,
107
+ status = ?,
108
+ updated_at = ?
109
+ WHERE id = ?`)
110
+ .run((input.title ?? existing.title).trim() || existing.title, input.category_id !== undefined
111
+ ? input.category_id
112
+ : existing.category_id, newStart, newEnd, newDuration, input.rating !== undefined ? input.rating : existing.rating, input.rating_reason !== undefined
113
+ ? input.rating_reason
114
+ : existing.rating_reason, input.notes !== undefined ? input.notes : existing.notes, input.status !== undefined ? input.status : existing.status, now, id);
115
+ return (await this.getById(id));
116
+ }
117
+ async remove(id) {
118
+ const exists = this.db
119
+ .prepare("SELECT id, deleted_at FROM time_blocks WHERE id = ?")
120
+ .get(id);
121
+ if (!exists) {
122
+ throw new Error("时间块不存在");
123
+ }
124
+ if (exists.deleted_at !== null) {
125
+ return; // already soft-deleted, idempotent
126
+ }
127
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
128
+ this.db
129
+ .prepare("UPDATE time_blocks SET deleted_at = ?, updated_at = ? WHERE id = ?")
130
+ .run(now, now, id);
131
+ }
132
+ async exists(id) {
133
+ const row = this.db
134
+ .prepare("SELECT id FROM time_blocks WHERE id = ?")
135
+ .get(id);
136
+ return !!row;
137
+ }
138
+ async userExists(userId) {
139
+ const row = this.db
140
+ .prepare("SELECT id FROM user_profile WHERE id = ? AND deleted_at IS NULL")
141
+ .get(userId);
142
+ return !!row;
143
+ }
144
+ async restore(id) {
145
+ const row = this.db
146
+ .prepare("SELECT id, deleted_at FROM time_blocks WHERE id = ?")
147
+ .get(id);
148
+ if (!row || row.deleted_at === null) {
149
+ throw new Error("时间块不存在或未被删除");
150
+ }
151
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
152
+ this.db
153
+ .prepare("UPDATE time_blocks SET deleted_at = NULL, status = 'completed', updated_at = ? WHERE id = ?")
154
+ .run(now, id);
155
+ return (await this.getById(id));
156
+ }
157
+ }
158
+ // ============================================================
159
+ // SqliteStatsRepo (R2 — stats + matrix)
160
+ // ============================================================
161
+ // -- pure helpers for renderMatrix (copied verbatim from matrix/index.ts) --
162
+ function median(values) {
163
+ if (values.length === 0)
164
+ return 0;
165
+ const sorted = [...values].sort((a, b) => a - b);
166
+ const mid = Math.floor(sorted.length / 2);
167
+ if (sorted.length % 2 === 1) {
168
+ return sorted[mid];
169
+ }
170
+ return (sorted[mid - 1] + sorted[mid]) / 2;
171
+ }
172
+ const QUADRANT_ORDER = {
173
+ "能耗黑洞": 0,
174
+ "可放弃": 1,
175
+ "保持区": 2,
176
+ "亮点区": 3,
177
+ };
178
+ function assignQuadrant(highTime, highRating) {
179
+ if (highTime && highRating)
180
+ return "保持区";
181
+ if (highTime && !highRating)
182
+ return "能耗黑洞";
183
+ if (!highTime && highRating)
184
+ return "亮点区";
185
+ return "可放弃";
186
+ }
187
+ export class SqliteStatsRepo {
188
+ db;
189
+ constructor(db) {
190
+ this.db = db;
191
+ }
192
+ async summarize(filters) {
193
+ const conditions = [
194
+ "tb.user_id = ?",
195
+ "tb.deleted_at IS NULL",
196
+ ];
197
+ const params = [filters.user_id];
198
+ const range = buildTimeRangeWhere({
199
+ column: "tb.start_time",
200
+ from: filters.from,
201
+ to: filters.to,
202
+ });
203
+ conditions.push(...range.clauses);
204
+ params.push(...range.params);
205
+ if (filters.category_id) {
206
+ conditions.push("tb.category_id = ?");
207
+ params.push(filters.category_id);
208
+ }
209
+ const rows = this.db
210
+ .prepare(`SELECT
211
+ COALESCE(tb.category_id, 'cat-other') as category_id,
212
+ COALESCE(c.name, '其他') as category_name,
213
+ COALESCE(c.emoji, '❓') as category_emoji,
214
+ SUM(tb.duration_minutes) as total_minutes,
215
+ COUNT(*) as record_count
216
+ FROM time_blocks tb
217
+ LEFT JOIN categories c ON tb.category_id = c.id
218
+ WHERE ${conditions.join(" AND ")}
219
+ GROUP BY COALESCE(tb.category_id, 'cat-other')
220
+ ORDER BY total_minutes DESC`)
221
+ .all(...params);
222
+ const grandTotal = rows.reduce((sum, r) => sum + r.total_minutes, 0);
223
+ return rows.map((r) => ({
224
+ category_id: r.category_id,
225
+ category_name: r.category_name,
226
+ category_emoji: r.category_emoji,
227
+ total_minutes: r.total_minutes,
228
+ percentage: grandTotal > 0
229
+ ? Math.round((r.total_minutes / grandTotal) * 1000) / 10
230
+ : 0,
231
+ record_count: r.record_count,
232
+ }));
233
+ }
234
+ async dailyBreakdown(filters) {
235
+ const conditions = [
236
+ "tb.user_id = ?",
237
+ "tb.deleted_at IS NULL",
238
+ ];
239
+ const params = [filters.user_id];
240
+ const range = buildTimeRangeWhere({
241
+ column: "tb.start_time",
242
+ from: filters.from,
243
+ to: filters.to,
244
+ });
245
+ conditions.push(...range.clauses);
246
+ params.push(...range.params);
247
+ if (filters.category_id) {
248
+ conditions.push("tb.category_id = ?");
249
+ params.push(filters.category_id);
250
+ }
251
+ const rows = this.db
252
+ .prepare(`SELECT
253
+ DATE(tb.start_time) as date,
254
+ COALESCE(tb.category_id, 'cat-other') as category_id,
255
+ COALESCE(c.name, '其他') as category_name,
256
+ COALESCE(c.emoji, '❓') as category_emoji,
257
+ SUM(tb.duration_minutes) as minutes
258
+ FROM time_blocks tb
259
+ LEFT JOIN categories c ON tb.category_id = c.id
260
+ WHERE ${conditions.join(" AND ")}
261
+ GROUP BY DATE(tb.start_time), COALESCE(tb.category_id, 'cat-other')
262
+ ORDER BY DATE(tb.start_time) ASC, minutes DESC`)
263
+ .all(...params);
264
+ // Aggregate into date-keyed map
265
+ const dayMap = new Map();
266
+ for (const row of rows) {
267
+ let day = dayMap.get(row.date);
268
+ if (!day) {
269
+ day = { date: row.date, total_minutes: 0, categories: [] };
270
+ dayMap.set(row.date, day);
271
+ }
272
+ day.categories.push({
273
+ category_id: row.category_id,
274
+ category_name: row.category_name,
275
+ category_emoji: row.category_emoji,
276
+ minutes: row.minutes,
277
+ });
278
+ day.total_minutes += row.minutes;
279
+ }
280
+ return [...dayMap.values()];
281
+ }
282
+ async renderMatrix(filters) {
283
+ const conditions = [
284
+ "tb.user_id = ?",
285
+ "tb.deleted_at IS NULL",
286
+ ];
287
+ const params = [filters.user_id];
288
+ const range = buildTimeRangeWhere({
289
+ column: "tb.start_time",
290
+ from: filters.from,
291
+ to: filters.to,
292
+ });
293
+ conditions.push(...range.clauses);
294
+ params.push(...range.params);
295
+ const rows = this.db
296
+ .prepare(`SELECT
297
+ COALESCE(tb.category_id, 'cat-other') as category_id,
298
+ COALESCE(c.name, '其他') as category_name,
299
+ COALESCE(c.emoji, '❓') as category_emoji,
300
+ SUM(tb.duration_minutes) as total_minutes,
301
+ AVG(tb.rating) as avg_rating,
302
+ COUNT(*) as record_count
303
+ FROM time_blocks tb
304
+ LEFT JOIN categories c ON tb.category_id = c.id
305
+ WHERE ${conditions.join(" AND ")}
306
+ GROUP BY COALESCE(tb.category_id, 'cat-other')
307
+ HAVING avg_rating IS NOT NULL`)
308
+ .all(...params);
309
+ if (rows.length === 0) {
310
+ return { median_minutes: 0, median_rating: 0, bubbles: [] };
311
+ }
312
+ const minutesValues = rows.map((r) => r.total_minutes);
313
+ const ratingValues = rows.map((r) => r.avg_rating);
314
+ const medianMinutes = median(minutesValues);
315
+ const medianRating = median(ratingValues);
316
+ const bubbles = rows
317
+ .map((r) => {
318
+ const highTime = r.total_minutes >= medianMinutes;
319
+ const highRating = r.avg_rating >= medianRating;
320
+ return {
321
+ category_id: r.category_id,
322
+ category_name: r.category_name,
323
+ category_emoji: r.category_emoji,
324
+ total_minutes: r.total_minutes,
325
+ avg_rating: Math.round(r.avg_rating * 10) / 10,
326
+ record_count: r.record_count,
327
+ quadrant: assignQuadrant(highTime, highRating),
328
+ };
329
+ })
330
+ .sort((a, b) => {
331
+ const orderDiff = QUADRANT_ORDER[a.quadrant] - QUADRANT_ORDER[b.quadrant];
332
+ if (orderDiff !== 0)
333
+ return orderDiff;
334
+ return b.total_minutes - a.total_minutes;
335
+ });
336
+ return {
337
+ median_minutes: medianMinutes,
338
+ median_rating: Math.round(medianRating * 10) / 10,
339
+ bubbles,
340
+ };
341
+ }
342
+ }
343
+ // ============================================================
344
+ // SqliteTimerRepo (R4 — timer persistence)
345
+ // ============================================================
346
+ export class SqliteTimerRepo {
347
+ db;
348
+ constructor(db) {
349
+ this.db = db;
350
+ }
351
+ async getActive(userId) {
352
+ const row = this.db
353
+ .prepare("SELECT * FROM timer_sessions WHERE user_id = ? AND status IN ('running', 'paused') LIMIT 1")
354
+ .get(userId);
355
+ return row ?? null;
356
+ }
357
+ async create(input) {
358
+ const id = crypto.randomUUID();
359
+ const now = new Date().toISOString();
360
+ this.db
361
+ .prepare(`INSERT INTO timer_sessions (id, user_id, event, category_id, status, started_at, accumulated_ms, created_at, updated_at)
362
+ VALUES (?, ?, ?, ?, 'running', ?, 0, ?, ?)`)
363
+ .run(id, input.user_id, input.event.trim(), input.category_id ?? null, input.started_at, now, now);
364
+ return this.db
365
+ .prepare("SELECT * FROM timer_sessions WHERE id = ?")
366
+ .get(id);
367
+ }
368
+ async update(id, patch) {
369
+ const now = new Date().toISOString();
370
+ const sets = ["updated_at = ?"];
371
+ const params = [now];
372
+ if (patch.status !== undefined) {
373
+ sets.push("status = ?");
374
+ params.push(patch.status);
375
+ }
376
+ if (patch.started_at !== undefined) {
377
+ sets.push("started_at = ?");
378
+ params.push(patch.started_at);
379
+ }
380
+ if (patch.accumulated_ms !== undefined) {
381
+ sets.push("accumulated_ms = ?");
382
+ params.push(patch.accumulated_ms);
383
+ }
384
+ if (patch.ended_at !== undefined) {
385
+ sets.push("ended_at = ?");
386
+ params.push(patch.ended_at);
387
+ }
388
+ params.push(id);
389
+ this.db
390
+ .prepare(`UPDATE timer_sessions SET ${sets.join(", ")} WHERE id = ?`)
391
+ .run(...params);
392
+ return this.db
393
+ .prepare("SELECT * FROM timer_sessions WHERE id = ?")
394
+ .get(id);
395
+ }
396
+ async userExists(userId) {
397
+ const row = this.db
398
+ .prepare("SELECT id FROM user_profile WHERE id = ? AND deleted_at IS NULL")
399
+ .get(userId);
400
+ return !!row;
401
+ }
402
+ }
403
+ // ============================================================
404
+ // SqliteProfileRepo (S13 — profile with structured settings)
405
+ // ============================================================
406
+ export class SqliteProfileRepo {
407
+ db;
408
+ constructor(db) {
409
+ this.db = db;
410
+ }
411
+ async getById(userId) {
412
+ const row = this.db
413
+ .prepare("SELECT id, display_name, expectations, settings, created_at, updated_at FROM user_profile WHERE id = ? AND deleted_at IS NULL")
414
+ .get(userId);
415
+ if (!row)
416
+ return null;
417
+ return {
418
+ id: row.id,
419
+ display_name: row.display_name,
420
+ expectations: row.expectations,
421
+ settings: row.settings != null ? JSON.parse(row.settings) : null,
422
+ created_at: row.created_at,
423
+ updated_at: row.updated_at,
424
+ };
425
+ }
426
+ async upsert(input) {
427
+ const existing = await this.getById(input.user_id);
428
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "");
429
+ if (existing) {
430
+ // Update: merge top-level fields, retain existing values for unset fields
431
+ const display_name = input.display_name !== undefined
432
+ ? input.display_name
433
+ : existing.display_name;
434
+ const expectations = input.expectations !== undefined
435
+ ? input.expectations
436
+ : existing.expectations;
437
+ // settings: whole-object replace (not deep merge), per S13 §4
438
+ const settings = input.settings !== undefined
439
+ ? JSON.stringify(input.settings)
440
+ : existing.settings != null
441
+ ? JSON.stringify(existing.settings)
442
+ : null;
443
+ this.db
444
+ .prepare(`UPDATE user_profile SET display_name = ?, expectations = ?, settings = ?, updated_at = ? WHERE id = ?`)
445
+ .run(display_name, expectations, settings, now, input.user_id);
446
+ }
447
+ else {
448
+ // Insert
449
+ const display_name = input.display_name ?? "";
450
+ const expectations = input.expectations ?? null;
451
+ const settings = input.settings !== undefined
452
+ ? JSON.stringify(input.settings)
453
+ : null;
454
+ this.db
455
+ .prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
456
+ VALUES (?, ?, ?, ?, ?, ?)`)
457
+ .run(input.user_id, display_name, expectations, settings, now, now);
458
+ }
459
+ return (await this.getById(input.user_id));
460
+ }
461
+ }
@@ -0,0 +1,4 @@
1
+ // ============================================================
2
+ // Domain types — shared by tools and repos
3
+ // ============================================================
4
+ export {};
@@ -0,0 +1,26 @@
1
+ export function createStatsTools(repo) {
2
+ return {
3
+ async summarize(filters) {
4
+ if (!filters.user_id) {
5
+ throw new Error("user_id 为必填参数");
6
+ }
7
+ if (filters.from !== undefined &&
8
+ filters.to !== undefined &&
9
+ filters.from > filters.to) {
10
+ throw new Error("开始时间不能晚于结束时间");
11
+ }
12
+ return repo.summarize(filters);
13
+ },
14
+ async dailyBreakdown(filters) {
15
+ if (!filters.user_id) {
16
+ throw new Error("user_id 为必填参数");
17
+ }
18
+ if (filters.from !== undefined &&
19
+ filters.to !== undefined &&
20
+ filters.from > filters.to) {
21
+ throw new Error("开始时间不能晚于结束时间");
22
+ }
23
+ return repo.dailyBreakdown(filters);
24
+ },
25
+ };
26
+ }