Forráskód Böngészése

fix(review): preserve original learning dates in historical tasks

彭峰 9 órája
szülő
commit
65725e515e

+ 19 - 1
projects/xiaoshu-mobile/src/app/core/anti-forget.spec.ts

@@ -1,6 +1,24 @@
-import { buildAntiForgetCalendar, buildAntiForgetView, findAntiForgetList, mergeAntiForgetItems, nearestAntiForgetDate, summarizeAntiForgetHistory } from './anti-forget';
+import { antiForgetDate, buildAntiForgetCalendar, buildAntiForgetView, findAntiForgetList, mergeAntiForgetItems, nearestAntiForgetDate, summarizeAntiForgetHistory } from './anti-forget';
 
 describe('anti-forget view model', () => {
+  it('uses each historical planned day when timestamps all point to September 1', () => {
+    const tasks = [
+      { kywrq: '2026-08-01', kywsj: '2026-09-01 10:00:00', fxzt: 1 },
+      { kywrq: '2026-08-18', kywsj: '2026-09-01 10:00:00', fxzt: 0 },
+    ];
+    expect(tasks.map(antiForgetDate)).toEqual(['2026-08-01', '2026-08-18']);
+    const calendar = buildAntiForgetCalendar(tasks, new Date(2026, 7, 1), '2026-08-18', new Date(2026, 8, 2));
+    expect(calendar.scheduledDays).toBe(2);
+    expect(calendar.days.find(day => day.date === '2026-08-18')?.total).toBe(1);
+  });
+
+  it('falls back to legacy timestamps only when the planned day is blank or absent', () => {
+    expect(antiForgetDate({ kywrq: '  ', kywsj: '2026-08-18 09:00:00' })).toBe('2026-08-18');
+    expect(antiForgetDate({ kywrq: ' 2026-08-19 ', kywsj: '2026-09-01' })).toBe('2026-08-19');
+    expect(antiForgetDate({ fxrl: '2026-08-20' })).toBe('2026-08-20');
+    expect(antiForgetDate({ CreateTime: '2026-08-21 09:00:00' })).toBe('2026-08-21');
+  });
+
   it('maps source tasks into a stable 21-day progress view', () => {
     const view = buildAntiForgetView([
       { kywsj: '2026-08-10 08:00:00', fxzt: 1 },

+ 2 - 1
projects/xiaoshu-mobile/src/app/core/anti-forget.ts

@@ -232,7 +232,8 @@ export function nearestAntiForgetDate(items: AntiForgetItem[], now = new Date())
 }
 
 export function antiForgetDate(item: AntiForgetItem): string {
-  return String(item['kywsj'] ?? item['fxrl'] ?? item['CreateTime'] ?? '').slice(0, 10);
+  const plannedDate = String(item['kywrq'] ?? '').trim();
+  return (plannedDate || String(item['kywsj'] ?? item['fxrl'] ?? item['CreateTime'] ?? '')).slice(0, 10);
 }
 
 export function antiForgetCompleted(item: AntiForgetItem): boolean {

+ 29 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -1213,6 +1213,35 @@ describe('home learning modules', () => {
     expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
   });
 
+  it('shows the planned review day and original learning time for migrated August tasks', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    spyOn(api, 'post').and.callFake((action, params) => action === 'app_review_page'
+      ? of({ retcode: 1, result: {
+        items: [{ GeneralID: 102, yhid: 100, xxjlid: 900, kcid: 801, Title: '张雅茜@任务',
+          kywrq: params?.['date'], kywsj: '2026-09-01 09:00', CreateTime: '2026-09-01T02:25:17Z',
+          learned_at: '2026-07-31T01:20:00Z', learned_date: '20260731', fxzt: 0 }],
+        page: { itemCount: 1, pageCount: 1 }, summary: { total: 1, completed: 0, due: 1, today: 0, income: 0 },
+        dates: [{ date: '2026-08-01', total: 1, completed: 0 }, { date: '2026-08-15', total: 1, completed: 0 }],
+      } } as ApiEnvelope<any>) : post(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/stu_calendar?date=2026-08-01');
+    await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.task-context')?.textContent).toContain('复习日期 2026-08-01');
+    expect(harness.routeNativeElement?.querySelector('.first-learned-at')?.textContent).toContain('2026-07-31 09:20');
+    (harness.routeNativeElement?.querySelector('[aria-label^="2026-08-15"]') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.task-context')?.textContent).toContain('复习日期 2026-08-15');
+    const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    const task = component.calendarItems()[0];
+    expect(component.calendarFirstLearnedAt({ learned_date: '20260731' })).toContain('2026-07-31(时间未记录)');
+    component.openCalendar(task);
+    await harness.fixture.whenStable();
+    expect(TestBed.inject(Router).url).toContain('plannedDate=2026-08-15');
+  });
+
   it('keeps the anti-forget page visible while a selected date loads locally', async () => {
     const harness = await RouterTestingHarness.create();
     await harness.navigateByUrl('/pages/stu/stu_calendar');

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1243,7 +1243,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   calendarReady(item: SourceItem): boolean {
-    const due = String(item['kywsj'] ?? '').slice(0, 10);
+    const due = antiForgetDate(item);
     return Boolean(due && Date.parse(due) <= Date.parse(this.dateOnly()) && Number(item['fxzt'] ?? 0) !== 1);
   }
 

+ 2 - 1
scripts/cloud/mobile-teaching.js

@@ -51,7 +51,8 @@ async function appReviewPage(input,current) {
   if(input.status==='completed')clauses.push('m."fxzt"=\'1\'');
   if(input.status==='pending')clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\'');
   const where=clauses.join(' AND '),countPromise=Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
-  const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",COALESCE(n."nodeName",CASE WHEN '+notesSource+' THEN \'语法写作复习\' END) AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",'+reviewStudentNameSql()+' AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
+  // Historical createdAt is the import time; the study model preserves the original learning time.
+  const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",COALESCE(n."nodeName",CASE WHEN '+notesSource+' THEN \'语法写作复习\' END) AS "kc_title",d."dqrq" AS "learned_date",dc."createTime" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",'+reviewStudentNameSql()+' AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
   const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?Psql.one('SELECT COALESCE(SUM(r."amount"),0) AS income FROM "MemoryPracticeRecord" m JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" WHERE '+where,args).then(row=>number(row.income)):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
   return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,todayTotal:number(summary.today),todayCompleted:number(summary.todayCompleted),todayPending:number(summary.today)-number(summary.todayCompleted),overduePending:number(summary.overduePending),income,incomeScope:input.month||'all'},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions};

+ 1 - 1
scripts/tests/fixtures/teacher-review-schema.sql

@@ -1,5 +1,5 @@
 CREATE TABLE "MemoryPracticeRecord"("objectId" text,company text,id numeric,plid numeric,yhid numeric,xxjlid numeric,kcid numeric,fxzt text,kywrq text,kywsj text);
-  CREATE TABLE "CommonModel"(company text,"modelId" numeric,"generalId" numeric,"itemId" numeric,title text);
+  CREATE TABLE "CommonModel"(company text,"modelId" numeric,"generalId" numeric,"itemId" numeric,title text,"createTime" timestamptz);
   CREATE TABLE "DailyStudyRecord"(company text,id numeric,learned numeric,xxqs text,dqrq text,"createdAt" timestamptz);
   CREATE TABLE "Node"(company text,"nodeId" numeric,"nodeName" text);
   CREATE TABLE "_User"(company text,"legacyUserId" numeric,"realName" text,nickname text,username text,"legacyUserData" jsonb);

+ 14 - 0
scripts/tests/review-student-names.test.mjs

@@ -4,6 +4,20 @@ import {readFileSync} from 'node:fs';
 import vm from 'node:vm';
 import {PGlite} from '@electric-sql/pglite';
 const source=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8');
+test('历史复习首次学习时间来自原始学习记录,不显示九月一日迁移时间',async()=>{
+ const db=new PGlite();try{
+  await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+  await db.exec(`INSERT INTO "CommonModel"(company,"modelId","generalId","itemId","createTime") VALUES('co',56,100,101,'2026-07-31T01:20:00Z');
+   INSERT INTO "DailyStudyRecord"(company,id,learned,xxqs,dqrq,"createdAt") VALUES('co',101,1,'[]','20260731','2026-09-01T02:25:17Z');`);
+  const query=async(s,a)=>(await db.query(s,a)).rows;
+  const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),Psql:{query,one:async(s,a)=>(await query(s,a))[0]}});vm.runInContext(source,ctx);
+  const page=()=>ctx.appReviewPage({date:'2026-10-14',includeIncome:false},{get:()=>3});
+  const result=await page();assert.equal(result.items.length,2);
+  assert.ok(result.items.every(row=>new Date(row.learned_at).toISOString()==='2026-07-31T01:20:00.000Z'));
+  await db.exec('UPDATE "CommonModel" SET "createTime"=NULL WHERE "modelId"=56');
+  const dateOnly=await page();assert.ok(dateOnly.items.every(row=>row.learned_at==null&&row.learned_date==='20260731'));
+ }finally{await db.close();}
+});
 test('复习姓名跳过空白,保留真实姓名、昵称、旧姓名及历史任务姓名,无法确认时显示学员编号',async()=>{
  const expression=vm.runInNewContext(source+';reviewStudentNameSql()'),db=new PGlite();
  try{