Skip to content

Commit 5f9056f

Browse files
committed
feat(contracts): 周契约数据层与周聚合纯函数(REQ-200)
1 parent fe7cd55 commit 5f9056f

6 files changed

Lines changed: 388 additions & 1 deletion

File tree

‎app/src-tauri/src/db_contracts.rs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//! 周契约数据层(v0.11.4 REQ-200;弹性承诺呈现层)。
2+
//!
3+
//! @ai-context: contracts 表按 (group_id, week_start) 唯一——每周每组一份契约,
4+
//! upsert 幂等覆盖本周(改目标即改承诺,不产生历史噪音);
5+
//! 完成度取数(review_logs JOIN flashcards 按组+周过滤)在本层,
6+
//! 聚合纯函数在 week_contract.rs(周界/去重口径单一)。
7+
//! @ai-context: 弹性承诺纪律——本层只读写事实,不算 streak、不追债、不惩罚。
8+
9+
use rusqlite::params;
10+
11+
use crate::db::{unix_seconds, Db};
12+
use crate::error::Result;
13+
use crate::types::WeekContract;
14+
use crate::week_contract::WEEK_SECS;
15+
16+
/// contracts 表统一查询列(列顺序与 row_to_contract 严格对应)。
17+
const CONTRACT_COLUMNS: &str = "id, group_id, week_start, target_days, target_cards, created_at";
18+
19+
impl Db {
20+
/// 幂等写入本周契约(同组同周已存在 → 覆盖目标值;created_at 刷新)。
21+
///
22+
/// @ai-context: ON CONFLICT DO UPDATE——用户改目标即改承诺(无历史版本,
23+
/// 弹性承诺不记恩怨,只记当前约定);外键组不存在报错。
24+
pub fn upsert_week_contract(
25+
&self,
26+
group_id: i64,
27+
week_start: i64,
28+
target_days: i64,
29+
target_cards: i64,
30+
) -> Result<WeekContract> {
31+
let now = unix_seconds();
32+
self.with_conn(|conn| {
33+
conn.execute(
34+
"INSERT INTO contracts (group_id, week_start, target_days, target_cards, created_at)
35+
VALUES (?1, ?2, ?3, ?4, ?5)
36+
ON CONFLICT(group_id, week_start) DO UPDATE SET
37+
target_days = excluded.target_days,
38+
target_cards = excluded.target_cards,
39+
created_at = excluded.created_at",
40+
params![group_id, week_start, target_days, target_cards, now],
41+
)?;
42+
let id = conn.last_insert_rowid();
43+
Ok(WeekContract {
44+
id,
45+
group_id,
46+
week_start,
47+
target_days,
48+
target_cards,
49+
created_at: now,
50+
})
51+
})
52+
}
53+
54+
/// 读取指定周契约(无 → None——本周未立约,UI 显示设定表单)。
55+
pub fn get_week_contract(&self, group_id: i64, week_start: i64) -> Result<Option<WeekContract>> {
56+
self.with_conn(|conn| {
57+
let mut stmt = conn.prepare(&format!(
58+
"SELECT {} FROM contracts WHERE group_id = ?1 AND week_start = ?2",
59+
CONTRACT_COLUMNS
60+
))?;
61+
let mut rows = stmt.query_map(params![group_id, week_start], row_to_contract)?;
62+
match rows.next() {
63+
Some(Ok(c)) => Ok(Some(c)),
64+
Some(Err(e)) => Err(e.into()),
65+
None => Ok(None),
66+
}
67+
})
68+
}
69+
70+
/// 组在某周(含)之后的复习时刻列表(周契约实际完成度取数)。
71+
///
72+
/// @ai-context: JOIN flashcards 按组过滤(review_logs 无组概念,弹性承诺
73+
/// 日志只记卡);范围 [week_start, week_start+7d) 左闭右开——
74+
/// 下一周周一零点归下周,周界不重叠。
75+
pub fn review_ats_in_week(&self, group_id: i64, week_start: i64) -> Result<Vec<i64>> {
76+
self.with_conn(|conn| {
77+
let mut stmt = conn.prepare(
78+
"SELECT l.reviewed_at FROM review_logs l
79+
JOIN flashcards c ON c.id = l.card_id
80+
WHERE c.group_id = ?1 AND l.reviewed_at >= ?2 AND l.reviewed_at < ?3
81+
ORDER BY l.reviewed_at ASC",
82+
)?;
83+
let rows = stmt.query_map(params![group_id, week_start, week_start + WEEK_SECS], |r| r.get(0))?;
84+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
85+
})
86+
}
87+
}
88+
89+
/// 把 rusqlite 行映射为 WeekContract。
90+
fn row_to_contract(row: &rusqlite::Row<'_>) -> rusqlite::Result<WeekContract> {
91+
Ok(WeekContract {
92+
id: row.get(0)?,
93+
group_id: row.get(1)?,
94+
week_start: row.get(2)?,
95+
target_days: row.get(3)?,
96+
target_cards: row.get(4)?,
97+
created_at: row.get(5)?,
98+
})
99+
}
100+
101+
/// 单测独立文件。
102+
#[cfg(test)]
103+
#[path = "db_contracts_tests.rs"]
104+
mod tests;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//! db_contracts 单测(内存库;AAA 模式)。
2+
3+
use crate::db::Db;
4+
use crate::db_flashcards::NewFlashcard;
5+
use crate::types::NewNoteGroup;
6+
use crate::week_contract::week_start_secs;
7+
8+
/// 内存库。
9+
fn mem_db() -> Db {
10+
Db::open(":memory:").expect("内存库打开")
11+
}
12+
13+
/// 建组助手(contracts/flashcards 外键前置)。
14+
fn make_group(db: &Db, name: &str) -> crate::types::NoteGroup {
15+
db.create_group(&NewNoteGroup {
16+
name: name.to_string(),
17+
terrain: "container".to_string(),
18+
kind: "standalone".to_string(),
19+
domain_tag: None,
20+
source: "manual".to_string(),
21+
series_key: None,
22+
route_reason: None,
23+
})
24+
.expect("group")
25+
}
26+
27+
/// 建卡助手(review_logs 外键前置;新卡 due 不限制)。
28+
fn make_card(db: &Db, group_id: i64, front: &str) -> crate::types::Flashcard {
29+
db.create_card(&NewFlashcard {
30+
group_id,
31+
note_id: None,
32+
fragment_id: None,
33+
front: front.to_string(),
34+
back: "验证材料".to_string(),
35+
kind: "fact".to_string(),
36+
state_json: "{}".to_string(),
37+
due_at: 0,
38+
})
39+
.expect("card")
40+
}
41+
42+
#[test]
43+
fn upsert_creates_and_overrides_this_week() {
44+
// Arrange
45+
let db = mem_db();
46+
let group = make_group(&db, "契约组");
47+
let ws = week_start_secs(1_767_225_600);
48+
// Act:首次写入 → 再次写入覆盖(幂等语义)
49+
let c1 = db
50+
.upsert_week_contract(group.id, ws, 3, 15)
51+
.expect("first");
52+
let c2 = db
53+
.upsert_week_contract(group.id, ws, 5, 30)
54+
.expect("override");
55+
// Assert:同周同组仅一份契约,目标值已覆盖
56+
let fetched = db.get_week_contract(group.id, ws).expect("get");
57+
let fetched = fetched.expect("契约应存在");
58+
assert_eq!(fetched.id, c1.id);
59+
assert_eq!(fetched.target_days, 5);
60+
assert_eq!(fetched.target_cards, 30);
61+
assert_ne!(fetched.created_at, c2.created_at); // created_at 刷新
62+
}
63+
64+
#[test]
65+
fn different_weeks_coexist() {
66+
// Arrange
67+
let db = mem_db();
68+
let group = make_group(&db, "双周组");
69+
let wk1 = week_start_secs(1_767_225_600);
70+
let wk2 = wk1 + 604_800; // 下一周
71+
// Act:两周各立一约
72+
db.upsert_week_contract(group.id, wk1, 2, 10).expect("wk1");
73+
db.upsert_week_contract(group.id, wk2, 4, 20).expect("wk2");
74+
// Assert:互不覆盖
75+
assert_eq!(db.get_week_contract(group.id, wk1).expect("g1").expect("c1").target_cards, 10);
76+
assert_eq!(db.get_week_contract(group.id, wk2).expect("g2").expect("c2").target_cards, 20);
77+
}
78+
79+
#[test]
80+
fn missing_contract_returns_none() {
81+
// Arrange:组存在但从未立约
82+
let db = mem_db();
83+
let group = make_group(&db, "无约组");
84+
// Act/Assert:诚实 None(UI 显示设定表单)
85+
assert!(db.get_week_contract(group.id, week_start_secs(1_767_225_600)).expect("get").is_none());
86+
}
87+
88+
#[test]
89+
fn review_ats_in_week_filters_group_and_range() {
90+
// Arrange:两组各一卡;本周 3 次复习 + 下周 1 次
91+
let db = mem_db();
92+
let g1 = make_group(&db, "组一");
93+
let g2 = make_group(&db, "组二");
94+
let card1 = make_card(&db, g1.id, "卡一");
95+
let card2 = make_card(&db, g2.id, "卡二");
96+
let ws = week_start_secs(1_767_225_600);
97+
db.add_review_log(card1.id, "good", ws + 3_600).expect("r1"); // 本周
98+
db.add_review_log(card1.id, "good", ws + 86_400).expect("r2"); // 本周
99+
db.add_review_log(card2.id, "easy", ws + 43_200).expect("r3"); // 本周·他组
100+
db.add_review_log(card1.id, "again", ws + 604_800 + 100).expect("r4"); // 下周
101+
// Act
102+
let ats = db.review_ats_in_week(g1.id, ws).expect("query");
103+
// Assert:仅组一本周 2 条(下周归下周、他组不计)
104+
assert_eq!(ats.len(), 2);
105+
assert!(ats.contains(&(ws + 3_600)));
106+
assert!(ats.contains(&(ws + 86_400)));
107+
}
108+
109+
#[test]
110+
fn upsert_rejects_unknown_group() {
111+
// Arrange:不存在的组 id
112+
let db = mem_db();
113+
// Act/Assert:外键约束报错(不静默写孤儿契约)
114+
assert!(db.upsert_week_contract(9999, 0, 3, 15).is_err());
115+
}

