first commit
This commit is contained in:
commit
0e854110d3
49 files changed
+2292
No files matched your search
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -0,0 +1,28 @@
|
||||
// SM-2 spaced repetition algorithm (SuperMemo 2).
|
||||
// `quality` is 0-5: 0-2 = failed recall (reset), 3-5 = successful recall (grows interval).
|
||||
export interface SM2State {
|
||||
easeFactor: number;
|
||||
intervalDays: number;
|
||||
repetitions: number;
|
||||
}
|
||||
|
||||
export function sm2(state: SM2State, quality: number): SM2State {
|
||||
const q = Math.max(0, Math.min(5, quality));
|
||||
let { easeFactor, intervalDays, repetitions } = state;
|
||||
|
||||
if (q < 3) {
|
||||
return { easeFactor, intervalDays: 1, repetitions: 0 };
|
||||
}
|
||||
|
||||
repetitions += 1;
|
||||
if (repetitions === 1) intervalDays = 1;
|
||||
else if (repetitions === 2) intervalDays = 6;
|
||||
else intervalDays = Math.round(intervalDays * easeFactor);
|
||||
|
||||
easeFactor = Math.max(
|
||||
1.3,
|
||||
easeFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)),
|
||||
);
|
||||
|
||||
return { easeFactor, intervalDays, repetitions };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { sm2 } from "../lib/sm2.js";
|
||||
|
||||
const reviewBodySchema = z.object({
|
||||
quality: z.number().int().min(0).max(5),
|
||||
});
|
||||
|
||||
export async function quizRoutes(app: FastifyInstance) {
|
||||
// Returns a batch of structures due for review (or new ones if nothing is due yet).
|
||||
app.get<{ Querystring: { system?: string; limit?: string } }>(
|
||||
"/api/quiz/next",
|
||||
async (req) => {
|
||||
const limit = Math.min(Number(req.query.limit ?? 10), 50);
|
||||
const systemFilter = req.query.system
|
||||
? { system: { slug: req.query.system } }
|
||||
: {};
|
||||
|
||||
const due = await prisma.structure.findMany({
|
||||
where: {
|
||||
...systemFilter,
|
||||
OR: [
|
||||
{ quizCard: null },
|
||||
{ quizCard: { dueAt: { lte: new Date() } } },
|
||||
],
|
||||
},
|
||||
include: { system: true, quizCard: true },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return due;
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { structureId: string }; Body: unknown }>(
|
||||
"/api/quiz/:structureId/review",
|
||||
async (req, reply) => {
|
||||
const { quality } = reviewBodySchema.parse(req.body);
|
||||
const structureId = req.params.structureId;
|
||||
|
||||
const existing = await prisma.quizCard.findUnique({ where: { structureId } });
|
||||
const current = existing ?? {
|
||||
easeFactor: 2.5,
|
||||
intervalDays: 0,
|
||||
repetitions: 0,
|
||||
};
|
||||
|
||||
const next = sm2(current, quality);
|
||||
const dueAt = new Date(Date.now() + next.intervalDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const card = await prisma.quizCard.upsert({
|
||||
where: { structureId },
|
||||
create: {
|
||||
structureId,
|
||||
easeFactor: next.easeFactor,
|
||||
intervalDays: next.intervalDays,
|
||||
repetitions: next.repetitions,
|
||||
dueAt,
|
||||
lastReviewedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
easeFactor: next.easeFactor,
|
||||
intervalDays: next.intervalDays,
|
||||
repetitions: next.repetitions,
|
||||
dueAt,
|
||||
lastReviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send(card);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
system: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function structuresRoutes(app: FastifyInstance) {
|
||||
app.get("/api/structures", async (req) => {
|
||||
const { system, q } = listQuerySchema.parse(req.query);
|
||||
return prisma.structure.findMany({
|
||||
where: {
|
||||
...(system ? { system: { slug: system } } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ namePt: { contains: q, mode: "insensitive" } },
|
||||
{ nameEn: { contains: q, mode: "insensitive" } },
|
||||
{ synonyms: { has: q } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { system: true },
|
||||
orderBy: { namePt: "asc" },
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/structures/:id", async (req, reply) => {
|
||||
const structure = await prisma.structure.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { system: true },
|
||||
});
|
||||
if (!structure) return reply.code(404).send({ error: "not_found" });
|
||||
return structure;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
|
||||
export async function systemsRoutes(app: FastifyInstance) {
|
||||
app.get("/api/systems", async () => {
|
||||
return prisma.anatomicalSystem.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
include: { _count: { select: { structures: true } } },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { systemsRoutes } from "./routes/systems.js";
|
||||
import { structuresRoutes } from "./routes/structures.js";
|
||||
import { quizRoutes } from "./routes/quiz.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MODELS_DIR = process.env.MODELS_DIR ?? path.resolve(__dirname, "../models");
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
root: MODELS_DIR,
|
||||
prefix: "/models/",
|
||||
decorateReply: false,
|
||||
immutable: true,
|
||||
maxAge: "7d",
|
||||
});
|
||||
|
||||
app.get("/api/health", async () => ({ status: "ok" }));
|
||||
|
||||
await app.register(systemsRoutes);
|
||||
await app.register(structuresRoutes);
|
||||
await app.register(quizRoutes);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3333);
|
||||
app
|
||||
.listen({ port, host: "0.0.0.0" })
|
||||
.catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user