feat: M3-03 — Growth & Retention, streak + recommendations
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 19s
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 19s
- GrowthService: streak calculation from DailyLearningActivity - Recommendations: focus items, due review cards, new knowledge items - New API: GET /api/activity/streak, GET /api/activity/recommendations Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
cddcf57a93
commit
098b8055f5
90
src/modules/learning-activity/growth.service.ts
Normal file
90
src/modules/learning-activity/growth.service.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class GrowthService {
|
||||
private readonly logger = new Logger(GrowthService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Calculate streak from daily activity */
|
||||
async getStreak(userId: string): Promise<{ currentStreak: number; longestStreak: number }> {
|
||||
const activities = await this.prisma.dailyLearningActivity.findMany({
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
select: { date: true },
|
||||
distinct: ['date'],
|
||||
take: 365,
|
||||
});
|
||||
|
||||
if (activities.length === 0) return { currentStreak: 0, longestStreak: 0 };
|
||||
|
||||
const dates = activities.map(a => new Date(a.date).toISOString().slice(0, 10));
|
||||
let currentStreak = 1;
|
||||
let longestStreak = 1;
|
||||
let streak = 1;
|
||||
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
const prev = new Date(dates[i - 1]);
|
||||
const curr = new Date(dates[i]);
|
||||
const diffDays = Math.round((prev.getTime() - curr.getTime()) / 86400000);
|
||||
if (diffDays === 1) {
|
||||
streak++;
|
||||
longestStreak = Math.max(longestStreak, streak);
|
||||
} else if (diffDays === 0) {
|
||||
// same day, skip
|
||||
} else {
|
||||
streak = 1;
|
||||
}
|
||||
}
|
||||
currentStreak = streak;
|
||||
|
||||
return { currentStreak, longestStreak };
|
||||
}
|
||||
|
||||
/** Get learning recommendation based on mastery */
|
||||
async getRecommendations(userId: string): Promise<{ type: string; title: string; reason: string }[]> {
|
||||
const recommendations: { type: string; title: string; reason: string }[] = [];
|
||||
|
||||
// Find pending focus items
|
||||
const focusCount = await this.prisma.focusItem.count({
|
||||
where: { userId, status: 'pending' },
|
||||
});
|
||||
|
||||
if (focusCount > 0) {
|
||||
recommendations.push({
|
||||
type: 'focus', title: `${focusCount} 个待巩固项`, reason: '巩固薄弱环节,提升掌握度',
|
||||
});
|
||||
}
|
||||
|
||||
// Find due review cards
|
||||
const now = new Date();
|
||||
const dueCards = await this.prisma.reviewCard.count({
|
||||
where: { userId, nextReviewAt: { lte: now }, status: 'active' },
|
||||
});
|
||||
|
||||
if (dueCards > 0) {
|
||||
recommendations.push({
|
||||
type: 'review', title: `${dueCards} 张复习卡到期`, reason: '间隔复习,巩固长期记忆',
|
||||
});
|
||||
}
|
||||
|
||||
// Suggest new knowledge items if no pending items
|
||||
if (focusCount === 0 && dueCards === 0) {
|
||||
const itemsCount = await this.prisma.knowledgeItem.count({
|
||||
where: { userId, deletedAt: null, learnable: true },
|
||||
});
|
||||
if (itemsCount > 0) {
|
||||
recommendations.push({
|
||||
type: 'learn', title: `${itemsCount} 个可学习知识点`, reason: '继续拓展知识库',
|
||||
});
|
||||
} else {
|
||||
recommendations.push({
|
||||
type: 'import', title: '导入新资料', reason: '开始学习新内容',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,17 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { LearningActivityService } from './learning-activity.service';
|
||||
import { GrowthService } from './growth.service';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { UserPayload } from '../../common/types';
|
||||
|
||||
@ApiTags('learning-activity')
|
||||
@Controller('activity')
|
||||
export class LearningActivityController {
|
||||
constructor(private readonly activityService: LearningActivityService) {}
|
||||
constructor(
|
||||
private readonly activityService: LearningActivityService,
|
||||
private readonly growth: GrowthService,
|
||||
) {}
|
||||
|
||||
@Get('heatmap')
|
||||
@ApiOperation({ summary: '获取学习热力图数据' })
|
||||
@ -23,14 +27,20 @@ export class LearningActivityController {
|
||||
|
||||
@Get('trend')
|
||||
@ApiOperation({ summary: '获取 AI 学习趋势分析' })
|
||||
async getTrend(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Query('days') days?: string,
|
||||
) {
|
||||
async getTrend(@CurrentUser() user: UserPayload, @Query('days') days?: string) {
|
||||
const periodDays = parseInt(days || '7', 10);
|
||||
return this.activityService.getTrend(
|
||||
String(user?.id || 'anonymous'),
|
||||
Math.min(Math.max(periodDays, 7), 30),
|
||||
);
|
||||
return this.activityService.getTrend(String(user?.id || 'anonymous'), Math.min(Math.max(periodDays, 7), 30));
|
||||
}
|
||||
|
||||
@Get('streak')
|
||||
@ApiOperation({ summary: '获取连续学习天数' })
|
||||
async getStreak(@CurrentUser() user: UserPayload) {
|
||||
return this.growth.getStreak(String(user?.id || 'anonymous'));
|
||||
}
|
||||
|
||||
@Get('recommendations')
|
||||
@ApiOperation({ summary: '获取下一步学习推荐' })
|
||||
async getRecommendations(@CurrentUser() user: UserPayload) {
|
||||
return this.growth.getRecommendations(String(user?.id || 'anonymous'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,12 @@ import { AiModule } from '../ai/ai.module';
|
||||
import { LearningActivityController } from './learning-activity.controller';
|
||||
import { LearningActivityService } from './learning-activity.service';
|
||||
import { LearningActivityRepository } from './learning-activity.repository';
|
||||
import { GrowthService } from './growth.service';
|
||||
|
||||
@Module({
|
||||
imports: [AiModule],
|
||||
controllers: [LearningActivityController],
|
||||
providers: [LearningActivityService, LearningActivityRepository],
|
||||
exports: [LearningActivityService],
|
||||
providers: [LearningActivityService, LearningActivityRepository, GrowthService],
|
||||
exports: [LearningActivityService, GrowthService],
|
||||
})
|
||||
export class LearningActivityModule {}
|
||||
|
||||
@ -66,4 +66,22 @@ describe('M3 E2E Tests', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// M3-03: Growth & Retention
|
||||
// ══════════════════════════════════════════════
|
||||
describe('M3-03 Growth & Retention', () => {
|
||||
it('GET /api/activity/streak → 401 without token', async () => {
|
||||
await request(app.getHttpServer()).get('/api/activity/streak').expect(401);
|
||||
});
|
||||
|
||||
it('GET /api/activity/recommendations → endpoint exists', async () => {
|
||||
await request(app.getHttpServer()).get('/api/activity/recommendations').expect(401);
|
||||
});
|
||||
|
||||
it('GrowthService registered (app starts cleanly)', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api').expect(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user