class ProgressUpdate(BaseModel):
group_id: Union[str, int]
op_type: str
+ duration_sec: int = 0
@field_validator('group_id', mode='before')
@classmethod
group_id: Union[str, int]
op_type: str
completed_at: str
+ duration_sec: int = 0
@field_validator('group_id', mode='before')
@classmethod
conn.close()
return {"status": "skipped", "date": now, "reason": "duplicate same-day record"}
cursor.execute(
- "INSERT INTO study_logs (group_id, op_type, status, created_at, log_date) VALUES (?, ?, ?, ?, ?)",
- (data.group_id, data.op_type, 'completed', now, today_prefix)
+ "INSERT INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?)",
+ (data.group_id, data.op_type, 'completed', now, today_prefix, data.duration_sec or 0)
)
conn.commit()
conn.close()
@app.get("/api/calendar")
async def get_calendar():
- # 按日期聚合 study_logs,供学习日历使用;同时计算每日学习时长(分钟)
- # 时长 = 当天最早一项到最后一项的时间跨度(中国时间)
- # 注意: created_at 历史记录为 UTC、新记录为中国时间, 用 CASE 归一化到中国时间(与 log_date 对齐),
- # 避免同一天里 UTC/中国时间混算导致跨度虚高
+ # 按日期聚合 study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟)
+ # 实际学习时长 = 当天各 (group,op) 完成时长(duration_sec)之和, 不含间隔, 即把各次学习时段相加
try:
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
cursor.execute("""
- SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id,
- CASE
- WHEN log_date IS NOT NULL AND substr(created_at,1,10) <> log_date
- THEN datetime(created_at, '+8 hours')
- ELSE created_at
- END as ct
+ SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id
FROM study_logs
WHERE status='completed'
ORDER BY day, op_type, CAST(group_id AS INTEGER)
""")
day_map = {}
- day_span = {} # day -> [min_ct, max_ct] (已归一化到中国时间)
- for day, op_type, group_id, ct in cursor.fetchall():
+ for day, op_type, group_id in cursor.fetchall():
day_map.setdefault(day, {}).setdefault(op_type, []).append(str(group_id))
- if ct:
- sp = day_span.setdefault(day, [ct, ct])
- if ct < sp[0]: sp[0] = ct
- if ct > sp[1]: sp[1] = ct
+ # 每日实际学习时长(秒) = 各 (group,op) 完成时长之和
+ cursor.execute("""
+ SELECT COALESCE(log_date, substr(created_at,1,10)) as day, COALESCE(SUM(duration_sec), 0) as total_sec
+ FROM study_logs
+ WHERE status='completed'
+ GROUP BY day
+ """)
+ day_total = {day: total for day, total in cursor.fetchall()}
conn.close()
op_order = ['study', 'test', 'review1', 'review2', 'review3']
result = []
acts = day_map[day]
activities = [{"op_type": op, "groups": acts[op]} for op in op_order if op in acts]
item = {"date": day, "activities": activities}
- if day in day_span:
- t0, t1 = day_span[day]
- try:
- dt0 = datetime.datetime.strptime(t0, "%Y-%m-%d %H:%M:%S")
- dt1 = datetime.datetime.strptime(t1, "%Y-%m-%d %H:%M:%S")
- # 向上取整到分钟
- item["duration_min"] = int((dt1 - dt0).total_seconds() + 59) // 60
- except Exception:
- pass
+ total = day_total.get(day, 0)
+ if total and total > 0:
+ item["duration_min"] = int(round(total / 60))
result.append(item)
return result
except Exception as e:
continue
completed_at = ev.completed_at or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_date = completed_at[:10]
+ duration_sec = ev.duration_sec or 0
cursor.execute(
- "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date) "
- "VALUES (?, ?, 'completed', ?, ?)",
- (ev.group_id, op, completed_at, log_date))
+ "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) "
+ "VALUES (?, ?, 'completed', ?, ?, ?)",
+ (ev.group_id, op, completed_at, log_date, duration_sec))
updated += 1
conn.commit()
conn.close()
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
cursor.execute(
- "SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)) "
+ "SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)), MAX(duration_sec) "
"FROM study_logs WHERE status='completed' GROUP BY group_id, op_type"
)
rows = cursor.fetchall()
conn.close()
- events = [{"group_id": str(r[0]), "op_type": r[1], "completed_at": r[2]}
+ events = [{"group_id": str(r[0]), "op_type": r[1], "completed_at": r[2], "duration_sec": r[3] or 0}
for r in rows if r[2]]
return {"status": "success", "events": events}
except Exception as e:
console.error("获取日历失败, 降级读离线缓存:", err);
const allProgress = await getAllProgress();
const tmp = {};
- const dayDur = {}; // day -> [min_completed_at, max_completed_at] 用于离线计算每日时长
+ const dayDurSec = {}; // day -> 累计学习秒数(离线按本地 duration_sec 求和, 与后端一致)
allProgress.forEach(p => {
const day = (p.completed_at || '').slice(0, 10);
if (!day) return;
if (!tmp[day][p.op_type]) tmp[day][p.op_type] = [];
const g = String(p.group_id);
if (!tmp[day][p.op_type].includes(g)) tmp[day][p.op_type].push(g);
- const t = p.completed_at;
- if (!dayDur[day]) dayDur[day] = [t, t];
- else {
- if (t < dayDur[day][0]) dayDur[day][0] = t;
- if (t > dayDur[day][1]) dayDur[day][1] = t;
- }
+ if (!dayDurSec[day]) dayDurSec[day] = 0;
+ dayDurSec[day] += (p.duration_sec || 0);
});
const m = {};
Object.keys(tmp).forEach(day => {
const activities = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] }));
- let durationMin = 0;
- if (dayDur[day]) {
- const a = new Date(dayDur[day][0].replace(' ', 'T'));
- const b = new Date(dayDur[day][1].replace(' ', 'T'));
- durationMin = Math.max(0, Math.ceil((b - a) / 60000));
- }
+ const totalSec = dayDurSec[day] || 0;
+ const durationMin = totalSec > 0 ? Math.round(totalSec / 60) : 0;
m[day] = { activities, durationMin };
});
setDayMap(m);
const { groupId } = useParams();
const navigate = useNavigate();
const isOnline = useOnlineStatus();
+ // 学习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
+ const sessionStartRef = useRef(Date.now());
const [words, setWords] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [phase, setPhase] = useState('learning'); // learning, flashcard, correction
if (submittedRef.current) return; // 防重入(按住Enter等场景)
submittedRef.current = true;
const now = localTimestamp();
+ // 本次学习实际时长(秒) = 完成时刻 - 会话开始时刻
+ const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
// 本地优先: 先写本地 dirty (必定保存, 不丢记录)
- await putProgress(groupId, 'study', now, true);
+ await putProgress(groupId, 'study', now, true, durationSec);
// 在线则后台自动同步 (成功清 dirty, 失败保留)
if (isOnline) triggerAutoSync();
alert("恭喜完成本组学习!");
// 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test)
const activityType = searchParams.get('type') || 'test';
const isOnline = useOnlineStatus();
+ // 测试/复习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
+ const sessionStartRef = useRef(Date.now());
const [words, setWords] = useState([]);
const [queue, setQueue] = useState([]); // 当前测试队列
const [wrongQueue, setWrongQueue] = useState([]); // 错词重考队列
if (submittedRef.current) return; // 防重入(按住Enter等场景)
submittedRef.current = true;
const now = localTimestamp();
+ // 本次测试/复习实际时长(秒) = 完成时刻 - 会话开始时刻
+ const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000));
// 本地优先: 先写本地 dirty (必定保存, 不丢记录)
- await putProgress(groupId, activityType, now, true);
+ await putProgress(groupId, activityType, now, true, durationSec);
// 在线则后台自动同步 (成功清 dirty, 失败保留)
if (isOnline) triggerAutoSync();
navigate('/');