2026-05-09 18:25:04 +08:00
|
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
|
import { LearningActivityRepository } from './learning-activity.repository';
|
2026-05-18 10:07:57 +08:00
|
|
|
import { LearningTrendWorkflow } from '../ai/workflows/learning-trend.workflow';
|
2026-05-09 18:25:04 +08:00
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class LearningActivityService {
|
2026-05-18 10:07:57 +08:00
|
|
|
constructor(
|
|
|
|
|
private readonly repository: LearningActivityRepository,
|
|
|
|
|
private readonly trendWorkflow: LearningTrendWorkflow,
|
|
|
|
|
) {}
|
2026-05-09 18:25:04 +08:00
|
|
|
|
2026-05-17 00:39:46 +08:00
|
|
|
async getHeatmap(userId: string) {
|
|
|
|
|
const activities = await this.repository.findAll(userId);
|
2026-05-09 18:25:04 +08:00
|
|
|
const heatmap: Record<string, number> = {};
|
|
|
|
|
for (const a of activities) {
|
2026-05-17 00:39:46 +08:00
|
|
|
const dateStr = a.activityDate instanceof Date
|
|
|
|
|
? a.activityDate.toISOString().split('T')[0]
|
|
|
|
|
: String(a.activityDate).split('T')[0];
|
|
|
|
|
heatmap[dateStr] = a.durationSeconds;
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
return heatmap;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 00:39:46 +08:00
|
|
|
async getSummary(userId: string) {
|
|
|
|
|
const activities = await this.repository.findAll(userId);
|
|
|
|
|
const totalMinutes = Math.round(
|
|
|
|
|
activities.reduce((s, a) => s + a.durationSeconds, 0) / 60,
|
|
|
|
|
);
|
|
|
|
|
const totalCards = activities.reduce((s, a) => s + a.reviewCount, 0);
|
|
|
|
|
const activeDays = activities.filter((a) => a.durationSeconds > 0).length;
|
2026-05-09 18:25:04 +08:00
|
|
|
const dailyAverage = activeDays > 0 ? Math.round(totalMinutes / activeDays) : 0;
|
|
|
|
|
return { totalMinutes, totalCardsReviewed: totalCards, activeDays, dailyAverage };
|
|
|
|
|
}
|
2026-05-18 10:07:57 +08:00
|
|
|
|
|
|
|
|
async getTrend(userId: string, periodDays: number = 7) {
|
|
|
|
|
const activities = await this.repository.findAll(userId);
|
|
|
|
|
|
|
|
|
|
const cutoff = new Date();
|
|
|
|
|
cutoff.setDate(cutoff.getDate() - periodDays);
|
|
|
|
|
const recent = activities.filter((a) =>
|
|
|
|
|
new Date(a.activityDate) >= cutoff
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const previousCutoff = new Date(cutoff);
|
|
|
|
|
previousCutoff.setDate(previousCutoff.getDate() - periodDays);
|
|
|
|
|
const previous = activities.filter((a) => {
|
|
|
|
|
const d = new Date(a.activityDate);
|
|
|
|
|
return d >= previousCutoff && d < cutoff;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sum = (items: typeof activities, key: keyof typeof activities[0]) =>
|
|
|
|
|
items.reduce((s, a) => s + (Number(a[key]) || 0), 0);
|
|
|
|
|
|
|
|
|
|
const recentTotalMinutes = Math.round(sum(recent, 'durationSeconds') / 60);
|
|
|
|
|
const recentSessions = sum(recent, 'sessionsCount');
|
|
|
|
|
const recentRecall = sum(recent, 'activeRecallCount');
|
|
|
|
|
const recentReview = sum(recent, 'reviewCount');
|
|
|
|
|
const recentAiAnalysis = sum(recent, 'aiAnalysisCount');
|
|
|
|
|
const recentLoops = sum(recent, 'completedLoopCount');
|
|
|
|
|
const recentActiveDays = recent.filter((a) => a.durationSeconds > 0).length;
|
|
|
|
|
const recentDailyAvg = recentActiveDays > 0
|
|
|
|
|
? Math.round(recentTotalMinutes / recentActiveDays) : 0;
|
|
|
|
|
const recentActivityLevel = recent.length > 0
|
|
|
|
|
? Math.round(recent.reduce((s, a) => s + a.activityLevel, 0) / recent.length)
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
const prevTotalMinutes = Math.round(sum(previous, 'durationSeconds') / 60);
|
|
|
|
|
|
2026-06-05 20:12:18 +08:00
|
|
|
// Build daily time-series for chart rendering
|
|
|
|
|
const dailySeries = this.buildDailySeries(recent, periodDays);
|
|
|
|
|
|
2026-05-18 10:07:57 +08:00
|
|
|
const trendInput = {
|
|
|
|
|
userId,
|
|
|
|
|
periodDays,
|
|
|
|
|
totalMinutes: recentTotalMinutes,
|
|
|
|
|
sessionsCount: recentSessions,
|
|
|
|
|
activeRecallCount: recentRecall,
|
|
|
|
|
reviewCount: recentReview,
|
|
|
|
|
aiAnalysisCount: recentAiAnalysis,
|
|
|
|
|
completedLoopCount: recentLoops,
|
|
|
|
|
activityLevel: recentActivityLevel,
|
|
|
|
|
activeDays: recentActiveDays,
|
|
|
|
|
dailyAverage: recentDailyAvg,
|
|
|
|
|
previousPeriod: previous.length > 0 ? {
|
|
|
|
|
totalMinutes: prevTotalMinutes,
|
|
|
|
|
activeRecallCount: sum(previous, 'activeRecallCount'),
|
|
|
|
|
reviewCount: sum(previous, 'reviewCount'),
|
|
|
|
|
activeDays: previous.filter((a) => a.durationSeconds > 0).length,
|
|
|
|
|
} : undefined,
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-06 12:07:40 +08:00
|
|
|
// AI analysis with 15s timeout fallback
|
|
|
|
|
let aiResult: any = {};
|
|
|
|
|
try {
|
|
|
|
|
aiResult = await Promise.race([
|
|
|
|
|
this.trendWorkflow.execute(trendInput),
|
|
|
|
|
new Promise<any>((_, reject) =>
|
|
|
|
|
setTimeout(() => reject(new Error('AI trend analysis timeout')), 15000)
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
aiResult = { periodSummary: 'AI 分析暂不可用', overallScore: 0, overallDirection: 'stable', trends: [], strengths: [], weaknesses: [], recommendations: [], nextFocusAreas: [] };
|
|
|
|
|
}
|
2026-06-05 20:12:18 +08:00
|
|
|
return { ...aiResult, dailySeries };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildDailySeries(activities: any[], days: number) {
|
|
|
|
|
const series: { date: string; value: number; label: string }[] = [];
|
|
|
|
|
const now = new Date();
|
|
|
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
|
|
|
const d = new Date(now);
|
|
|
|
|
d.setDate(d.getDate() - i);
|
2026-06-06 12:07:40 +08:00
|
|
|
// Use local date string to avoid UTC timezone shift issues
|
|
|
|
|
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
2026-06-05 20:12:18 +08:00
|
|
|
const dayActs = activities.filter((a) => {
|
|
|
|
|
const ad = a.activityDate instanceof Date ? a.activityDate : new Date(a.activityDate);
|
2026-06-06 12:07:40 +08:00
|
|
|
const adStr = `${ad.getFullYear()}-${String(ad.getMonth() + 1).padStart(2, '0')}-${String(ad.getDate()).padStart(2, '0')}`;
|
|
|
|
|
return adStr === dateStr;
|
2026-06-05 20:12:18 +08:00
|
|
|
});
|
|
|
|
|
const minutes = Math.round(dayActs.reduce((s: number, a: any) => s + a.durationSeconds, 0) / 60);
|
|
|
|
|
series.push({
|
|
|
|
|
date: dateStr,
|
|
|
|
|
value: minutes,
|
|
|
|
|
label: `${minutes}分钟`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return series;
|
2026-05-18 10:07:57 +08:00
|
|
|
}
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|