‎app/src-tauri/src/db_migrations.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,20 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<()> {
150150
stats_json TEXT NOT NULL,
151151
created_at INTEGER NOT NULL
152152
);
153-
CREATE INDEX IF NOT EXISTS idx_settlements_group ON settlements(group_id, created_at);",
153+
CREATE INDEX IF NOT EXISTS idx_settlements_group ON settlements(group_id, created_at);
154+
-- v0.11.4(REQ-200,弹性承诺呈现层):周契约——用户自设本周目标
155+
-- (target_days/target_cards),非打卡 KPI;(group_id, week_start) 唯一
156+
-- 保证每周每组一份契约,upsert 幂等覆盖本周(无 streak 无惩罚)
157+
CREATE TABLE IF NOT EXISTS contracts (
158+
id INTEGER PRIMARY KEY AUTOINCREMENT,
159+
group_id INTEGER NOT NULL REFERENCES note_groups(id) ON DELETE CASCADE,
160+
week_start INTEGER NOT NULL, -- 周一零点(UTC Unix 秒)
161+
target_days INTEGER NOT NULL, -- 本周承诺复习天数(1..7)
162+
target_cards INTEGER NOT NULL, -- 本周承诺复习卡数(有界)
163+
created_at INTEGER NOT NULL
164+
);
165+
CREATE UNIQUE INDEX IF NOT EXISTS idx_contracts_group_week
166+
ON contracts(group_id, week_start);",
154167
)?;
155168
// v0.5.0 M1(REQ-043):旧库迁移——sessions 表补 profile 列(兼容既有数据库)
156169
ensure_column(conn, "sessions", "profile", "ALTER TABLE sessions ADD COLUMN profile TEXT")?;

