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.
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/data/migrations/001_initial_schema.sql +43 -0
- package/data/migrations/002_seed_categories.sql +18 -0
- package/data/migrations/003_add_rating.sql +4 -0
- package/data/migrations/004_timer_sessions.sql +21 -0
- package/data/migrations/005_profile_settings.sql +5 -0
- package/data/migrations/006_add_timeblock_status.sql +1 -0
- package/data/schema.sql +43 -0
- package/dist/data/db.js +61 -0
- package/dist/data/migrations/001_initial_schema.sql +43 -0
- package/dist/data/migrations/002_seed_categories.sql +18 -0
- package/dist/data/migrations/003_add_rating.sql +4 -0
- package/dist/data/migrations/004_timer_sessions.sql +21 -0
- package/dist/data/migrations/005_profile_settings.sql +5 -0
- package/dist/data/migrations/006_add_timeblock_status.sql +1 -0
- package/dist/data/schema.sql +43 -0
- package/dist/mcp/server-core.js +182 -0
- package/dist/mcp/server.js +30 -0
- package/dist/mcp/src/categories/index.js +12 -0
- package/dist/mcp/src/clock/index.js +57 -0
- package/dist/mcp/src/matrix/index.js +15 -0
- package/dist/mcp/src/profile/index.js +18 -0
- package/dist/mcp/src/repo/in-memory.js +15 -0
- package/dist/mcp/src/repo/sqlite-helpers.js +39 -0
- package/dist/mcp/src/repo/sqlite.js +461 -0
- package/dist/mcp/src/repo/types.js +4 -0
- package/dist/mcp/src/stats/index.js +26 -0
- package/dist/mcp/src/timeblock/index.js +60 -0
- package/dist/mcp/src/timer/index.js +116 -0
- package/package.json +40 -0
|
@@ -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,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
|
+
}
|