libero3 0.3.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/COLD_START.md +31 -0
- package/LICENSE +21 -0
- package/README.md +43 -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/migrations/007_reminders.sql +17 -0
- package/data/migrations/008_timer_target_ms.sql +5 -0
- package/data/migrations/009_tasks.sql +22 -0
- package/data/migrations/010_timer_task_id.sql +4 -0
- package/data/migrations/011_profile_default_confirmed.sql +4 -0
- package/data/migrations/012_identity_tables.sql +68 -0
- package/data/migrations/013_identity_managed_by.sql +3 -0
- package/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/data/migrations/015_timeblock_asked_at.sql +5 -0
- package/data/migrations/016_intent_columns.sql +8 -0
- package/data/migrations/017_ap_wallet.sql +25 -0
- package/data/migrations/018_ap_rewards.sql +21 -0
- package/data/migrations/019_ap_bounty.sql +10 -0
- package/data/migrations/020_chat_turns.sql +10 -0
- package/data/migrations/021_portrait.sql +4 -0
- package/data/migrations/022_family_emoji_single.sql +3 -0
- package/data/migrations/023_ap_balance_view_log.sql +9 -0
- package/data/schema.sql +75 -0
- package/dist/data/db.js +83 -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/migrations/007_reminders.sql +17 -0
- package/dist/data/migrations/008_timer_target_ms.sql +5 -0
- package/dist/data/migrations/009_tasks.sql +22 -0
- package/dist/data/migrations/010_timer_task_id.sql +4 -0
- package/dist/data/migrations/011_profile_default_confirmed.sql +4 -0
- package/dist/data/migrations/012_identity_tables.sql +68 -0
- package/dist/data/migrations/013_identity_managed_by.sql +3 -0
- package/dist/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/dist/data/migrations/015_timeblock_asked_at.sql +5 -0
- package/dist/data/migrations/016_intent_columns.sql +8 -0
- package/dist/data/migrations/017_ap_wallet.sql +25 -0
- package/dist/data/migrations/018_ap_rewards.sql +21 -0
- package/dist/data/migrations/019_ap_bounty.sql +10 -0
- package/dist/data/migrations/020_chat_turns.sql +10 -0
- package/dist/data/migrations/021_portrait.sql +4 -0
- package/dist/data/migrations/022_family_emoji_single.sql +3 -0
- package/dist/data/migrations/023_ap_balance_view_log.sql +9 -0
- package/dist/data/schema.sql +75 -0
- package/dist/knowledge/_registry/dimensions.yaml +10 -0
- package/dist/knowledge/_registry/personas.yaml +7 -0
- package/dist/knowledge/_schema/analysis-model.schema.js +32 -0
- package/dist/knowledge/_schema/entry.schema.js +45 -0
- package/dist/knowledge/_schema/persona.schema.js +23 -0
- package/dist/knowledge/_schema/scenario-pack.schema.js +29 -0
- package/dist/knowledge/analysis-models/review-default.yaml +16 -0
- package/dist/knowledge/analysis-models/review-summer-plan.yaml +15 -0
- package/dist/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
- package/dist/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
- package/dist/knowledge/brain-science/brain-env-engineering.yaml +22 -0
- package/dist/knowledge/brain-science/brain-extended-mind.yaml +22 -0
- package/dist/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
- package/dist/knowledge/brain-science/brain-peak-end.yaml +29 -0
- package/dist/knowledge/brain-science/brain-pomodoro.yaml +31 -0
- package/dist/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
- package/dist/knowledge/brain-science/brain-single-variable.yaml +15 -0
- package/dist/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
- package/dist/knowledge/energy/energy-active-recovery.yaml +22 -0
- package/dist/knowledge/energy/energy-deliberate-rest.yaml +22 -0
- package/dist/knowledge/energy/energy-exercise-embedded.yaml +22 -0
- package/dist/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
- package/dist/knowledge/energy/energy-four-energy-radar.yaml +22 -0
- package/dist/knowledge/energy/energy-intensity-recovery.yaml +22 -0
- package/dist/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
- package/dist/knowledge/src/repo/fs-yaml.js +252 -0
- package/dist/knowledge/src/repo/types.js +1 -0
- package/dist/knowledge/time/time-batch-windows.yaml +22 -0
- package/dist/knowledge/time/time-conscious-abandon.yaml +22 -0
- package/dist/knowledge/time/time-deep-work-block.yaml +22 -0
- package/dist/knowledge/time/time-finite-commitment.yaml +22 -0
- package/dist/knowledge/time/time-forced-whitespace.yaml +22 -0
- package/dist/knowledge/time/time-gtd-next-action.yaml +22 -0
- package/dist/knowledge/time/time-three-tier-list.yaml +22 -0
- package/dist/mcp/cold-start.js +43 -0
- package/dist/mcp/contract/v1/rest.js +56 -0
- package/dist/mcp/rest-server.js +52 -0
- package/dist/mcp/server-core.js +1334 -0
- package/dist/mcp/server.js +34 -0
- package/dist/mcp/src/ap/index.js +14 -0
- package/dist/mcp/src/ap/rewards.js +44 -0
- package/dist/mcp/src/categories/index.js +12 -0
- package/dist/mcp/src/chat/from-env.js +111 -0
- package/dist/mcp/src/chat/history.js +35 -0
- package/dist/mcp/src/chat/loop.js +131 -0
- package/dist/mcp/src/chat/portrait.js +133 -0
- package/dist/mcp/src/chat/system-prompt.js +23 -0
- package/dist/mcp/src/chat/tool-names.js +27 -0
- package/dist/mcp/src/chat/tool-schema.js +20 -0
- package/dist/mcp/src/chat/types.js +1 -0
- package/dist/mcp/src/clock/index.js +78 -0
- package/dist/mcp/src/coachmod/index.js +135 -0
- package/dist/mcp/src/host-adapters/hermes-cron.js +185 -0
- package/dist/mcp/src/host-adapters/index.js +61 -0
- package/dist/mcp/src/host-adapters/types.js +7 -0
- package/dist/mcp/src/identity/equivalent-ids.js +41 -0
- package/dist/mcp/src/identity/in-memory.js +100 -0
- package/dist/mcp/src/identity/resolve.js +60 -0
- package/dist/mcp/src/identity/types.js +4 -0
- package/dist/mcp/src/matrix/index.js +15 -0
- package/dist/mcp/src/profile/confirm-default.js +48 -0
- package/dist/mcp/src/profile/index.js +298 -0
- package/dist/mcp/src/profile-confirm.js +28 -0
- package/dist/mcp/src/profile-helpers.js +109 -0
- package/dist/mcp/src/reminder/hermes-channel.js +40 -0
- package/dist/mcp/src/reminder/index.js +70 -0
- package/dist/mcp/src/reminder/push-channel.js +10 -0
- package/dist/mcp/src/repo/factory.js +34 -0
- package/dist/mcp/src/repo/in-memory.js +469 -0
- package/dist/mcp/src/repo/sqlite-ap.js +188 -0
- package/dist/mcp/src/repo/sqlite-helpers.js +39 -0
- package/dist/mcp/src/repo/sqlite-identity.js +129 -0
- package/dist/mcp/src/repo/sqlite.js +754 -0
- package/dist/mcp/src/repo/types.js +4 -0
- package/dist/mcp/src/rest/app.js +121 -0
- package/dist/mcp/src/rest/invoke-tool.js +98 -0
- package/dist/mcp/src/rest/short-token.js +38 -0
- package/dist/mcp/src/rest/tokens.js +55 -0
- package/dist/mcp/src/scheduler/index.js +319 -0
- package/dist/mcp/src/stats/index.js +29 -0
- package/dist/mcp/src/stickiness/report.js +174 -0
- package/dist/mcp/src/stickiness/types.js +1 -0
- package/dist/mcp/src/task/index.js +412 -0
- package/dist/mcp/src/timeblock/index.js +206 -0
- package/dist/mcp/src/timeline/index.js +705 -0
- package/dist/mcp/src/timer/index.js +234 -0
- package/dist/mcp/src/user-id-resolver.js +30 -0
- package/dist/mcp/src/weixin/app.js +97 -0
- package/dist/mcp/src/weixin/custom-send.js +82 -0
- package/dist/mcp/src/weixin/handle-text.js +49 -0
- package/dist/mcp/src/weixin/reminder-loop.js +39 -0
- package/dist/mcp/src/weixin/signature.js +13 -0
- package/dist/mcp/src/weixin/tencent-asr.js +101 -0
- package/dist/mcp/src/weixin/xml.js +37 -0
- package/dist/mcp/weixin-server.js +158 -0
- package/knowledge/_registry/dimensions.yaml +10 -0
- package/knowledge/_registry/personas.yaml +7 -0
- package/knowledge/_schema/analysis-model.schema.ts +36 -0
- package/knowledge/_schema/entry.schema.ts +50 -0
- package/knowledge/_schema/persona.schema.ts +30 -0
- package/knowledge/_schema/scenario-pack.schema.ts +33 -0
- package/knowledge/analysis-models/review-default.yaml +16 -0
- package/knowledge/analysis-models/review-summer-plan.yaml +15 -0
- package/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
- package/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
- package/knowledge/brain-science/brain-env-engineering.yaml +22 -0
- package/knowledge/brain-science/brain-extended-mind.yaml +22 -0
- package/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
- package/knowledge/brain-science/brain-peak-end.yaml +29 -0
- package/knowledge/brain-science/brain-pomodoro.yaml +31 -0
- package/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
- package/knowledge/brain-science/brain-single-variable.yaml +15 -0
- package/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
- package/knowledge/energy/energy-active-recovery.yaml +22 -0
- package/knowledge/energy/energy-deliberate-rest.yaml +22 -0
- package/knowledge/energy/energy-exercise-embedded.yaml +22 -0
- package/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
- package/knowledge/energy/energy-four-energy-radar.yaml +22 -0
- package/knowledge/energy/energy-intensity-recovery.yaml +22 -0
- package/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
- package/knowledge/src/repo/fs-yaml.ts +282 -0
- package/knowledge/src/repo/types.ts +24 -0
- package/knowledge/tests/fixtures/base/_registry/dimensions.yaml +4 -0
- package/knowledge/tests/fixtures/base/time/valid-entry.yaml +24 -0
- package/knowledge/tests/fixtures/dup/_registry/dimensions.yaml +4 -0
- package/knowledge/tests/fixtures/dup/time/entry-a.yaml +13 -0
- package/knowledge/tests/fixtures/dup/time/entry-b.yaml +13 -0
- package/knowledge/tests/fixtures/unregistered/_registry/dimensions.yaml +7 -0
- package/knowledge/tests/fixtures/unregistered/time/anxiety-entry.yaml +13 -0
- package/knowledge/tests/repo/fs-yaml-knowledge-repo.test.ts +333 -0
- package/knowledge/time/time-batch-windows.yaml +22 -0
- package/knowledge/time/time-conscious-abandon.yaml +22 -0
- package/knowledge/time/time-deep-work-block.yaml +22 -0
- package/knowledge/time/time-finite-commitment.yaml +22 -0
- package/knowledge/time/time-forced-whitespace.yaml +22 -0
- package/knowledge/time/time-gtd-next-action.yaml +22 -0
- package/knowledge/time/time-three-tier-list.yaml +22 -0
- package/knowledge/validate.ts +50 -0
- package/package.json +76 -0
- package/scripts/postinstall-cold-start.js +45 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { buildTimeRangeWhere } from "./sqlite-helpers.js";
|
|
2
|
+
import { buildTimelineFromEvents, computeDefaultWindow, } from "../timeline/index.js";
|
|
3
|
+
import { effectiveUserId } from "../profile-helpers.js";
|
|
4
|
+
import { userIdIn } from "../identity/equivalent-ids.js";
|
|
5
|
+
function profileExistsForUser(db, userId) {
|
|
6
|
+
const uid = userIdIn(db, "id", userId);
|
|
7
|
+
const row = db
|
|
8
|
+
.prepare(`SELECT id FROM user_profile WHERE ${uid.clause} AND deleted_at IS NULL`)
|
|
9
|
+
.get(...uid.params);
|
|
10
|
+
return !!row;
|
|
11
|
+
}
|
|
12
|
+
// ============================================================
|
|
13
|
+
// SqliteCategoryRepo (R1-R3)
|
|
14
|
+
// ============================================================
|
|
15
|
+
export class SqliteCategoryRepo {
|
|
16
|
+
db;
|
|
17
|
+
constructor(db) {
|
|
18
|
+
this.db = db;
|
|
19
|
+
}
|
|
20
|
+
async getAll() {
|
|
21
|
+
return this.db
|
|
22
|
+
.prepare("SELECT id, name, emoji, color FROM categories WHERE deleted_at IS NULL ORDER BY id")
|
|
23
|
+
.all();
|
|
24
|
+
}
|
|
25
|
+
async getById(id) {
|
|
26
|
+
const row = this.db
|
|
27
|
+
.prepare("SELECT id, name, emoji, color FROM categories WHERE id = ? AND deleted_at IS NULL")
|
|
28
|
+
.get(id);
|
|
29
|
+
return row ?? null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ============================================================
|
|
33
|
+
// SqliteTimeBlockRepo (R2)
|
|
34
|
+
// ============================================================
|
|
35
|
+
export class SqliteTimeBlockRepo {
|
|
36
|
+
db;
|
|
37
|
+
constructor(db) {
|
|
38
|
+
this.db = db;
|
|
39
|
+
}
|
|
40
|
+
async create(input) {
|
|
41
|
+
const id = crypto.randomUUID();
|
|
42
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
43
|
+
const durationMinutes = input.duration_minutes ??
|
|
44
|
+
Math.round((new Date(input.end_time).getTime() -
|
|
45
|
+
new Date(input.start_time).getTime()) /
|
|
46
|
+
60000);
|
|
47
|
+
this.db
|
|
48
|
+
.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)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
50
|
+
.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);
|
|
51
|
+
return (await this.getById(id));
|
|
52
|
+
}
|
|
53
|
+
async getById(id) {
|
|
54
|
+
const row = this.db
|
|
55
|
+
.prepare("SELECT * FROM time_blocks WHERE id = ? AND deleted_at IS NULL")
|
|
56
|
+
.get(id);
|
|
57
|
+
return row ?? null;
|
|
58
|
+
}
|
|
59
|
+
async list(filters) {
|
|
60
|
+
const uid = userIdIn(this.db, "user_id", filters.user_id);
|
|
61
|
+
const conditions = [uid.clause];
|
|
62
|
+
const params = [...uid.params];
|
|
63
|
+
if (!filters.include_deleted) {
|
|
64
|
+
conditions.push("deleted_at IS NULL");
|
|
65
|
+
}
|
|
66
|
+
const range = buildTimeRangeWhere({
|
|
67
|
+
column: "start_time",
|
|
68
|
+
date: filters.date,
|
|
69
|
+
from: filters.from,
|
|
70
|
+
to: filters.to,
|
|
71
|
+
});
|
|
72
|
+
conditions.push(...range.clauses);
|
|
73
|
+
params.push(...range.params);
|
|
74
|
+
if (filters.category_id) {
|
|
75
|
+
conditions.push("category_id = ?");
|
|
76
|
+
params.push(filters.category_id);
|
|
77
|
+
}
|
|
78
|
+
if (filters.status) {
|
|
79
|
+
conditions.push("status = ?");
|
|
80
|
+
params.push(filters.status);
|
|
81
|
+
}
|
|
82
|
+
let limit = filters.limit ?? 50;
|
|
83
|
+
if (limit > 100)
|
|
84
|
+
limit = 100;
|
|
85
|
+
const offset = filters.offset ?? 0;
|
|
86
|
+
const sql = `SELECT * FROM time_blocks WHERE ${conditions.join(" AND ")} ORDER BY start_time DESC LIMIT ? OFFSET ?`;
|
|
87
|
+
params.push(limit, offset);
|
|
88
|
+
return this.db.prepare(sql).all(...params);
|
|
89
|
+
}
|
|
90
|
+
async update(id, input) {
|
|
91
|
+
const existing = this.db
|
|
92
|
+
.prepare("SELECT * FROM time_blocks WHERE id = ? AND deleted_at IS NULL")
|
|
93
|
+
.get(id);
|
|
94
|
+
if (!existing) {
|
|
95
|
+
throw new Error("时间块不存在");
|
|
96
|
+
}
|
|
97
|
+
const newStart = input.start_time ?? existing.start_time;
|
|
98
|
+
const newEnd = input.end_time ?? existing.end_time;
|
|
99
|
+
let newDuration;
|
|
100
|
+
if (input.duration_minutes !== undefined) {
|
|
101
|
+
newDuration = input.duration_minutes;
|
|
102
|
+
}
|
|
103
|
+
else if (input.start_time !== undefined ||
|
|
104
|
+
input.end_time !== undefined) {
|
|
105
|
+
newDuration = Math.round((new Date(newEnd).getTime() - new Date(newStart).getTime()) / 60000);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
newDuration = existing.duration_minutes;
|
|
109
|
+
}
|
|
110
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "") +
|
|
111
|
+
String(Math.floor(performance.now() * 100000) % 100000).padStart(5, "0");
|
|
112
|
+
this.db
|
|
113
|
+
.prepare(`UPDATE time_blocks SET
|
|
114
|
+
title = ?,
|
|
115
|
+
category_id = ?,
|
|
116
|
+
start_time = ?,
|
|
117
|
+
end_time = ?,
|
|
118
|
+
duration_minutes = ?,
|
|
119
|
+
rating = ?,
|
|
120
|
+
rating_reason = ?,
|
|
121
|
+
notes = ?,
|
|
122
|
+
status = ?,
|
|
123
|
+
updated_at = ?
|
|
124
|
+
WHERE id = ?`)
|
|
125
|
+
.run((input.title ?? existing.title).trim() || existing.title, input.category_id !== undefined
|
|
126
|
+
? input.category_id
|
|
127
|
+
: existing.category_id, newStart, newEnd, newDuration, input.rating !== undefined ? input.rating : existing.rating, input.rating_reason !== undefined
|
|
128
|
+
? input.rating_reason
|
|
129
|
+
: existing.rating_reason, input.notes !== undefined ? input.notes : existing.notes, input.status !== undefined ? input.status : existing.status, now, id);
|
|
130
|
+
return (await this.getById(id));
|
|
131
|
+
}
|
|
132
|
+
async remove(id) {
|
|
133
|
+
const exists = this.db
|
|
134
|
+
.prepare("SELECT id, deleted_at FROM time_blocks WHERE id = ?")
|
|
135
|
+
.get(id);
|
|
136
|
+
if (!exists) {
|
|
137
|
+
throw new Error("时间块不存在");
|
|
138
|
+
}
|
|
139
|
+
if (exists.deleted_at !== null) {
|
|
140
|
+
return; // already soft-deleted, idempotent
|
|
141
|
+
}
|
|
142
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
143
|
+
this.db
|
|
144
|
+
.prepare("UPDATE time_blocks SET deleted_at = ?, updated_at = ? WHERE id = ?")
|
|
145
|
+
.run(now, now, id);
|
|
146
|
+
}
|
|
147
|
+
async exists(id) {
|
|
148
|
+
const row = this.db
|
|
149
|
+
.prepare("SELECT id FROM time_blocks WHERE id = ?")
|
|
150
|
+
.get(id);
|
|
151
|
+
return !!row;
|
|
152
|
+
}
|
|
153
|
+
async userExists(userId) {
|
|
154
|
+
return profileExistsForUser(this.db, userId);
|
|
155
|
+
}
|
|
156
|
+
/** S48(2026-08-11):标记扫描追问已发。幂等保护:每条只被 cron 主动问一次。返回受影响行数。 */
|
|
157
|
+
async markAsked(ids, askedAt) {
|
|
158
|
+
if (!ids || ids.length === 0)
|
|
159
|
+
return 0;
|
|
160
|
+
const ts = askedAt ?? new Date().toISOString();
|
|
161
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
162
|
+
const result = this.db
|
|
163
|
+
.prepare(`UPDATE time_blocks SET asked_at = ?, updated_at = ?
|
|
164
|
+
WHERE id IN (${placeholders}) AND deleted_at IS NULL`)
|
|
165
|
+
.run(ts, ts, ...ids);
|
|
166
|
+
return result.changes;
|
|
167
|
+
}
|
|
168
|
+
async restore(id) {
|
|
169
|
+
const row = this.db
|
|
170
|
+
.prepare("SELECT id, deleted_at FROM time_blocks WHERE id = ?")
|
|
171
|
+
.get(id);
|
|
172
|
+
if (!row || row.deleted_at === null) {
|
|
173
|
+
throw new Error("时间块不存在或未被删除");
|
|
174
|
+
}
|
|
175
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
176
|
+
this.db
|
|
177
|
+
.prepare(
|
|
178
|
+
// MV-COACH-3 Slice 4: restore 后状态 = unverified(不知道用户执行了没,等用户核实才升 completed)。
|
|
179
|
+
// 原 v0 是 'completed'(当时没状态机概念,恢复即默认已完成)。新模型下 completed 必须用户确认。
|
|
180
|
+
"UPDATE time_blocks SET deleted_at = NULL, status = 'unverified', updated_at = ? WHERE id = ?")
|
|
181
|
+
.run(now, id);
|
|
182
|
+
return (await this.getById(id));
|
|
183
|
+
}
|
|
184
|
+
async findOverlapping(input) {
|
|
185
|
+
// ⚠️ 时区安全:用 SQLite datetime() 规范化所有时间字段后再比较。
|
|
186
|
+
// datetime() 能解析多种 ISO 格式(含 +08:00 / Z),输出统一 'YYYY-MM-DD HH:MM:SS' UTC。
|
|
187
|
+
// 不能用裸字符串比较('2026-08-07T09:00:00+08:00' vs '2026-08-07T01:00:00Z' 字符串比较会误判)。
|
|
188
|
+
const uid = userIdIn(this.db, "user_id", input.user_id);
|
|
189
|
+
return this.db
|
|
190
|
+
.prepare(`SELECT * FROM time_blocks
|
|
191
|
+
WHERE ${uid.clause} AND status = 'planned' AND deleted_at IS NULL
|
|
192
|
+
AND datetime(start_time) < datetime(?)
|
|
193
|
+
AND datetime(end_time) > datetime(?)
|
|
194
|
+
ORDER BY start_time`)
|
|
195
|
+
.all(...uid.params, input.end, input.start);
|
|
196
|
+
}
|
|
197
|
+
async findExactWindow(input) {
|
|
198
|
+
const uid = userIdIn(this.db, "user_id", input.user_id);
|
|
199
|
+
const row = this.db
|
|
200
|
+
.prepare(`SELECT * FROM time_blocks
|
|
201
|
+
WHERE ${uid.clause} AND deleted_at IS NULL
|
|
202
|
+
AND title = ? AND status = ?
|
|
203
|
+
AND datetime(start_time) = datetime(?)
|
|
204
|
+
AND datetime(end_time) = datetime(?)
|
|
205
|
+
LIMIT 1`)
|
|
206
|
+
.get(...uid.params, input.title.trim(), input.status, input.start, input.end);
|
|
207
|
+
return row ?? null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// ============================================================
|
|
211
|
+
// SqliteStatsRepo (R2 — stats + matrix)
|
|
212
|
+
// ============================================================
|
|
213
|
+
// -- pure helpers for renderMatrix (copied verbatim from matrix/index.ts) --
|
|
214
|
+
function median(values) {
|
|
215
|
+
if (values.length === 0)
|
|
216
|
+
return 0;
|
|
217
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
218
|
+
const mid = Math.floor(sorted.length / 2);
|
|
219
|
+
if (sorted.length % 2 === 1) {
|
|
220
|
+
return sorted[mid];
|
|
221
|
+
}
|
|
222
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
223
|
+
}
|
|
224
|
+
const QUADRANT_ORDER = {
|
|
225
|
+
"能耗黑洞": 0,
|
|
226
|
+
"可放弃": 1,
|
|
227
|
+
"保持区": 2,
|
|
228
|
+
"亮点区": 3,
|
|
229
|
+
};
|
|
230
|
+
function assignQuadrant(highTime, highRating) {
|
|
231
|
+
if (highTime && highRating)
|
|
232
|
+
return "保持区";
|
|
233
|
+
if (highTime && !highRating)
|
|
234
|
+
return "能耗黑洞";
|
|
235
|
+
if (!highTime && highRating)
|
|
236
|
+
return "亮点区";
|
|
237
|
+
return "可放弃";
|
|
238
|
+
}
|
|
239
|
+
export class SqliteStatsRepo {
|
|
240
|
+
db;
|
|
241
|
+
constructor(db) {
|
|
242
|
+
this.db = db;
|
|
243
|
+
}
|
|
244
|
+
async summarize(filters) {
|
|
245
|
+
const uid = userIdIn(this.db, "tb.user_id", filters.user_id);
|
|
246
|
+
const conditions = [
|
|
247
|
+
uid.clause,
|
|
248
|
+
"tb.deleted_at IS NULL",
|
|
249
|
+
// MV-COACH-3 Slice 4: 只统计实绩。planned/unverified 不算(用户主权模型:
|
|
250
|
+
// 只有用户确认执行过的才进 stats,避免"建了日程就虚增统计")。
|
|
251
|
+
"tb.status = 'completed'",
|
|
252
|
+
];
|
|
253
|
+
const params = [...uid.params];
|
|
254
|
+
const range = buildTimeRangeWhere({
|
|
255
|
+
column: "tb.start_time",
|
|
256
|
+
from: filters.from,
|
|
257
|
+
to: filters.to,
|
|
258
|
+
});
|
|
259
|
+
conditions.push(...range.clauses);
|
|
260
|
+
params.push(...range.params);
|
|
261
|
+
if (filters.category_id) {
|
|
262
|
+
conditions.push("tb.category_id = ?");
|
|
263
|
+
params.push(filters.category_id);
|
|
264
|
+
}
|
|
265
|
+
const rows = this.db
|
|
266
|
+
.prepare(`SELECT
|
|
267
|
+
COALESCE(tb.category_id, 'cat-other') as category_id,
|
|
268
|
+
COALESCE(c.name, '其他') as category_name,
|
|
269
|
+
COALESCE(c.emoji, '❓') as category_emoji,
|
|
270
|
+
SUM(tb.duration_minutes) as total_minutes,
|
|
271
|
+
COUNT(*) as record_count
|
|
272
|
+
FROM time_blocks tb
|
|
273
|
+
LEFT JOIN categories c ON tb.category_id = c.id
|
|
274
|
+
WHERE ${conditions.join(" AND ")}
|
|
275
|
+
GROUP BY COALESCE(tb.category_id, 'cat-other')
|
|
276
|
+
ORDER BY total_minutes DESC`)
|
|
277
|
+
.all(...params);
|
|
278
|
+
const grandTotal = rows.reduce((sum, r) => sum + r.total_minutes, 0);
|
|
279
|
+
return rows.map((r) => ({
|
|
280
|
+
category_id: r.category_id,
|
|
281
|
+
category_name: r.category_name,
|
|
282
|
+
category_emoji: r.category_emoji,
|
|
283
|
+
total_minutes: r.total_minutes,
|
|
284
|
+
percentage: grandTotal > 0
|
|
285
|
+
? Math.round((r.total_minutes / grandTotal) * 1000) / 10
|
|
286
|
+
: 0,
|
|
287
|
+
record_count: r.record_count,
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
async dailyBreakdown(filters) {
|
|
291
|
+
const uid = userIdIn(this.db, "tb.user_id", filters.user_id);
|
|
292
|
+
const conditions = [
|
|
293
|
+
uid.clause,
|
|
294
|
+
"tb.deleted_at IS NULL",
|
|
295
|
+
// MV-COACH-3 Slice 4: 只统计实绩。planned/unverified 不算(用户主权模型:
|
|
296
|
+
// 只有用户确认执行过的才进 stats,避免"建了日程就虚增统计")。
|
|
297
|
+
"tb.status = 'completed'",
|
|
298
|
+
];
|
|
299
|
+
const params = [...uid.params];
|
|
300
|
+
const range = buildTimeRangeWhere({
|
|
301
|
+
column: "tb.start_time",
|
|
302
|
+
from: filters.from,
|
|
303
|
+
to: filters.to,
|
|
304
|
+
});
|
|
305
|
+
conditions.push(...range.clauses);
|
|
306
|
+
params.push(...range.params);
|
|
307
|
+
if (filters.category_id) {
|
|
308
|
+
conditions.push("tb.category_id = ?");
|
|
309
|
+
params.push(filters.category_id);
|
|
310
|
+
}
|
|
311
|
+
const rows = this.db
|
|
312
|
+
.prepare(`SELECT
|
|
313
|
+
DATE(tb.start_time) as date,
|
|
314
|
+
COALESCE(tb.category_id, 'cat-other') as category_id,
|
|
315
|
+
COALESCE(c.name, '其他') as category_name,
|
|
316
|
+
COALESCE(c.emoji, '❓') as category_emoji,
|
|
317
|
+
SUM(tb.duration_minutes) as minutes
|
|
318
|
+
FROM time_blocks tb
|
|
319
|
+
LEFT JOIN categories c ON tb.category_id = c.id
|
|
320
|
+
WHERE ${conditions.join(" AND ")}
|
|
321
|
+
GROUP BY DATE(tb.start_time), COALESCE(tb.category_id, 'cat-other')
|
|
322
|
+
ORDER BY DATE(tb.start_time) ASC, minutes DESC`)
|
|
323
|
+
.all(...params);
|
|
324
|
+
// Aggregate into date-keyed map
|
|
325
|
+
const dayMap = new Map();
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
let day = dayMap.get(row.date);
|
|
328
|
+
if (!day) {
|
|
329
|
+
day = { date: row.date, total_minutes: 0, categories: [] };
|
|
330
|
+
dayMap.set(row.date, day);
|
|
331
|
+
}
|
|
332
|
+
day.categories.push({
|
|
333
|
+
category_id: row.category_id,
|
|
334
|
+
category_name: row.category_name,
|
|
335
|
+
category_emoji: row.category_emoji,
|
|
336
|
+
minutes: row.minutes,
|
|
337
|
+
});
|
|
338
|
+
day.total_minutes += row.minutes;
|
|
339
|
+
}
|
|
340
|
+
return [...dayMap.values()];
|
|
341
|
+
}
|
|
342
|
+
async renderMatrix(filters) {
|
|
343
|
+
const uid = userIdIn(this.db, "tb.user_id", filters.user_id);
|
|
344
|
+
const conditions = [
|
|
345
|
+
uid.clause,
|
|
346
|
+
"tb.deleted_at IS NULL",
|
|
347
|
+
// MV-COACH-3 Slice 4: 只统计实绩。planned/unverified 不算(用户主权模型:
|
|
348
|
+
// 只有用户确认执行过的才进 stats,避免"建了日程就虚增统计")。
|
|
349
|
+
"tb.status = 'completed'",
|
|
350
|
+
];
|
|
351
|
+
const params = [...uid.params];
|
|
352
|
+
const range = buildTimeRangeWhere({
|
|
353
|
+
column: "tb.start_time",
|
|
354
|
+
from: filters.from,
|
|
355
|
+
to: filters.to,
|
|
356
|
+
});
|
|
357
|
+
conditions.push(...range.clauses);
|
|
358
|
+
params.push(...range.params);
|
|
359
|
+
const rows = this.db
|
|
360
|
+
.prepare(`SELECT
|
|
361
|
+
COALESCE(tb.category_id, 'cat-other') as category_id,
|
|
362
|
+
COALESCE(c.name, '其他') as category_name,
|
|
363
|
+
COALESCE(c.emoji, '❓') as category_emoji,
|
|
364
|
+
SUM(tb.duration_minutes) as total_minutes,
|
|
365
|
+
AVG(tb.rating) as avg_rating,
|
|
366
|
+
COUNT(*) as record_count
|
|
367
|
+
FROM time_blocks tb
|
|
368
|
+
LEFT JOIN categories c ON tb.category_id = c.id
|
|
369
|
+
WHERE ${conditions.join(" AND ")}
|
|
370
|
+
GROUP BY COALESCE(tb.category_id, 'cat-other')
|
|
371
|
+
HAVING avg_rating IS NOT NULL`)
|
|
372
|
+
.all(...params);
|
|
373
|
+
if (rows.length === 0) {
|
|
374
|
+
return { median_minutes: 0, median_rating: 0, bubbles: [] };
|
|
375
|
+
}
|
|
376
|
+
const minutesValues = rows.map((r) => r.total_minutes);
|
|
377
|
+
const ratingValues = rows.map((r) => r.avg_rating);
|
|
378
|
+
const medianMinutes = median(minutesValues);
|
|
379
|
+
const medianRating = median(ratingValues);
|
|
380
|
+
const bubbles = rows
|
|
381
|
+
.map((r) => {
|
|
382
|
+
const highTime = r.total_minutes >= medianMinutes;
|
|
383
|
+
const highRating = r.avg_rating >= medianRating;
|
|
384
|
+
return {
|
|
385
|
+
category_id: r.category_id,
|
|
386
|
+
category_name: r.category_name,
|
|
387
|
+
category_emoji: r.category_emoji,
|
|
388
|
+
total_minutes: r.total_minutes,
|
|
389
|
+
avg_rating: Math.round(r.avg_rating * 10) / 10,
|
|
390
|
+
record_count: r.record_count,
|
|
391
|
+
quadrant: assignQuadrant(highTime, highRating),
|
|
392
|
+
};
|
|
393
|
+
})
|
|
394
|
+
.sort((a, b) => {
|
|
395
|
+
const orderDiff = QUADRANT_ORDER[a.quadrant] - QUADRANT_ORDER[b.quadrant];
|
|
396
|
+
if (orderDiff !== 0)
|
|
397
|
+
return orderDiff;
|
|
398
|
+
return b.total_minutes - a.total_minutes;
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
median_minutes: medianMinutes,
|
|
402
|
+
median_rating: Math.round(medianRating * 10) / 10,
|
|
403
|
+
bubbles,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// ============================================================
|
|
408
|
+
// SqliteTimelineRepo (schedule.timeline)
|
|
409
|
+
// ============================================================
|
|
410
|
+
export class SqliteTimelineRepo {
|
|
411
|
+
db;
|
|
412
|
+
constructor(db) {
|
|
413
|
+
this.db = db;
|
|
414
|
+
}
|
|
415
|
+
async renderTimeline(filters) {
|
|
416
|
+
const userId = effectiveUserId(filters);
|
|
417
|
+
const blockUid = userIdIn(this.db, "tb.user_id", userId);
|
|
418
|
+
const timerUid = userIdIn(this.db, "user_id", userId);
|
|
419
|
+
const now = new Date();
|
|
420
|
+
let from;
|
|
421
|
+
let to;
|
|
422
|
+
if (filters.from !== undefined || filters.to !== undefined) {
|
|
423
|
+
const def = computeDefaultWindow(now);
|
|
424
|
+
from = filters.from !== undefined ? new Date(filters.from) : def.from;
|
|
425
|
+
to = filters.to !== undefined ? new Date(filters.to) : def.to;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
const def = computeDefaultWindow(now);
|
|
429
|
+
from = def.from;
|
|
430
|
+
to = def.to;
|
|
431
|
+
}
|
|
432
|
+
// Expand fetch to calendar-day bounds so details can include 窗口外
|
|
433
|
+
const fetchFrom = new Date(from);
|
|
434
|
+
fetchFrom.setHours(0, 0, 0, 0);
|
|
435
|
+
const fetchTo = new Date(to);
|
|
436
|
+
fetchTo.setHours(23, 59, 59, 999);
|
|
437
|
+
const blocks = this.db
|
|
438
|
+
.prepare(`SELECT
|
|
439
|
+
tb.start_time as start,
|
|
440
|
+
tb.end_time as end,
|
|
441
|
+
tb.status as status,
|
|
442
|
+
tb.title as title,
|
|
443
|
+
tb.category_id as category_id,
|
|
444
|
+
c.emoji as category_emoji,
|
|
445
|
+
c.name as category_name,
|
|
446
|
+
c.color as category_color
|
|
447
|
+
FROM time_blocks tb
|
|
448
|
+
LEFT JOIN categories c ON tb.category_id = c.id
|
|
449
|
+
WHERE ${blockUid.clause}
|
|
450
|
+
AND tb.deleted_at IS NULL
|
|
451
|
+
AND tb.status IN ('planned', 'unverified', 'completed')
|
|
452
|
+
AND datetime(tb.start_time) < datetime(?)
|
|
453
|
+
AND datetime(tb.end_time) > datetime(?)`)
|
|
454
|
+
.all(...blockUid.params, fetchTo.toISOString(), fetchFrom.toISOString());
|
|
455
|
+
const timers = this.db
|
|
456
|
+
.prepare(`SELECT started_at, accumulated_ms, status, event
|
|
457
|
+
FROM timer_sessions
|
|
458
|
+
WHERE ${timerUid.clause} AND status IN ('running', 'paused')`)
|
|
459
|
+
.all(...timerUid.params);
|
|
460
|
+
return buildTimelineFromEvents({
|
|
461
|
+
blocks,
|
|
462
|
+
timers,
|
|
463
|
+
from,
|
|
464
|
+
to,
|
|
465
|
+
now,
|
|
466
|
+
format: filters.format ?? "emoji",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// ============================================================
|
|
471
|
+
// SqliteTimerRepo (R4 — timer persistence)
|
|
472
|
+
// ============================================================
|
|
473
|
+
export class SqliteTimerRepo {
|
|
474
|
+
db;
|
|
475
|
+
constructor(db) {
|
|
476
|
+
this.db = db;
|
|
477
|
+
}
|
|
478
|
+
async getActive(userId) {
|
|
479
|
+
const uid = userIdIn(this.db, "user_id", userId);
|
|
480
|
+
const row = this.db
|
|
481
|
+
.prepare(`SELECT * FROM timer_sessions WHERE ${uid.clause} AND status IN ('running', 'paused') LIMIT 1`)
|
|
482
|
+
.get(...uid.params);
|
|
483
|
+
return row ?? null;
|
|
484
|
+
}
|
|
485
|
+
async getAllActive() {
|
|
486
|
+
return this.db
|
|
487
|
+
.prepare("SELECT * FROM timer_sessions WHERE status IN ('running', 'paused')")
|
|
488
|
+
.all();
|
|
489
|
+
}
|
|
490
|
+
async create(input) {
|
|
491
|
+
const id = crypto.randomUUID();
|
|
492
|
+
const now = new Date().toISOString();
|
|
493
|
+
this.db
|
|
494
|
+
.prepare(`INSERT INTO timer_sessions (id, user_id, event, category_id, status, started_at, accumulated_ms, target_ms, task_id, created_at, updated_at)
|
|
495
|
+
VALUES (?, ?, ?, ?, 'running', ?, 0, ?, ?, ?, ?)`)
|
|
496
|
+
.run(id, input.user_id, input.event.trim(), input.category_id ?? null, input.started_at, input.target_ms ?? null, input.task_id ?? null, now, now);
|
|
497
|
+
return this.db
|
|
498
|
+
.prepare("SELECT * FROM timer_sessions WHERE id = ?")
|
|
499
|
+
.get(id);
|
|
500
|
+
}
|
|
501
|
+
async update(id, patch) {
|
|
502
|
+
const now = new Date().toISOString();
|
|
503
|
+
const sets = ["updated_at = ?"];
|
|
504
|
+
const params = [now];
|
|
505
|
+
if (patch.status !== undefined) {
|
|
506
|
+
sets.push("status = ?");
|
|
507
|
+
params.push(patch.status);
|
|
508
|
+
}
|
|
509
|
+
if (patch.started_at !== undefined) {
|
|
510
|
+
sets.push("started_at = ?");
|
|
511
|
+
params.push(patch.started_at);
|
|
512
|
+
}
|
|
513
|
+
if (patch.accumulated_ms !== undefined) {
|
|
514
|
+
sets.push("accumulated_ms = ?");
|
|
515
|
+
params.push(patch.accumulated_ms);
|
|
516
|
+
}
|
|
517
|
+
if (patch.ended_at !== undefined) {
|
|
518
|
+
sets.push("ended_at = ?");
|
|
519
|
+
params.push(patch.ended_at);
|
|
520
|
+
}
|
|
521
|
+
if (patch.target_ms !== undefined) {
|
|
522
|
+
sets.push("target_ms = ?");
|
|
523
|
+
params.push(patch.target_ms);
|
|
524
|
+
}
|
|
525
|
+
params.push(id);
|
|
526
|
+
this.db
|
|
527
|
+
.prepare(`UPDATE timer_sessions SET ${sets.join(", ")} WHERE id = ?`)
|
|
528
|
+
.run(...params);
|
|
529
|
+
return this.db
|
|
530
|
+
.prepare("SELECT * FROM timer_sessions WHERE id = ?")
|
|
531
|
+
.get(id);
|
|
532
|
+
}
|
|
533
|
+
async userExists(userId) {
|
|
534
|
+
return profileExistsForUser(this.db, userId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// ============================================================
|
|
538
|
+
// SqliteProfileRepo (S13 — profile with structured settings)
|
|
539
|
+
// ============================================================
|
|
540
|
+
export class SqliteProfileRepo {
|
|
541
|
+
db;
|
|
542
|
+
constructor(db) {
|
|
543
|
+
this.db = db;
|
|
544
|
+
}
|
|
545
|
+
async markPortraitDirty(userId) {
|
|
546
|
+
this.db
|
|
547
|
+
.prepare("UPDATE user_profile SET portrait_dirty = 1 WHERE id = ?")
|
|
548
|
+
.run(userId);
|
|
549
|
+
}
|
|
550
|
+
async getById(userId) {
|
|
551
|
+
const row = this.db
|
|
552
|
+
.prepare("SELECT id, display_name, expectations, settings, created_at, updated_at FROM user_profile WHERE id = ? AND deleted_at IS NULL")
|
|
553
|
+
.get(userId);
|
|
554
|
+
if (!row)
|
|
555
|
+
return null;
|
|
556
|
+
return {
|
|
557
|
+
id: row.id,
|
|
558
|
+
display_name: row.display_name,
|
|
559
|
+
expectations: row.expectations,
|
|
560
|
+
settings: row.settings != null ? JSON.parse(row.settings) : null,
|
|
561
|
+
created_at: row.created_at,
|
|
562
|
+
updated_at: row.updated_at,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async upsert(input) {
|
|
566
|
+
const existing = await this.getById(input.user_id);
|
|
567
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
568
|
+
if (existing) {
|
|
569
|
+
// Update: merge top-level fields, retain existing values for unset fields
|
|
570
|
+
const display_name = input.display_name !== undefined
|
|
571
|
+
? input.display_name
|
|
572
|
+
: existing.display_name;
|
|
573
|
+
const expectations = input.expectations !== undefined
|
|
574
|
+
? input.expectations
|
|
575
|
+
: existing.expectations;
|
|
576
|
+
// settings: whole-object replace (not deep merge), per S13 §4
|
|
577
|
+
const settings = input.settings !== undefined
|
|
578
|
+
? JSON.stringify(input.settings)
|
|
579
|
+
: existing.settings != null
|
|
580
|
+
? JSON.stringify(existing.settings)
|
|
581
|
+
: null;
|
|
582
|
+
this.db
|
|
583
|
+
.prepare(`UPDATE user_profile SET display_name = ?, expectations = ?, settings = ?, updated_at = ? WHERE id = ?`)
|
|
584
|
+
.run(display_name, expectations, settings, now, input.user_id);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
// Insert
|
|
588
|
+
const display_name = input.display_name ?? "";
|
|
589
|
+
const expectations = input.expectations ?? null;
|
|
590
|
+
const settings = input.settings !== undefined
|
|
591
|
+
? JSON.stringify(input.settings)
|
|
592
|
+
: null;
|
|
593
|
+
this.db
|
|
594
|
+
.prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
|
|
595
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
596
|
+
.run(input.user_id, display_name, expectations, settings, now, now);
|
|
597
|
+
}
|
|
598
|
+
return (await this.getById(input.user_id));
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* S40 slice 3:Atomic claim nudge。
|
|
602
|
+
*
|
|
603
|
+
* 用 better-sqlite3 的 `db.transaction()` 把「SELECT → 检查 window → UPDATE → 返回」
|
|
604
|
+
* 包成一个原子单元。better-sqlite3 是**同步**的——transaction 期间阻塞 event loop,
|
|
605
|
+
* 第二个调用根本没机会插入。
|
|
606
|
+
*
|
|
607
|
+
* 注意:transaction 返回的函数是同步的,必须在同步上下文调用(不能 await)。
|
|
608
|
+
* 这里包装成 async 只是为了匹配 ProfileRepo interface,内部完全同步执行。
|
|
609
|
+
*/
|
|
610
|
+
async claimNudgeAtomic(input) {
|
|
611
|
+
const windowMs = input.window_minutes * 60 * 1000;
|
|
612
|
+
const now = new Date();
|
|
613
|
+
const nowIso = now.toISOString();
|
|
614
|
+
const nowDb = nowIso.replace("T", " ").replace("Z", "");
|
|
615
|
+
const rowToProfile = (r) => ({
|
|
616
|
+
id: r.id,
|
|
617
|
+
display_name: r.display_name,
|
|
618
|
+
expectations: r.expectations,
|
|
619
|
+
settings: r.settings != null ? JSON.parse(r.settings) : null,
|
|
620
|
+
created_at: r.created_at,
|
|
621
|
+
updated_at: r.updated_at,
|
|
622
|
+
});
|
|
623
|
+
const selectStmt = this.db.prepare("SELECT id, display_name, expectations, settings, created_at, updated_at FROM user_profile WHERE id = ? AND deleted_at IS NULL");
|
|
624
|
+
const updateStmt = this.db.prepare(`UPDATE user_profile SET settings = ?, updated_at = ? WHERE id = ?`);
|
|
625
|
+
const insertStmt = this.db.prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
|
|
626
|
+
VALUES (?, '', NULL, ?, ?, ?)`);
|
|
627
|
+
// transaction 函数(同步)。better-sqlite3 在 transaction() 期间阻塞 event loop,
|
|
628
|
+
// 第二个并发调用必须等第一个 COMMIT 才能进入 BEGIN。
|
|
629
|
+
const txn = this.db.transaction(() => {
|
|
630
|
+
const row = selectStmt.get(input.user_id);
|
|
631
|
+
const existingSettings = row?.settings != null
|
|
632
|
+
? JSON.parse(row.settings)
|
|
633
|
+
: {};
|
|
634
|
+
const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
|
|
635
|
+
if (typeof lastNudgeAt === "string") {
|
|
636
|
+
const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
|
|
637
|
+
if (ageMs < windowMs) {
|
|
638
|
+
return { won: false, profile: rowToProfile(row) };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const merged = {
|
|
642
|
+
...existingSettings,
|
|
643
|
+
last_inactivity_nudge_at: nowIso,
|
|
644
|
+
};
|
|
645
|
+
const settingsJson = JSON.stringify(merged);
|
|
646
|
+
if (row) {
|
|
647
|
+
updateStmt.run(settingsJson, nowDb, input.user_id);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
insertStmt.run(input.user_id, settingsJson, nowDb, nowDb);
|
|
651
|
+
}
|
|
652
|
+
const updated = selectStmt.get(input.user_id);
|
|
653
|
+
return { won: true, profile: rowToProfile(updated) };
|
|
654
|
+
});
|
|
655
|
+
// BEGIN IMMEDIATE:立刻拿写锁,防止 deferred 模式下读-写升级冲突
|
|
656
|
+
// better-sqlite3 API:transaction 函数有 .immediate(args) 变种
|
|
657
|
+
return txn.immediate();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// ============================================================
|
|
661
|
+
// SqliteReminderRepo (S11 — reminder persistence)
|
|
662
|
+
// ============================================================
|
|
663
|
+
export class SqliteReminderRepo {
|
|
664
|
+
db;
|
|
665
|
+
constructor(db) {
|
|
666
|
+
this.db = db;
|
|
667
|
+
}
|
|
668
|
+
async create(input) {
|
|
669
|
+
const id = crypto.randomUUID();
|
|
670
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
671
|
+
// Normalize to UTC Z format — ensures getDueReminders string comparison
|
|
672
|
+
// works correctly even when caller passes +08:00 or other offsets.
|
|
673
|
+
const remindAt = new Date(input.remind_at).toISOString();
|
|
674
|
+
this.db
|
|
675
|
+
.prepare(`INSERT INTO reminders (id, user_id, timeblock_id, content, remind_at, channel, status, attempts, sent_at, created_at, updated_at)
|
|
676
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, ?, ?)`)
|
|
677
|
+
.run(id, input.user_id, input.timeblock_id ?? null, input.content, remindAt, input.channel ?? "session", now, now);
|
|
678
|
+
return (await this.getById(id));
|
|
679
|
+
}
|
|
680
|
+
async getById(id) {
|
|
681
|
+
const row = this.db
|
|
682
|
+
.prepare("SELECT * FROM reminders WHERE id = ? AND deleted_at IS NULL")
|
|
683
|
+
.get(id);
|
|
684
|
+
return row ?? null;
|
|
685
|
+
}
|
|
686
|
+
async getDueReminders(input) {
|
|
687
|
+
if (input.user_id) {
|
|
688
|
+
const uid = userIdIn(this.db, "user_id", input.user_id);
|
|
689
|
+
return this.db
|
|
690
|
+
.prepare(`SELECT * FROM reminders WHERE status = 'pending' AND remind_at <= ? AND ${uid.clause} AND deleted_at IS NULL ORDER BY remind_at ASC`)
|
|
691
|
+
.all(input.until, ...uid.params);
|
|
692
|
+
}
|
|
693
|
+
return this.db
|
|
694
|
+
.prepare("SELECT * FROM reminders WHERE status = 'pending' AND remind_at <= ? AND deleted_at IS NULL ORDER BY remind_at ASC")
|
|
695
|
+
.all(input.until);
|
|
696
|
+
}
|
|
697
|
+
async getNextDue(input = {}) {
|
|
698
|
+
if (input.user_id) {
|
|
699
|
+
const uid = userIdIn(this.db, "user_id", input.user_id);
|
|
700
|
+
const row = this.db
|
|
701
|
+
.prepare(`SELECT remind_at FROM reminders WHERE status = 'pending' AND ${uid.clause} AND deleted_at IS NULL ORDER BY remind_at ASC LIMIT 1`)
|
|
702
|
+
.get(...uid.params);
|
|
703
|
+
return row ?? null;
|
|
704
|
+
}
|
|
705
|
+
const row = this.db
|
|
706
|
+
.prepare("SELECT remind_at FROM reminders WHERE status = 'pending' AND deleted_at IS NULL ORDER BY remind_at ASC LIMIT 1")
|
|
707
|
+
.get();
|
|
708
|
+
return row ?? null;
|
|
709
|
+
}
|
|
710
|
+
async getMissedReminders(input) {
|
|
711
|
+
const uid = userIdIn(this.db, "user_id", input.user_id);
|
|
712
|
+
return this.db
|
|
713
|
+
.prepare(`SELECT * FROM reminders WHERE status = 'pending' AND remind_at < ? AND ${uid.clause} AND deleted_at IS NULL ORDER BY remind_at ASC`)
|
|
714
|
+
.all(input.now, ...uid.params);
|
|
715
|
+
}
|
|
716
|
+
async markSent(id) {
|
|
717
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
718
|
+
this.db
|
|
719
|
+
.prepare("UPDATE reminders SET status = 'sent', sent_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL")
|
|
720
|
+
.run(now, now, id);
|
|
721
|
+
}
|
|
722
|
+
async cancelReminder(id) {
|
|
723
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
724
|
+
this.db
|
|
725
|
+
.prepare("UPDATE reminders SET status = 'cancelled', updated_at = ? WHERE id = ? AND deleted_at IS NULL")
|
|
726
|
+
.run(now, id);
|
|
727
|
+
}
|
|
728
|
+
async userExists(userId) {
|
|
729
|
+
return profileExistsForUser(this.db, userId);
|
|
730
|
+
}
|
|
731
|
+
async claim(id) {
|
|
732
|
+
const result = this.db
|
|
733
|
+
.prepare("UPDATE reminders SET status = 'sending', updated_at = datetime('now') WHERE id = ? AND status = 'pending'")
|
|
734
|
+
.run(id);
|
|
735
|
+
return result.changes > 0;
|
|
736
|
+
}
|
|
737
|
+
async releaseOrFail(id, maxAttempts) {
|
|
738
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
739
|
+
// Increment attempts atomically
|
|
740
|
+
this.db
|
|
741
|
+
.prepare("UPDATE reminders SET attempts = attempts + 1, updated_at = ? WHERE id = ? AND deleted_at IS NULL")
|
|
742
|
+
.run(now, id);
|
|
743
|
+
// Check if we should retry
|
|
744
|
+
const row = this.db
|
|
745
|
+
.prepare("SELECT attempts FROM reminders WHERE id = ? AND deleted_at IS NULL")
|
|
746
|
+
.get(id);
|
|
747
|
+
if (row && row.attempts < maxAttempts) {
|
|
748
|
+
this.db
|
|
749
|
+
.prepare("UPDATE reminders SET status = 'pending', updated_at = ? WHERE id = ? AND deleted_at IS NULL")
|
|
750
|
+
.run(now, id);
|
|
751
|
+
}
|
|
752
|
+
// else: stays 'sending' — dead, won't be picked up again
|
|
753
|
+
}
|
|
754
|
+
}
|