Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,11 +459,16 @@ pub async fn transactions_trend(
// 桶表达式:day/week 产出 "YYYY-MM-DD",hour 产出 "YYYY-MM-DD HH:00"
let expr = match bucket.as_str() {
"hour" => "%Y-%m-%d %H:00",
"week" => "%Y-%m-%d", // 配 modifier:周一起始
"week" => "%Y-%m-%d", // 配 modifier:周一起始(含周一当日)
_ => "%Y-%m-%d",
};
let mods = if bucket == "week" {
", 'weekday 1', '-7 days'"
// SQLite 的 `weekday N` 只把日期**向前**推进到下一个星期 N,且当日已是 N 时为空操作:
// 故 `weekday 1, -7 days` 会把**周一当天**的行推到上一周(其余星期恰好落回正确的那一周)。
// `weekday 0, -6 days` 才是「本周周一(含周一当日)」。
// ⚠️ 前端 `ui/js/app.js::txTrendDays` 补时间轴时用的是**同一口径**(周轴对齐到周一),
// 两处必须一致,否则补出的轴键与后端桶键错位、整周柱子被补成 0。
", 'weekday 0', '-6 days'"
} else {
""
};
Expand Down Expand Up @@ -1351,6 +1356,64 @@ mod tests {
assert_eq!(v["bucket"], "day", "非法 bucket 应回退 day: {body}");
}

#[tokio::test]
async fn transactions_trend_week_bucket_starts_on_monday() {
// C2094:week 桶必须是「周一起始(含周一当日)」。
// SQLite 的 `weekday 1, -7 days` 会把**周一当天**的行推进上一周
// (`strftime('%Y-%m-%d','2026-09-07 10:00:00','weekday 1','-7 days')` = `2026-08-31`),
// 只有周二..周日才恰好落在正确的那一周 —— 于是同一自然周被劈成两个桶,
// 而周一行「消失」在上一周。修法:`weekday 0, -6 days`。
let st = test_state("trendweek");
let key = login(st.clone()).await;
// 2026-09-07 是周一、2026-09-13 是周日,同属一个自然周。
let days = [
"2026-09-07", // 周一
"2026-09-08", // 周二
"2026-09-09", // 周三
"2026-09-10", // 周四
"2026-09-11", // 周五
"2026-09-12", // 周六
"2026-09-13", // 周日
];
{
let conn = st.db.lock().unwrap();
for (i, d) in days.iter().enumerate() {
conn.execute(
"INSERT INTO transactions (user_id, counterpart, model, tokens, pts, type, status, time) \
VALUES (1, 'w', 'm', 0, ?1, 'consume', '成功', ?2)",
rusqlite::params![(i as f64) + 1.0, format!("{d} 10:00:00")],
)
.unwrap();
}
}
let (s, body) = get(
st.clone(),
"/api/transactions/trend?type=all&bucket=week",
&key,
)
.await;
assert_eq!(s, axum::http::StatusCode::OK, "body: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["bucket"], "week", "body: {body}");
let buckets = v["buckets"].as_array().unwrap();
// 一个自然周的 7 行必须聚成**恰好一个**桶(缺陷版本会把周一那行拆到上一周 → 2 个桶)。
assert_eq!(buckets.len(), 1, "同一自然周应聚成 1 个桶: {body}");
assert_eq!(
buckets[0]["t"].as_str().unwrap(),
"2026-09-07T00:00:00Z",
"week 桶起点应为该周周一 00:00Z: {body}"
);
assert_eq!(
buckets[0]["count"].as_i64().unwrap(),
7,
"整周 7 行应同桶(周一那行不能落上一周): {body}"
);
assert!(
(buckets[0]["expense"].as_f64().unwrap() - 28.0).abs() < 1e-9,
"整周 expense=1+2+..+7=28: {body}"
);
}

#[tokio::test]
async fn transactions_key_label() {
// rant 2026-08-22T06:36:54/06:37:50:/api/transactions 每行返回 key_label
Expand Down
2 changes: 1 addition & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,6 @@ <h3 id="chat-title">使用模型</h3>
<script src="js/api.js?v=20260912-5"></script>
<script src="js/data.js?v=20260912-5"></script>
<script src="js/i18n.js?v=20260912-5"></script>
<script src="js/app.js?v=20260913-3"></script>
<script src="js/app.js?v=20260914-1"></script>
</body>
</html>
10 changes: 9 additions & 1 deletion ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1420,7 +1420,8 @@
if (!isNaN(d.getTime())) byKey.set(keyOf(d), b);
});
const now = new Date();
// 窗口右端:custom/end 已指定则用 end,否则用「现在」(UTC 对齐到桶,与后端 strftime 口径一致)
// 窗口右端:custom/end 已指定则用 end,否则用「现在」
// (UTC 对齐到桶,与后端 strftime 口径一致:hour→小时、day→当日、week→周一)
let end;
if (txRange === "custom" && txCustomEnd) {
end = new Date(txCustomEnd);
Expand All @@ -1429,8 +1430,15 @@
end = now;
}
const p = (n) => String(n).padStart(2, "0");
// 桶起点 UTC 对齐:hour → 所在小时;day → 所在 UTC 日的 00:00;
// week → 所在自然周的**周一** 00:00(`(getUTCDay()+6)%7` 即「距本周一的天数」,周一→0)。
// week 必须对齐到周一,才能与后端 strftime 的 `weekday 0, -6 days` 同口径 ——
// 否则补出的轴键是「今天减 7k 天」(星期几跟随今天),6/7 的星期几对不上后端桶键,
// 整周柱子被补成 0(见 C2094)。
const utcFloor = (d) => bucket === "hour"
? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours())
: bucket === "week"
? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - ((d.getUTCDay() + 6) % 7))
: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
const step = bucket === "hour" ? 36e5 : bucket === "week" ? 7 * 864e5 : 864e5;
// 窗口长度:跟随实际数据跨度(先算最早桶到右端的桶数),避免 24h/自定义短窗口补出几百根空柱
Expand Down
Loading