]> acesimba.cloud Git - words.git/commitdiff
feat: 学习日历显示每日实际学习时长(各次学习时段之和, 不含间隔)
authorroot <root@localhost.localdomain>
Tue, 18 Aug 2026 15:22:50 +0000 (23:22 +0800)
committerroot <root@localhost.localdomain>
Tue, 18 Aug 2026 15:22:50 +0000 (23:22 +0800)
- 改为方案B: 记录每次学习的真实起止时长并按时长求和
- 前端 StudyMode/TestMode: 进入时记录 sessionStartRef, 完成时计算 durationSec 传入 putProgress
- db.putProgress 增加 duration_sec 字段; sync push/pull 携带 duration_sec(双向同步)
- 后端 SyncEvent/ProgressUpdate 增加 duration_sec; sync/push 写入, sync/pull 返回
- /api/calendar 改为按日 SUM(duration_sec) 得实际学习分钟(替代之前的跨度算法)
- 日历离线降级同样按本地 duration_sec 求和; 日期左侧"N分钟"徽标不变
- 历史记录 duration_sec 多为0, 故旧日期不显示时长, 仅新学习的日期开始累计

backend/main.py
frontend/src/components/StudyCalendar.js
frontend/src/components/StudyMode.js
frontend/src/components/TestMode.js
frontend/src/db.js
frontend/src/sync.js

index 74302fe0dd428569dbb0e42918df14e4e331f6c4..a63f59202850ae713665d37edc5c1f3759d1874f 100644 (file)
@@ -56,6 +56,7 @@ class WordData(BaseModel):
 class ProgressUpdate(BaseModel):
     group_id: Union[str, int]
     op_type: str
+    duration_sec: int = 0
 
     @field_validator('group_id', mode='before')
     @classmethod
@@ -66,6 +67,7 @@ class SyncEvent(BaseModel):
     group_id: Union[str, int]
     op_type: str
     completed_at: str
+    duration_sec: int = 0
 
     @field_validator('group_id', mode='before')
     @classmethod
@@ -339,8 +341,8 @@ async def update_progress(data: ProgressUpdate):
             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()
@@ -350,32 +352,28 @@ async def update_progress(data: ProgressUpdate):
 
 @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 = []
@@ -383,15 +381,9 @@ async def get_calendar():
             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:
@@ -421,10 +413,11 @@ async def sync_push(data: SyncPushRequest):
                 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()
@@ -440,12 +433,12 @@ async def sync_pull():
         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:
index 83b207469b2ab5a5e3bc16660700939daec12d11..dceb13dba2a2ec04a3dbc3bdf05ea4e9b3a5ba3b 100644 (file)
@@ -29,7 +29,7 @@ function StudyCalendar() {
                 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;
@@ -37,22 +37,14 @@ function StudyCalendar() {
                     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);
index b4a501f75f17350de174d1c7fff4ae560851483e..52a1de74c9ecd6e2661982c420b7b215907e7eeb 100644 (file)
@@ -10,6 +10,8 @@ function StudyMode() {
     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
@@ -102,8 +104,10 @@ function StudyMode() {
             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("恭喜完成本组学习!");
index 1ebd1fb1a5780b2743b4de474750e04cf3466a0a..f228eba1a75b602c49757850f723866368ea6726 100644 (file)
@@ -13,6 +13,8 @@ function TestMode() {
     // 活动类型: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([]); // 错词重考队列
@@ -144,8 +146,10 @@ function TestMode() {
         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('/');
index 325fcbbeb667ed714d7eb91ff39363827ccc3a15..a3603ecf9d085ed913f8f6622d5024899098fba9 100644 (file)
@@ -81,12 +81,12 @@ export async function getAllProgress() {
     return singleReq('progress', 'readonly', s => s.getAll());
 }
 
-export async function putProgress(group_id, op_type, completed_at, dirty) {
+export async function putProgress(group_id, op_type, completed_at, dirty, duration_sec = 0) {
     const key = `${group_id}:${op_type}`;
     const db = await openDB();
     return new Promise((resolve, reject) => {
         const t = db.transaction('progress', 'readwrite');
-        t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty });
+        t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty, duration_sec: duration_sec || 0 });
         t.oncomplete = () => resolve();
         t.onerror = () => reject(t.error);
     });
index d349a2e10e7c563ed3e4b7ab9bdc8eb522d2403b..7009d2340cb1fa8396abd750161245acc4b017c2 100644 (file)
@@ -44,6 +44,7 @@ export async function syncNow() {
             group_id: p.group_id,
             op_type: p.op_type,
             completed_at: p.completed_at,
+            duration_sec: p.duration_sec || 0,
         }));
         const r = await api.syncPush(events);
         result.pushed = r.data.updated || events.length;
@@ -62,7 +63,7 @@ export async function syncNow() {
         const local = localAll.find(p => p.key === key);
         if (!local) {
             // 本地缺失: 直接补入(布尔并集)
-            await putProgress(ev.group_id, ev.op_type, ev.completed_at, false);
+            await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
             result.pulled++;
             result.pulledEvents.push(ev);
         } else {
@@ -71,7 +72,7 @@ export async function syncNow() {
             const localDate = (local.completed_at || '').slice(0, 10);
             const serverDate = (ev.completed_at || '').slice(0, 10);
             if (serverDate > localDate) {
-                await putProgress(ev.group_id, ev.op_type, ev.completed_at, false);
+                await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0);
                 result.pulled++;
                 result.pulledEvents.push(ev);
             }