init
This commit is contained in:
29
server/src/services/auth.service.js
Normal file
29
server/src/services/auth.service.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { User } from '../models/User.js';
|
||||
|
||||
export const loginUser = async (apiKey) => {
|
||||
const response = await fetch('https://api.wanikani.com/v2/user', {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Invalid API Key');
|
||||
}
|
||||
|
||||
let user = await User.findOne({ wkApiKey: apiKey });
|
||||
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
wkApiKey: apiKey,
|
||||
tokenVersion: 0,
|
||||
stats: { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 },
|
||||
settings: { batchSize: 20 }
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const logoutUser = async (userId) => {
|
||||
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
|
||||
return true;
|
||||
};
|
||||
85
server/src/services/review.service.js
Normal file
85
server/src/services/review.service.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ReviewLog } from '../models/ReviewLog.js';
|
||||
import { StudyItem } from '../models/StudyItem.js';
|
||||
import { getDaysDiff, getSRSDate } from '../utils/dateUtils.js';
|
||||
|
||||
export const processReview = async (user, subjectId, success) => {
|
||||
if (!user.stats) user.stats = { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 };
|
||||
user.stats.totalReviews += 1;
|
||||
if (success) user.stats.correctReviews += 1;
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const lastStudyStr = user.stats.lastStudyDate;
|
||||
|
||||
if (lastStudyStr !== todayStr) {
|
||||
if (!lastStudyStr) {
|
||||
user.stats.currentStreak = 1;
|
||||
} else {
|
||||
const diff = getDaysDiff(lastStudyStr, todayStr);
|
||||
if (diff === 1) {
|
||||
user.stats.currentStreak += 1;
|
||||
} else if (diff > 1) {
|
||||
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
|
||||
let daysSinceFreeze = 999;
|
||||
if (lastFreeze) daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
|
||||
|
||||
const canUseShield = daysSinceFreeze >= 7;
|
||||
|
||||
if (canUseShield && diff === 2) {
|
||||
console.log(`User ${user._id} saved by Zen Shield!`);
|
||||
user.stats.lastFreezeDate = new Date();
|
||||
user.stats.currentStreak += 1;
|
||||
} else {
|
||||
user.stats.currentStreak = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
user.stats.lastStudyDate = todayStr;
|
||||
if (user.stats.currentStreak > user.stats.maxStreak) user.stats.maxStreak = user.stats.currentStreak;
|
||||
}
|
||||
await user.save();
|
||||
|
||||
await ReviewLog.findOneAndUpdate(
|
||||
{ userId: user._id, date: todayStr },
|
||||
{ $inc: { count: 1 } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
const item = await StudyItem.findOne({ userId: user._id, wkSubjectId: subjectId });
|
||||
if (!item) throw new Error('Item not found');
|
||||
|
||||
if (!item.stats) item.stats = { correct: 0, total: 0 };
|
||||
item.stats.total += 1;
|
||||
if (success) item.stats.correct += 1;
|
||||
|
||||
if (success) {
|
||||
const nextLevel = Math.min(item.srsLevel + 1, 10);
|
||||
item.srsLevel = nextLevel;
|
||||
item.nextReview = getSRSDate(nextLevel);
|
||||
} else {
|
||||
item.srsLevel = Math.max(1, item.srsLevel - 1);
|
||||
item.nextReview = Date.now();
|
||||
}
|
||||
|
||||
await item.save();
|
||||
return { nextReview: item.nextReview, srsLevel: item.srsLevel };
|
||||
};
|
||||
|
||||
export const getQueue = async (user, limit = 100, sortMode) => {
|
||||
const query = {
|
||||
userId: user._id,
|
||||
srsLevel: { $lt: 10, $gt: 0 },
|
||||
nextReview: { $lte: new Date() }
|
||||
};
|
||||
|
||||
let dueItems;
|
||||
if (sortMode === 'priority') {
|
||||
dueItems = await StudyItem.find(query).sort({ srsLevel: 1, level: 1 }).limit(limit);
|
||||
} else {
|
||||
dueItems = await StudyItem.find(query).limit(limit);
|
||||
for (let i = dueItems.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[dueItems[i], dueItems[j]] = [dueItems[j], dueItems[i]];
|
||||
}
|
||||
}
|
||||
return dueItems;
|
||||
};
|
||||
112
server/src/services/stats.service.js
Normal file
112
server/src/services/stats.service.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { StudyItem } from '../models/StudyItem.js';
|
||||
import { ReviewLog } from '../models/ReviewLog.js';
|
||||
import { getDaysDiff } from '../utils/dateUtils.js';
|
||||
|
||||
export const getUserStats = async (user) => {
|
||||
const userId = user._id;
|
||||
|
||||
const srsCounts = await StudyItem.aggregate([
|
||||
{ $match: { userId: userId } },
|
||||
{ $group: { _id: "$srsLevel", count: { $sum: 1 } } }
|
||||
]);
|
||||
const dist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
||||
srsCounts.forEach(g => { if (dist[g._id] !== undefined) dist[g._id] = g.count; });
|
||||
|
||||
const now = new Date();
|
||||
const next24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
const upcoming = await StudyItem.find({
|
||||
userId: userId,
|
||||
srsLevel: { $lt: 6, $gt: 0 },
|
||||
nextReview: { $lte: next24h }
|
||||
}).select('nextReview');
|
||||
|
||||
const forecast = new Array(24).fill(0);
|
||||
upcoming.forEach(item => {
|
||||
const diff = Math.floor((new Date(item.nextReview) - now) / 3600000);
|
||||
if (diff < 0) forecast[0]++;
|
||||
else if (diff < 24) forecast[diff]++;
|
||||
});
|
||||
|
||||
const queueItems = await StudyItem.find({
|
||||
userId: userId,
|
||||
srsLevel: { $lt: 6, $gt: 0 },
|
||||
nextReview: { $lte: now }
|
||||
}).select('srsLevel');
|
||||
const queueCount = queueItems.length;
|
||||
|
||||
let hasLowerLevels = false;
|
||||
let lowerLevelCount = 0;
|
||||
if (queueCount > 0) {
|
||||
const levels = queueItems.map(i => i.srsLevel);
|
||||
const minSrs = Math.min(...levels);
|
||||
const maxSrs = Math.max(...levels);
|
||||
if (minSrs < maxSrs) {
|
||||
hasLowerLevels = true;
|
||||
lowerLevelCount = queueItems.filter(i => i.srsLevel === minSrs).length;
|
||||
}
|
||||
}
|
||||
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
|
||||
const dateStr = oneYearAgo.toISOString().split('T')[0];
|
||||
const logs = await ReviewLog.find({ userId: userId, date: { $gte: dateStr } });
|
||||
const heatmap = {};
|
||||
logs.forEach(l => { heatmap[l.date] = l.count; });
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const lastStudyStr = user.stats.lastStudyDate || null;
|
||||
let displayStreak = user.stats.currentStreak || 0;
|
||||
|
||||
if (lastStudyStr) {
|
||||
const diff = getDaysDiff(lastStudyStr, todayStr);
|
||||
if (diff > 1) displayStreak = 0;
|
||||
}
|
||||
|
||||
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
|
||||
let daysSinceFreeze = 999;
|
||||
if (lastFreeze) {
|
||||
daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
|
||||
}
|
||||
|
||||
const shieldReady = daysSinceFreeze >= 7;
|
||||
const shieldCooldown = shieldReady ? 0 : (7 - daysSinceFreeze);
|
||||
|
||||
const history7Days = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const dStr = d.toISOString().split('T')[0];
|
||||
const count = heatmap[dStr] || 0;
|
||||
history7Days.push({ date: dStr, active: count > 0 });
|
||||
}
|
||||
|
||||
const allGhosts = await StudyItem.find({ userId: userId, 'stats.total': { $gte: 2 } })
|
||||
.select('char meaning stats srsLevel');
|
||||
|
||||
const sortedGhosts = allGhosts.map(item => ({
|
||||
...item.toObject(),
|
||||
accuracy: Math.round((item.stats.correct / item.stats.total) * 100)
|
||||
})).filter(i => i.accuracy < 85)
|
||||
.sort((a, b) => a.accuracy - b.accuracy)
|
||||
.slice(0, 4);
|
||||
|
||||
return {
|
||||
distribution: dist,
|
||||
forecast: forecast,
|
||||
queueLength: queueCount,
|
||||
hasLowerLevels,
|
||||
lowerLevelCount,
|
||||
heatmap: heatmap,
|
||||
ghosts: sortedGhosts,
|
||||
streak: {
|
||||
current: displayStreak,
|
||||
best: user.stats.maxStreak || 0,
|
||||
shield: { ready: shieldReady, cooldown: Math.max(0, shieldCooldown) },
|
||||
history: history7Days
|
||||
},
|
||||
accuracy: {
|
||||
total: user.stats.totalReviews || 0,
|
||||
correct: user.stats.correctReviews || 0
|
||||
}
|
||||
};
|
||||
};
|
||||
78
server/src/services/sync.service.js
Normal file
78
server/src/services/sync.service.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { User } from '../models/User.js';
|
||||
import { StudyItem } from '../models/StudyItem.js';
|
||||
|
||||
export const syncWithWaniKani = async (user) => {
|
||||
const apiKey = user.wkApiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('User has no WaniKani API Key');
|
||||
}
|
||||
|
||||
console.log(`Starting sync for user: ${user._id}`);
|
||||
|
||||
let allSubjectIds = [];
|
||||
let nextUrl = 'https://api.wanikani.com/v2/assignments?subject_types=kanji&started=true';
|
||||
|
||||
try {
|
||||
while (nextUrl) {
|
||||
const res = await fetch(nextUrl, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`WaniKani API Error: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
allSubjectIds = allSubjectIds.concat(json.data.map(d => d.data.subject_id));
|
||||
nextUrl = json.pages.next_url;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching assignments:", err);
|
||||
throw new Error("Failed to connect to WaniKani. Check your internet connection.");
|
||||
}
|
||||
|
||||
if (allSubjectIds.length === 0) {
|
||||
return { count: 0, message: "No unlocked kanji found.", settings: user.settings };
|
||||
}
|
||||
|
||||
const existingItems = await StudyItem.find({ userId: user._id }).select('wkSubjectId');
|
||||
const existingIds = new Set(existingItems.map(i => i.wkSubjectId));
|
||||
const newIds = allSubjectIds.filter(id => !existingIds.has(id));
|
||||
|
||||
console.log(`Found ${newIds.length} new items to download.`);
|
||||
|
||||
const CHUNK_SIZE = 100;
|
||||
for (let i = 0; i < newIds.length; i += CHUNK_SIZE) {
|
||||
const chunk = newIds.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
const subRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${chunk.join(',')}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
const subJson = await subRes.json();
|
||||
|
||||
const operations = subJson.data.map(d => {
|
||||
const readings = d.data.readings || [];
|
||||
return {
|
||||
userId: user._id,
|
||||
wkSubjectId: d.id,
|
||||
char: d.data.characters,
|
||||
meaning: d.data.meanings.find(m => m.primary)?.meaning || 'Unknown',
|
||||
level: d.data.level,
|
||||
srsLevel: 1,
|
||||
nextReview: Date.now(),
|
||||
onyomi: readings.filter(r => r.type === 'onyomi').map(r => r.reading),
|
||||
kunyomi: readings.filter(r => r.type === 'kunyomi').map(r => r.reading),
|
||||
nanori: readings.filter(r => r.type === 'nanori').map(r => r.reading),
|
||||
stats: { correct: 0, total: 0 }
|
||||
};
|
||||
});
|
||||
|
||||
if (operations.length > 0) {
|
||||
await StudyItem.insertMany(operations);
|
||||
}
|
||||
}
|
||||
|
||||
const finalCount = await StudyItem.countDocuments({ userId: user._id });
|
||||
return { success: true, count: finalCount, settings: user.settings };
|
||||
};
|
||||
Reference in New Issue
Block a user