‎app/src-tauri/src/types.rs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,25 @@ pub struct Flashcard {
259259
pub created_at: i64,
260260
}
261261

262+
/// 周契约(v0.11.4 REQ-200;弹性承诺呈现层——用户自设本周目标,非打卡 KPI)。
263+
///
264+
/// @ai-context: 无 streak 无惩罚——契约只记录承诺本身,完成度由
265+
/// review_logs 周聚合实时计算(week_contract.rs 纯函数)。
266+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267+
#[serde(rename_all = "camelCase")]
268+
pub struct WeekContract {
269+
pub id: i64,
270+
/// 绑定组(契约粒度=组——与学习循环绑定粒度一致,契约二)
271+
pub group_id: i64,
272+
/// 周界:周一零点(UTC Unix 秒)
273+
pub week_start: i64,
274+
/// 本周承诺复习天数(1..7)
275+
pub target_days: i64,
276+
/// 本周承诺复习卡数(有界)
277+
pub target_cards: i64,
278+
pub created_at: i64,
279+
}
280+
262281
// ────────────────────────────────────────────────────────────
263282
// 会话领域类型(REQ-010,ADR-004)
264283
// ────────────────────────────────────────────────────────────

‎app/src-tauri/src/week_contract.rs‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//! 周契约纯函数(v0.11.4 REQ-200;弹性承诺呈现层的数据基础)。
2+
//!
3+
//! @ai-context: 弹性承诺纪律(v4 §8.2 P31+N10)——契约是用户自设的本周目标,
4+
//! 不是打卡 KPI:无 streak、无惩罚、欠账不追。本层只回答
5+
//! "本周承诺 vs 实际"两个数字,聚合结果由 UI 呈现。
6+
//! @ai-context: 周界口径:UTC 周一零点(零依赖纯函数,不引 chrono)。时区偏差
7+
//! 对弹性承诺无惩罚性影响(欠账不追,边界模糊不伤契约精神);
8+
//! UI 展示时把 week_start 转本地日期。
9+
//! @ai-context: 纯逻辑无 IO;取数(review_logs 按组+周过滤)在 db_contracts.rs。
10+
11+
/// 一周秒数(周一零点周界计算用)。
12+
pub const WEEK_SECS: i64 = 7 * 86_400;
13+
/// 最小可行日徽标阈值:本周完成卡数达到此值即"成立"(N9/N11 低谷生存
14+
/// 的最轻形态——一天状态崩坏不否定整周,但至少 3 次提取才算成立)。
15+
pub const MINIMAL_DAY_CARDS: usize = 3;
16+
17+
/// 当前时刻所在周的周一零点(UTC;Unix 秒)。
18+
///
19+
/// @ai-context: 推导——Unix epoch(1970-01-01)是周四;设 d=距纪元天数,
20+
/// ISO weekday(d) = (d + 3) % 7 + 1(周一=1);该周起始天 =
21+
/// d - (weekday - 1) = d - (d + 3) % 7。rem_euclid 防负天数。
22+
pub fn week_start_secs(now_secs: i64) -> i64 {
23+
let d = now_secs / 86_400;
24+
let back = (d + 3).rem_euclid(7);
25+
(d - back) * 86_400
26+
}
27+
28+
/// 周聚合结果(承诺 vs 实际的数据基础)。
29+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30+
pub struct WeekAggregate {
31+
/// 有复习的天数(按自然日去重——断签不清零:不连续也算天数)
32+
pub review_days: usize,
33+
/// 复习卡次数(review_logs 行数)
34+
pub review_cards: usize,
35+
}
36+
37+
/// review 记录(reviewed_at Unix 秒列表)→ 周聚合。
38+
///
39+
/// @ai-context: 天数按 (t / 86400) 去重——同一天多次复习只算一天(日历日口径,
40+
/// 与 week_start 周界一致);卡数=复习次数(提取动作次数)。
41+
pub fn aggregate_week(reviewed_ats: &[i64]) -> WeekAggregate {
42+
let mut days = std::collections::HashSet::new();
43+
for t in reviewed_ats {
44+
days.insert(t / 86_400);
45+
}
46+
WeekAggregate {
47+
review_days: days.len(),
48+
review_cards: reviewed_ats.len(),
49+
}
50+
}
51+
52+
/// 最小可行日成立判定(本周完成卡数 ≥ MINIMAL_DAY_CARDS)。
53+
pub fn minimal_day_met(review_cards: usize) -> bool {
54+
review_cards >= MINIMAL_DAY_CARDS
55+
}
56+
57+
#[cfg(test)]
58+
#[path = "week_contract_tests.rs"]
59+
mod tests;

0 commit comments

Comments
 (0)