@app.get("/api/calendar")
async def get_calendar():
- # 按日期聚合 study_logs,供学习日历使用
+ # 按日期聚合 study_logs,供学习日历使用;同时计算每日学习时长(分钟)
+ # 时长 = 当天最早一项到最后一项的时间跨度(中国时间)
+ # 注意: created_at 历史记录为 UTC、新记录为中国时间, 用 CASE 归一化到中国时间(与 log_date 对齐),
+ # 避免同一天里 UTC/中国时间混算导致跨度虚高
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
+ 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
FROM study_logs
WHERE status='completed'
ORDER BY day, op_type, CAST(group_id AS INTEGER)
""")
day_map = {}
- for day, op_type, group_id in cursor.fetchall():
+ day_span = {} # day -> [min_ct, max_ct] (已归一化到中国时间)
+ for day, op_type, group_id, ct 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
conn.close()
op_order = ['study', 'test', 'review1', 'review2', 'review3']
result = []
for day in sorted(day_map.keys()):
acts = day_map[day]
activities = [{"op_type": op, "groups": acts[op]} for op in op_order if op in acts]
- result.append({"date": day, "activities": activities})
+ 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
+ result.append(item)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
api.getCalendar()
.then(res => {
const m = {};
- res.data.forEach(item => { m[item.date] = item.activities; });
+ res.data.forEach(item => {
+ m[item.date] = { activities: item.activities, durationMin: item.duration_min || 0 };
+ });
setDayMap(m);
})
.catch(async (err) => {
console.error("获取日历失败, 降级读离线缓存:", err);
const allProgress = await getAllProgress();
const tmp = {};
+ const dayDur = {}; // day -> [min_completed_at, max_completed_at] 用于离线计算每日时长
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;
+ }
});
const m = {};
Object.keys(tmp).forEach(day => {
- m[day] = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] }));
+ 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));
+ }
+ m[day] = { activities, durationMin };
});
setDayMap(m);
});
const studiedGroups = new Set();
Object.keys(dayMap).forEach(dateStr => {
if (dateStr.slice(0, 7) === monthStr) {
- dayMap[dateStr].forEach(a => {
+ const info = dayMap[dateStr];
+ if (info && info.activities) info.activities.forEach(a => {
if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g));
});
}
{cells.map((d, i) => {
if (d === null) return <div className="cal-day empty" key={i} />;
const dateStr = `${monthStr}-${pad(d)}`;
- const acts = dayMap[dateStr];
+ const dayInfo = dayMap[dateStr];
+ const acts = dayInfo ? dayInfo.activities : undefined;
+ const durationMin = dayInfo ? dayInfo.durationMin : 0;
const isActive = !!acts;
const isToday = dateStr === todayStr;
const cls = ['cal-day'];
if (isToday) cls.push('today');
return (
<div className={cls.join(' ')} key={i}>
- <div className="cal-day-num">{d}</div>
+ <div className="cal-day-head">
+ {durationMin > 0 && (
+ <span className="cal-duration">{durationMin}分钟</span>
+ )}
+ <div className="cal-day-num">{d}</div>
+ </div>
{acts && acts.map((a, j) => (
<div className="cal-act" key={j} style={{ color: OP_COLOR[a.op_type] || '#475569' }}>
{OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')}