chore: reloop-v2 initial import for harness breezing QA

This commit is contained in:
reloop
2026-04-11 23:44:22 +09:00
commit 99d5892eb4
74 changed files with 6654 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
node_modules/
.next/
dist/
*.log
.env
.env.local
.DS_Store
pnpm-lock.yaml
package-lock.json
.turbo
backend/node_modules
frontend/node_modules
frontend/.next
backend/dist

15
backend/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# ReLoop backend env
# Copy to .env and fill in.
DATABASE_URL="mysql://reloop:CHANGE_ME@localhost:3306/reloop"
# JWT
JWT_SECRET="change-me-in-prod-really-long-random-string"
JWT_EXPIRES_IN="30d"
# CORS
CORS_ORIGINS="https://reloop.nabomhalang.co.kr,http://localhost:3000"
# Server
PORT=3001
NODE_ENV=production

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

63
backend/package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "reloop-backend",
"version": "0.2.0",
"description": "ReLoop Backend API (v2 — persona forgetting curve)",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "vitest run",
"test:watch": "vitest",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:reset": "prisma migrate reset --force",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"helmet": "^8.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.17",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.1",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"prettier": "^3.0.0",
"prisma": "^5.0.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,156 @@
// ReLoop v2 schema — persona forgetting curve model
// Generated fresh; old FSRS fields dropped. DB reset via `prisma migrate reset`.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// ─── Enums ────────────────────────────────────────────────────────
enum Persona {
senior
mid
junior
crammer
}
enum ReviewIntensity {
strict
moderate
relaxed
}
enum StudyResult {
correct
incorrect
partial
}
enum ReviewStatus {
pending
done
skipped
expired
}
// ─── Models ───────────────────────────────────────────────────────
model User {
id Int @id @default(autoincrement())
email String @unique
password String
nickname String
persona Persona @default(mid)
currentGrade Int?
targetGrade Int?
reviewIntensity ReviewIntensity @default(moderate)
onboardedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subjects Subject[]
studyLogs StudyLog[]
reviewSchedules ReviewSchedule[]
skillSnapshots SkillSnapshot[]
@@map("users")
}
model Subject {
id Int @id @default(autoincrement())
name String
color String @default("#6366f1")
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
tags Tag[]
studyLogs StudyLog[]
@@unique([userId, name])
@@index([userId])
@@map("subjects")
}
model Tag {
id Int @id @default(autoincrement())
name String
subjectId Int
subject Subject @relation(fields: [subjectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
studyLogs StudyLog[]
skillSnapshots SkillSnapshot[]
@@unique([subjectId, name])
@@index([subjectId])
@@map("tags")
}
model StudyLog {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
subjectId Int
subject Subject @relation(fields: [subjectId], references: [id])
tagId Int?
tag Tag? @relation(fields: [tagId], references: [id])
title String
difficulty Float
baseCorrectRate Float?
result StudyResult
memo String? @db.Text
studiedAt DateTime @default(now())
timeSpent Int?
reviewSchedules ReviewSchedule[]
@@index([userId, studiedAt])
@@index([subjectId])
@@index([tagId])
@@map("study_logs")
}
model ReviewSchedule {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
studyLogId Int
studyLog StudyLog @relation(fields: [studyLogId], references: [id], onDelete: Cascade)
scheduledAt DateTime
reviewedAt DateTime?
result StudyResult?
iteration Int @default(0)
predictedP Float?
status ReviewStatus @default(pending)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, status, scheduledAt])
@@index([studyLogId])
@@map("review_schedules")
}
model SkillSnapshot {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tagId Int
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
s0 Float
lastUpdatedAt DateTime @default(now())
sampleCount Int @default(0)
@@unique([userId, tagId])
@@index([userId])
@@map("skill_snapshots")
}

98
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* Dev seed — creates a default demo user with 수능 과목/태그 preset.
* Run via: `pnpm prisma:seed` (or `ts-node prisma/seed.ts`).
*
* Idempotent: re-running just upserts.
*/
import { PrismaClient, Persona, ReviewIntensity } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
const DEMO_EMAIL = 'demo@reloop.local';
const DEMO_PASSWORD = 'demo1234';
interface SubjectSeed {
name: string;
color: string;
tags: string[];
}
const SUBJECTS: SubjectSeed[] = [
{
name: '국어',
color: '#ef4444',
tags: ['문학', '독서(비문학)', '화법과작문', '언어와매체', '고전시가'],
},
{
name: '수학',
color: '#3b82f6',
tags: ['미적분', '확률과통계', '기하', '수1 지수로그', '수1 삼각함수', '수2 미분', '수2 적분'],
},
{
name: '영어',
color: '#22c55e',
tags: ['문법/어법', '어휘', '빈칸추론', '순서배열', '삽입', '주제/제목', '함축의미'],
},
{
name: '사회탐구',
color: '#f59e0b',
tags: ['생활과윤리', '사회문화', '한국지리', '세계사'],
},
{
name: '과학탐구',
color: '#8b5cf6',
tags: ['물리1', '화학1', '생명과학1', '지구과학1'],
},
];
async function main() {
console.log('🌱 ReLoop seed start');
// Demo user
const hash = await bcrypt.hash(DEMO_PASSWORD, 10);
const user = await prisma.user.upsert({
where: { email: DEMO_EMAIL },
update: {},
create: {
email: DEMO_EMAIL,
password: hash,
nickname: '데모',
persona: Persona.mid,
currentGrade: 4,
targetGrade: 2,
reviewIntensity: ReviewIntensity.moderate,
onboardedAt: new Date(),
},
});
console.log(` user: ${user.email} (id=${user.id}) password=${DEMO_PASSWORD}`);
for (const s of SUBJECTS) {
const subject = await prisma.subject.upsert({
where: { userId_name: { userId: user.id, name: s.name } },
update: { color: s.color },
create: { name: s.name, color: s.color, userId: user.id },
});
console.log(` subject: ${subject.name}`);
for (const tagName of s.tags) {
await prisma.tag.upsert({
where: { subjectId_name: { subjectId: subject.id, name: tagName } },
update: {},
create: { name: tagName, subjectId: subject.id },
});
}
}
console.log('✅ seed done');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

33
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module';
import { MeModule } from './me/me.module';
import { SubjectsModule } from './subjects/subjects.module';
import { TagsModule } from './tags/tags.module';
import { StudyLogsModule } from './study-logs/study-logs.module';
import { ReviewsModule } from './reviews/reviews.module';
import { ForgetModule } from './forget/forget.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { StatsModule } from './stats/stats.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 120 }]),
PrismaModule,
HealthModule,
AuthModule,
MeModule,
SubjectsModule,
TagsModule,
StudyLogsModule,
ReviewsModule,
ForgetModule,
DashboardModule,
StatsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,33 @@
import {
Body,
Controller,
Get,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto, RegisterDto } from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
import { AuthUser } from './jwt.strategy';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return this.auth.me(user.id);
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
secret: cfg.get<string>('JWT_SECRET') ?? 'dev-reloop-secret-change-me',
signOptions: { expiresIn: cfg.get<string>('JWT_EXPIRES_IN') ?? '30d' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,88 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
export interface JwtPayload {
sub: number;
email: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async register(input: { email: string; password: string; nickname: string }) {
const existing = await this.prisma.user.findUnique({
where: { email: input.email },
});
if (existing) throw new ConflictException('already registered');
const hash = await bcrypt.hash(input.password, 10);
const user = await this.prisma.user.create({
data: {
email: input.email,
password: hash,
nickname: input.nickname,
},
});
const accessToken = this.sign(user.id, user.email);
return { accessToken, user: this.safeUser(user) };
}
async login(input: { email: string; password: string }) {
const user = await this.prisma.user.findUnique({
where: { email: input.email },
});
if (!user) throw new UnauthorizedException('invalid credentials');
const ok = await bcrypt.compare(input.password, user.password);
if (!ok) throw new UnauthorizedException('invalid credentials');
const accessToken = this.sign(user.id, user.email);
return { accessToken, user: this.safeUser(user) };
}
async me(userId: number) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
return this.safeUser(user);
}
private sign(id: number, email: string): string {
const payload: JwtPayload = { sub: id, email };
return this.jwt.sign(payload);
}
private safeUser(u: {
id: number;
email: string;
nickname: string;
persona: string;
currentGrade: number | null;
targetGrade: number | null;
reviewIntensity: string;
onboardedAt: Date | null;
createdAt: Date;
}) {
return {
id: u.id,
email: u.email,
nickname: u.nickname,
persona: u.persona,
currentGrade: u.currentGrade,
targetGrade: u.targetGrade,
reviewIntensity: u.reviewIntensity,
onboarded: u.onboardedAt !== null,
createdAt: u.createdAt,
};
}
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthUser } from './jwt.strategy';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
const req = ctx.switchToHttp().getRequest();
return req.user as AuthUser;
},
);

24
backend/src/auth/dto.ts Normal file
View File

@@ -0,0 +1,24 @@
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(6)
@MaxLength(128)
password: string;
@IsString()
@MinLength(1)
@MaxLength(32)
nickname: string;
}
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from './auth.service';
export interface AuthUser {
id: number;
email: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(cfg: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey:
cfg.get<string>('JWT_SECRET') ?? 'dev-reloop-secret-change-me',
});
}
validate(payload: JwtPayload): AuthUser {
return { id: payload.sub, email: payload.email };
}
}

View File

@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { DashboardService } from './dashboard.service';
@Controller('dashboard')
@UseGuards(JwtAuthGuard)
export class DashboardController {
constructor(private readonly svc: DashboardService) {}
@Get('summary')
summary(@CurrentUser() user: AuthUser) {
return this.svc.summary(user.id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { ReviewStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async summary(userId: number) {
const now = new Date();
const todayEnd = new Date(now);
todayEnd.setHours(23, 59, 59, 999);
const weekStart = new Date(now.getTime() - 6 * 86_400_000);
const [
user,
pendingNow,
pendingSoon,
recentLogs,
weeklyLogs,
topSkills,
] = await Promise.all([
this.prisma.user.findUniqueOrThrow({ where: { id: userId } }),
this.prisma.reviewSchedule.count({
where: {
userId,
status: ReviewStatus.pending,
scheduledAt: { lte: now },
},
}),
this.prisma.reviewSchedule.count({
where: {
userId,
status: ReviewStatus.pending,
scheduledAt: { gt: now, lte: todayEnd },
},
}),
this.prisma.studyLog.findMany({
where: { userId },
orderBy: { studiedAt: 'desc' },
take: 5,
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },
},
}),
this.prisma.studyLog.groupBy({
by: ['result'],
where: {
userId,
studiedAt: { gte: weekStart },
},
_count: { result: true },
}),
this.prisma.skillSnapshot.findMany({
where: { userId },
include: {
tag: {
select: {
id: true,
name: true,
subject: { select: { id: true, name: true, color: true } },
},
},
},
orderBy: { s0: 'desc' },
take: 5,
}),
]);
const weekly = {
correct: 0,
incorrect: 0,
partial: 0,
};
for (const row of weeklyLogs) {
if (row.result === 'correct') weekly.correct = row._count.result;
if (row.result === 'incorrect') weekly.incorrect = row._count.result;
if (row.result === 'partial') weekly.partial = row._count.result;
}
return {
user: {
nickname: user.nickname,
persona: user.persona,
currentGrade: user.currentGrade,
targetGrade: user.targetGrade,
reviewIntensity: user.reviewIntensity,
onboarded: user.onboardedAt !== null,
},
queue: {
overdue: pendingNow,
soon: pendingSoon,
total: pendingNow + pendingSoon,
},
weekly,
recentLogs,
topSkills,
};
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PersonaForgetService } from './persona-forget.service';
@Global()
@Module({
providers: [PersonaForgetService],
exports: [PersonaForgetService],
})
export class ForgetModule {}

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { PersonaForgetService } from './persona-forget.service';
import { Persona, ReviewIntensity, StudyResult } from '@prisma/client';
const NOW = new Date('2026-04-11T00:00:00Z');
const T0 = new Date('2026-04-01T00:00:00Z');
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
describe('PersonaForgetService', () => {
let svc: PersonaForgetService;
beforeEach(() => {
svc = new PersonaForgetService();
});
describe('updateS0', () => {
it('correct pushes low s0 upward', () => {
expect(svc.updateS0({ previousS0: 0.2, result: 'correct' })).toBeCloseTo(0.7);
});
it('incorrect halves s0', () => {
expect(svc.updateS0({ previousS0: 0.8, result: 'incorrect' })).toBeCloseTo(0.4);
});
it('partial gives a moderate boost', () => {
expect(svc.updateS0({ previousS0: 0.4, result: 'partial' })).toBeCloseTo(0.58);
});
it('correct from 0 goes to 0.6', () => {
expect(svc.updateS0({ previousS0: 0, result: 'correct' })).toBeCloseTo(0.6);
});
it('clamps to [0,1]', () => {
expect(svc.updateS0({ previousS0: 1, result: 'correct' })).toBeCloseTo(1);
expect(svc.updateS0({ previousS0: 0, result: 'incorrect' })).toBeCloseTo(0);
});
it('null prev uses initial default 0.3', () => {
const v = svc.updateS0({ previousS0: null, result: 'correct' });
expect(v).toBeCloseTo(0.3 * 0.5 + 0.6); // 0.75
});
});
describe('schedule', () => {
it('higher λ → sooner review (crammer earlier than senior)', () => {
const base = {
s0: 0.9,
intensity: 'moderate' as ReviewIntensity,
difficulty: 0.4,
lastUpdatedAt: T0,
now: NOW,
};
const senior = svc.schedule({ ...base, persona: 'senior' as Persona });
const crammer = svc.schedule({ ...base, persona: 'crammer' as Persona });
expect(crammer.scheduledAt.getTime()).toBeLessThan(senior.scheduledAt.getTime());
});
it('stricter intensity → sooner review', () => {
const base = {
s0: 0.9,
persona: 'mid' as Persona,
difficulty: 0.4,
lastUpdatedAt: T0,
now: NOW,
};
const strict = svc.schedule({ ...base, intensity: 'strict' as ReviewIntensity });
const moderate = svc.schedule({ ...base, intensity: 'moderate' as ReviewIntensity });
const relaxed = svc.schedule({ ...base, intensity: 'relaxed' as ReviewIntensity });
expect(strict.scheduledAt.getTime()).toBeLessThan(moderate.scheduledAt.getTime());
expect(moderate.scheduledAt.getTime()).toBeLessThan(relaxed.scheduledAt.getTime());
});
it('harder difficulty → sooner review', () => {
const base = {
s0: 0.9,
persona: 'mid' as Persona,
intensity: 'moderate' as ReviewIntensity,
lastUpdatedAt: T0,
now: NOW,
};
const easy = svc.schedule({ ...base, difficulty: 0.3 });
const hard = svc.schedule({ ...base, difficulty: 0.7 });
expect(hard.scheduledAt.getTime()).toBeLessThan(easy.scheduledAt.getTime());
});
it('s0=0 schedules immediate review (within 2h)', () => {
const r = svc.schedule({
s0: 0,
persona: 'mid',
intensity: 'moderate',
difficulty: 0.5,
lastUpdatedAt: T0,
now: NOW,
});
expect(r.scheduledAt.getTime() - NOW.getTime()).toBeLessThanOrEqual(2 * HOUR);
});
it('very easy problem + high s0 → capped at 60 days', () => {
const r = svc.schedule({
s0: 0.99,
persona: 'senior',
intensity: 'relaxed',
difficulty: 0.05,
lastUpdatedAt: T0,
now: NOW,
});
const diff = r.scheduledAt.getTime() - T0.getTime();
expect(diff).toBeLessThanOrEqual(60 * DAY + HOUR); // cap + epsilon
});
it('already-below-threshold schedules within 2h', () => {
const r = svc.schedule({
s0: 0.3,
persona: 'junior',
intensity: 'strict', // threshold 0.7
difficulty: 0.5, // P = σ(4·(0.3-0.5))=σ(-0.8)≈0.31 → below 0.7
lastUpdatedAt: T0,
now: NOW,
});
expect(r.scheduledAt.getTime() - NOW.getTime()).toBeLessThanOrEqual(2 * HOUR);
});
it('predictedP is at or below threshold', () => {
const r = svc.schedule({
s0: 0.9,
persona: 'mid',
intensity: 'moderate',
difficulty: 0.4,
lastUpdatedAt: T0,
now: NOW,
});
expect(r.predictedP).toBeLessThanOrEqual(0.5 + 1e-6);
});
});
describe('predictP', () => {
it('decays over time', () => {
const args = {
s0: 1,
persona: 'mid' as Persona,
difficulty: 0.5,
lastUpdatedAt: T0,
};
const p0 = svc.predictP({ ...args, at: T0 });
const p1d = svc.predictP({ ...args, at: new Date(T0.getTime() + 1 * DAY) });
const p10d = svc.predictP({ ...args, at: new Date(T0.getTime() + 10 * DAY) });
expect(p0).toBeGreaterThan(p1d);
expect(p1d).toBeGreaterThan(p10d);
});
it('at t=0, P equals sigmoid(k(s0-D))', () => {
const p = svc.predictP({
s0: 0.6,
persona: 'mid',
difficulty: 0.4,
lastUpdatedAt: T0,
at: T0,
});
const expected = svc.sigmoid(4 * (0.6 - 0.4));
expect(p).toBeCloseTo(expected, 6);
});
});
describe('sampleCurve', () => {
it('returns steps+1 points that monotonically decrease', () => {
const points = svc.sampleCurve({
s0: 0.9,
persona: 'mid',
difficulty: 0.5,
lastUpdatedAt: T0,
days: 30,
steps: 30,
});
expect(points.length).toBe(31);
for (let i = 1; i < points.length; i++) {
expect(points[i].s).toBeLessThanOrEqual(points[i - 1].s);
}
});
});
describe('sigmoid', () => {
it('σ(0) = 0.5', () => {
expect(svc.sigmoid(0)).toBeCloseTo(0.5);
});
it('σ(+∞) → 1', () => {
expect(svc.sigmoid(100)).toBeCloseTo(1);
});
it('σ(-∞) → 0', () => {
expect(svc.sigmoid(-100)).toBeCloseTo(0);
});
});
});
/**
* Integration-ish: StudyResult sequence should yield a sensible trajectory.
*/
describe('PersonaForgetService — end-to-end sanity', () => {
it('repeated correct reviews grow s0 toward 1', () => {
const svc = new PersonaForgetService();
let s0: number = 0.3;
for (let i = 0; i < 5; i++) {
s0 = svc.updateS0({ previousS0: s0, result: 'correct' });
}
expect(s0).toBeGreaterThan(0.9);
});
it('repeated incorrect drags s0 toward 0', () => {
const svc = new PersonaForgetService();
let s0: number = 0.8;
for (let i = 0; i < 5; i++) {
s0 = svc.updateS0({ previousS0: s0, result: 'incorrect' });
}
expect(s0).toBeLessThan(0.05);
});
});

View File

@@ -0,0 +1,200 @@
import { Injectable } from '@nestjs/common';
import { Persona, ReviewIntensity, StudyResult } from '@prisma/client';
/**
* Persona-based forgetting curve.
*
* S(t) = S₀ · exp(-λ · Δt_days) // 실력
* P(correct) = σ(k · (S(t) - D)) // 정답 확률
* σ(x) = 1 / (1 + exp(-x)) // sigmoid
*
* 변수
* S₀ : 0..1 마지막 업데이트 시점의 태그 실력
* λ (lambda) : 페르소나별 망각 속도 (day⁻¹)
* D : 0..1 문제 난이도 (baseCorrectRate 있으면 1-baseCorrectRate)
* k : sigmoid 가파름 상수 (기본 4.0)
* P_threshold : reviewIntensity 에 따라 결정되는 복습 trigger 기준
*
* 스케줄링
* "P(correct) 가 P_threshold 아래로 떨어지는 시점" 을 next review 로 삼는다.
* 방정식을 t 에 대해 풀면:
*
* σ(k(S₀·e^(-λt) - D)) = P_threshold
* ⇒ k(S₀·e^(-λt) - D) = logit(P_threshold)
* ⇒ S₀·e^(-λt) = D + logit(P_threshold) / k
* ⇒ t = -(1/λ) · ln( (D + logit(P_threshold)/k) / S₀ )
*
* 경계 조건
* • t < 0 (이미 threshold 이하) → 지금 당장 복습, 1 시간 후
* • target(= D + logit/k) ≤ 0 → 난이도가 0 이하라 영원히 안 까먹음 → 60 일 cap
* • target > S₀ → 이미 threshold 이하 (로그 도메인 오류) → 1 시간 후
* • 계산된 t > 60 일 → 60 일 cap (무한 대기 방지)
*/
export const PERSONA_LAMBDA: Record<Persona, number> = {
senior: 0.1,
mid: 0.2,
junior: 0.4,
crammer: 0.6,
};
export const INTENSITY_THRESHOLD: Record<ReviewIntensity, number> = {
strict: 0.7,
moderate: 0.5,
relaxed: 0.35,
};
export const DEFAULT_K = 4.0;
export const DEFAULT_INITIAL_S0 = 0.3;
export const MAX_INTERVAL_DAYS = 60;
export interface UpdateInput {
previousS0: number | null;
result: StudyResult;
}
export interface ScheduleInput {
s0: number;
persona: Persona;
intensity: ReviewIntensity;
difficulty: number;
lastUpdatedAt: Date;
now?: Date;
}
export interface ScheduleOutput {
scheduledAt: Date;
predictedP: number;
}
@Injectable()
export class PersonaForgetService {
readonly k = DEFAULT_K;
/**
* Apply a study result to the previous S₀ and return the updated value.
* Clamped to [0, 1].
*/
updateS0(input: UpdateInput): number {
const prev = input.previousS0 ?? DEFAULT_INITIAL_S0;
switch (input.result) {
case 'correct':
return clamp(prev * 0.5 + 0.6, 0, 1);
case 'partial':
return clamp(prev * 0.7 + 0.3, 0, 1);
case 'incorrect':
return clamp(prev * 0.5, 0, 1);
default:
return prev;
}
}
/**
* Compute the next scheduled review time at which P(correct) hits the
* persona's threshold.
*/
schedule(input: ScheduleInput): ScheduleOutput {
const lambda = PERSONA_LAMBDA[input.persona];
const pThreshold = INTENSITY_THRESHOLD[input.intensity];
const now = input.now ?? new Date();
const { s0, difficulty } = input;
// ── degenerate cases ─────────────────────────────────────────
if (s0 <= 0) {
// 실력이 0 이면 이미 모르는 상태 → 즉시 복습 (1 시간 후)
return {
scheduledAt: addHours(now, 1),
predictedP: this.sigmoid(this.k * (s0 - difficulty)),
};
}
const logitThreshold = logit(pThreshold);
const target = difficulty + logitThreshold / this.k;
if (target <= 0) {
// 난이도가 워낙 쉬워서 P(correct) 가 영원히 threshold 이상 → cap
return {
scheduledAt: addDays(now, MAX_INTERVAL_DAYS),
predictedP: pThreshold,
};
}
if (target >= s0) {
// 이미 threshold 이하 → 바로 복습 (1 시간 버퍼)
return {
scheduledAt: addHours(now, 1),
predictedP: this.sigmoid(this.k * (s0 - difficulty)),
};
}
// 본 공식
const tDays = -(1 / lambda) * Math.log(target / s0);
const cappedDays = Math.min(tDays, MAX_INTERVAL_DAYS);
return {
scheduledAt: addDays(input.lastUpdatedAt, cappedDays),
predictedP: pThreshold,
};
}
/**
* Current P(correct) at an arbitrary point in time (for charts / stats).
*/
predictP(opts: {
s0: number;
persona: Persona;
difficulty: number;
lastUpdatedAt: Date;
at: Date;
}): number {
const lambda = PERSONA_LAMBDA[opts.persona];
const dtDays = Math.max(
0,
(opts.at.getTime() - opts.lastUpdatedAt.getTime()) / 86_400_000,
);
const s = opts.s0 * Math.exp(-lambda * dtDays);
return this.sigmoid(this.k * (s - opts.difficulty));
}
/**
* Sample S(t) over a range — used by /stats/forget-curve endpoint to
* draw a line chart.
*/
sampleCurve(opts: {
s0: number;
persona: Persona;
difficulty: number;
lastUpdatedAt: Date;
days: number;
steps: number;
}): Array<{ t: number; s: number; p: number }> {
const lambda = PERSONA_LAMBDA[opts.persona];
const points: Array<{ t: number; s: number; p: number }> = [];
for (let i = 0; i <= opts.steps; i++) {
const tDays = (i / opts.steps) * opts.days;
const s = opts.s0 * Math.exp(-lambda * tDays);
const p = this.sigmoid(this.k * (s - opts.difficulty));
points.push({ t: tDays, s, p });
}
return points;
}
sigmoid(x: number): number {
return 1 / (1 + Math.exp(-x));
}
}
// ── helpers ──
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function logit(p: number): number {
const eps = 1e-9;
const q = Math.max(eps, Math.min(1 - eps, p));
return Math.log(q / (1 - q));
}
function addDays(d: Date, days: number): Date {
return new Date(d.getTime() + days * 86_400_000);
}
function addHours(d: Date, hours: number): Date {
return new Date(d.getTime() + hours * 3_600_000);
}

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
health() {
return { status: 'ok', service: 'reloop-backend', version: '0.2.0' };
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({ controllers: [HealthController] })
export class HealthModule {}

40
backend/src/main.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, { cors: false });
// CORS — allow the frontend origin explicitly (covers prod + dev)
const allowedOrigins = (
process.env.CORS_ORIGINS ??
'https://reloop.nabomhalang.co.kr,http://localhost:3000'
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
app.enableCors({
origin: allowedOrigins,
credentials: true,
});
app.use(helmet({ crossOriginResourcePolicy: false }));
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: false,
}),
);
const port = parseInt(process.env.PORT ?? '3001', 10);
await app.listen(port);
logger.log(`🚀 ReLoop API listening on :${port}`);
logger.log(`📡 CORS origins: ${allowedOrigins.join(', ')}`);
}
bootstrap();

View File

@@ -0,0 +1,74 @@
import {
Body,
Controller,
Patch,
UseGuards,
} from '@nestjs/common';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Persona, ReviewIntensity } from '@prisma/client';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { MeService } from './me.service';
class OnboardingDto {
@IsEnum(Persona)
persona: Persona;
@IsOptional()
@IsInt()
@Min(1)
@Max(9)
currentGrade?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(9)
targetGrade?: number;
@IsEnum(ReviewIntensity)
reviewIntensity: ReviewIntensity;
}
class ProfilePatchDto {
@IsOptional()
@IsString()
nickname?: string;
@IsOptional()
@IsEnum(Persona)
persona?: Persona;
@IsOptional()
@IsInt()
@Min(1)
@Max(9)
currentGrade?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(9)
targetGrade?: number;
@IsOptional()
@IsEnum(ReviewIntensity)
reviewIntensity?: ReviewIntensity;
}
@Controller('me')
@UseGuards(JwtAuthGuard)
export class MeController {
constructor(private readonly me: MeService) {}
@Patch('onboarding')
onboarding(@CurrentUser() user: AuthUser, @Body() dto: OnboardingDto) {
return this.me.updateOnboarding(user.id, dto);
}
@Patch('profile')
profile(@CurrentUser() user: AuthUser, @Body() dto: ProfilePatchDto) {
return this.me.updateProfile(user.id, dto);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MeController } from './me.controller';
import { MeService } from './me.service';
@Module({
controllers: [MeController],
providers: [MeService],
})
export class MeModule {}

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { Persona, ReviewIntensity } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface OnboardingInput {
persona: Persona;
currentGrade?: number;
targetGrade?: number;
reviewIntensity: ReviewIntensity;
}
@Injectable()
export class MeService {
constructor(private readonly prisma: PrismaService) {}
async updateOnboarding(userId: number, input: OnboardingInput) {
const user = await this.prisma.user.update({
where: { id: userId },
data: {
persona: input.persona,
currentGrade: input.currentGrade ?? null,
targetGrade: input.targetGrade ?? null,
reviewIntensity: input.reviewIntensity,
onboardedAt: new Date(),
},
});
return this.toView(user);
}
async updateProfile(
userId: number,
input: Partial<{
nickname: string;
persona: Persona;
currentGrade: number | null;
targetGrade: number | null;
reviewIntensity: ReviewIntensity;
}>,
) {
const user = await this.prisma.user.update({
where: { id: userId },
data: input,
});
return this.toView(user);
}
private toView(u: {
id: number;
email: string;
nickname: string;
persona: string;
currentGrade: number | null;
targetGrade: number | null;
reviewIntensity: string;
onboardedAt: Date | null;
createdAt: Date;
}) {
return {
id: u.id,
email: u.email,
nickname: u.nickname,
persona: u.persona,
currentGrade: u.currentGrade,
targetGrade: u.targetGrade,
reviewIntensity: u.reviewIntensity,
onboarded: u.onboardedAt !== null,
createdAt: u.createdAt,
};
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,64 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { StudyResult } from '@prisma/client';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { ReviewsService } from './reviews.service';
class SubmitDto {
@IsEnum(StudyResult)
result: StudyResult;
}
class HistoryQuery {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number;
}
@Controller('reviews')
@UseGuards(JwtAuthGuard)
export class ReviewsController {
constructor(private readonly svc: ReviewsService) {}
@Get('queue')
queue(@CurrentUser() user: AuthUser) {
return this.svc.queue(user.id);
}
@Post(':id/submit')
submit(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
@Body() dto: SubmitDto,
) {
return this.svc.submit(user.id, id, dto.result);
}
@Post(':id/skip')
skip(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.svc.skip(user.id, id);
}
@Get('history')
history(@CurrentUser() user: AuthUser, @Query() q: HistoryQuery) {
return this.svc.history(user.id, q.limit);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ReviewsController } from './reviews.controller';
import { ReviewsService } from './reviews.service';
@Module({
controllers: [ReviewsController],
providers: [ReviewsService],
})
export class ReviewsModule {}

View File

@@ -0,0 +1,165 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { StudyResult, ReviewStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PersonaForgetService } from '../forget/persona-forget.service';
@Injectable()
export class ReviewsService {
constructor(
private readonly prisma: PrismaService,
private readonly forget: PersonaForgetService,
) {}
/**
* Queue of reviews that should be done around "now". Includes anything
* whose scheduledAt is in the past OR within the next 24h so the UI can
* show "곧 해야 할 것들" too.
*/
async queue(userId: number) {
const now = new Date();
const soon = new Date(now.getTime() + 24 * 3_600_000);
return this.prisma.reviewSchedule.findMany({
where: {
userId,
status: ReviewStatus.pending,
scheduledAt: { lte: soon },
},
include: {
studyLog: {
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },
},
},
},
orderBy: { scheduledAt: 'asc' },
take: 100,
});
}
async submit(userId: number, reviewId: number, result: StudyResult) {
const review = await this.prisma.reviewSchedule.findUnique({
where: { id: reviewId },
include: {
studyLog: { include: { subject: true } },
},
});
if (!review) throw new NotFoundException();
if (review.userId !== userId) throw new ForbiddenException();
if (review.status !== 'pending') {
throw new ForbiddenException('review already processed');
}
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
// Update snapshot
let newS0 = 0.3;
if (review.studyLog.tagId) {
const existing = await this.prisma.skillSnapshot.findUnique({
where: {
userId_tagId: { userId, tagId: review.studyLog.tagId },
},
});
newS0 = this.forget.updateS0({
previousS0: existing?.s0 ?? null,
result,
});
await this.prisma.skillSnapshot.upsert({
where: {
userId_tagId: { userId, tagId: review.studyLog.tagId },
},
create: {
userId,
tagId: review.studyLog.tagId,
s0: newS0,
lastUpdatedAt: new Date(),
sampleCount: 1,
},
update: {
s0: newS0,
lastUpdatedAt: new Date(),
sampleCount: { increment: 1 },
},
});
} else {
newS0 = this.forget.updateS0({ previousS0: null, result });
}
// Compute the next schedule
const D =
review.studyLog.baseCorrectRate !== null &&
review.studyLog.baseCorrectRate !== undefined
? 1 - review.studyLog.baseCorrectRate
: review.studyLog.difficulty;
const next = this.forget.schedule({
s0: newS0,
persona: user.persona,
intensity: user.reviewIntensity,
difficulty: D,
lastUpdatedAt: new Date(),
});
// Transaction: mark done + insert next
const [updated, nextRow] = await this.prisma.$transaction([
this.prisma.reviewSchedule.update({
where: { id: reviewId },
data: {
status: 'done',
reviewedAt: new Date(),
result,
},
}),
this.prisma.reviewSchedule.create({
data: {
userId,
studyLogId: review.studyLogId,
scheduledAt: next.scheduledAt,
predictedP: next.predictedP,
iteration: review.iteration + 1,
status: 'pending',
},
}),
]);
return { updated, nextReview: nextRow, s0: newS0 };
}
async skip(userId: number, reviewId: number) {
const review = await this.prisma.reviewSchedule.findUnique({
where: { id: reviewId },
});
if (!review || review.userId !== userId) throw new NotFoundException();
if (review.status !== 'pending') throw new ForbiddenException();
return this.prisma.reviewSchedule.update({
where: { id: reviewId },
data: { status: 'skipped' },
});
}
async history(userId: number, limit = 50) {
return this.prisma.reviewSchedule.findMany({
where: {
userId,
status: { in: [ReviewStatus.done, ReviewStatus.skipped] },
},
include: {
studyLog: {
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },
},
},
},
orderBy: { reviewedAt: 'desc' },
take: limit,
});
}
}

View File

@@ -0,0 +1,49 @@
import {
Controller,
Get,
ParseIntPipe,
Query,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { StatsService } from './stats.service';
class ForgetCurveQuery {
@Type(() => Number)
@IsInt()
tagId: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(180)
days?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(5)
@Max(120)
steps?: number;
}
@Controller('stats')
@UseGuards(JwtAuthGuard)
export class StatsController {
constructor(private readonly svc: StatsService) {}
@Get('forget-curve')
forgetCurve(@CurrentUser() user: AuthUser, @Query() q: ForgetCurveQuery) {
return this.svc.forgetCurve(user.id, q.tagId, q.days, q.steps);
}
@Get('subjects')
subjects(@CurrentUser() user: AuthUser) {
return this.svc.bySubject(user.id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StatsController } from './stats.controller';
import { StatsService } from './stats.service';
@Module({
controllers: [StatsController],
providers: [StatsService],
})
export class StatsModule {}

View File

@@ -0,0 +1,101 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PersonaForgetService } from '../forget/persona-forget.service';
@Injectable()
export class StatsService {
constructor(
private readonly prisma: PrismaService,
private readonly forget: PersonaForgetService,
) {}
/**
* Sample S(t) / P(correct) over the next N days for a given tag.
* Used by the frontend to draw the forget curve chart.
*/
async forgetCurve(userId: number, tagId: number, days = 30, steps = 30) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
const snapshot = await this.prisma.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId } },
include: {
tag: {
select: {
id: true,
name: true,
subject: { select: { id: true, name: true, color: true } },
},
},
},
});
if (!snapshot) throw new NotFoundException('no snapshot for this tag');
// Use average difficulty from recent logs for this tag
const recentLog = await this.prisma.studyLog.findFirst({
where: { userId, tagId },
orderBy: { studiedAt: 'desc' },
});
const D =
recentLog?.baseCorrectRate !== null &&
recentLog?.baseCorrectRate !== undefined
? 1 - recentLog.baseCorrectRate
: recentLog?.difficulty ?? 0.5;
const points = this.forget.sampleCurve({
s0: snapshot.s0,
persona: user.persona,
difficulty: D,
lastUpdatedAt: snapshot.lastUpdatedAt,
days,
steps,
});
return {
tag: snapshot.tag,
snapshot: {
s0: snapshot.s0,
lastUpdatedAt: snapshot.lastUpdatedAt,
sampleCount: snapshot.sampleCount,
},
persona: user.persona,
intensity: user.reviewIntensity,
difficulty: D,
points,
};
}
async bySubject(userId: number) {
const subjects = await this.prisma.subject.findMany({
where: { userId },
include: {
tags: {
include: {
skillSnapshots: { where: { userId } },
},
},
},
});
return subjects.map((s) => {
const tagSkills = s.tags.map((t) => ({
tagId: t.id,
name: t.name,
s0: t.skillSnapshots[0]?.s0 ?? null,
sampleCount: t.skillSnapshots[0]?.sampleCount ?? 0,
}));
const withData = tagSkills.filter((t) => t.s0 !== null);
const avgS0 =
withData.length === 0
? null
: withData.reduce((acc, t) => acc + (t.s0 ?? 0), 0) / withData.length;
return {
id: s.id,
name: s.name,
color: s.color,
avgS0,
tags: tagSkills,
};
});
}
}

View File

@@ -0,0 +1,106 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
import { StudyResult } from '@prisma/client';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { StudyLogsService } from './study-logs.service';
class CreateStudyLogDto {
@IsInt()
subjectId: number;
@IsOptional()
@IsInt()
tagId?: number;
@IsString()
title: string;
@IsNumber()
@Min(0)
@Max(1)
difficulty: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
baseCorrectRate?: number;
@IsEnum(StudyResult)
result: StudyResult;
@IsOptional()
@IsString()
memo?: string;
@IsOptional()
@IsInt()
@Min(0)
timeSpent?: number;
}
class ListStudyLogQuery {
@IsOptional()
@Type(() => Number)
@IsInt()
subjectId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
tagId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
}
@Controller('study-logs')
@UseGuards(JwtAuthGuard)
export class StudyLogsController {
constructor(private readonly svc: StudyLogsService) {}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStudyLogDto) {
return this.svc.create(user.id, dto);
}
@Get()
list(@CurrentUser() user: AuthUser, @Query() q: ListStudyLogQuery) {
return this.svc.list(user.id, q);
}
@Get(':id')
one(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
return this.svc.getOne(user.id, id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StudyLogsController } from './study-logs.controller';
import { StudyLogsService } from './study-logs.service';
@Module({
controllers: [StudyLogsController],
providers: [StudyLogsService],
})
export class StudyLogsModule {}

View File

@@ -0,0 +1,173 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { StudyResult } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PersonaForgetService } from '../forget/persona-forget.service';
export interface CreateStudyLogInput {
subjectId: number;
tagId?: number;
title: string;
difficulty: number;
baseCorrectRate?: number | null;
result: StudyResult;
memo?: string;
timeSpent?: number;
}
/**
* Creating a study log has two side-effects:
* 1. Update (or create) the SkillSnapshot for (user, tag).
* 2. Create a ReviewSchedule row at the time P(correct) drops below
* the persona's threshold.
*
* Both happen in a single transaction so a failure leaves no orphan
* schedule or stale snapshot.
*/
@Injectable()
export class StudyLogsService {
constructor(
private readonly prisma: PrismaService,
private readonly forget: PersonaForgetService,
) {}
async create(userId: number, input: CreateStudyLogInput) {
// ── ownership checks ──
const subject = await this.prisma.subject.findFirst({
where: { id: input.subjectId, userId },
});
if (!subject) throw new ForbiddenException('subject');
if (input.tagId) {
const tag = await this.prisma.tag.findFirst({
where: { id: input.tagId, subjectId: input.subjectId },
});
if (!tag) throw new NotFoundException('tag');
}
// ── persona / intensity ──
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
// Difficulty: user provides 0..1; if baseCorrectRate given, blend.
const D =
input.baseCorrectRate !== undefined && input.baseCorrectRate !== null
? 1 - input.baseCorrectRate
: clamp01(input.difficulty);
// ── transaction ──
return this.prisma.$transaction(async (tx) => {
// 1. create the study log
const log = await tx.studyLog.create({
data: {
userId,
subjectId: input.subjectId,
tagId: input.tagId ?? null,
title: input.title,
difficulty: clamp01(input.difficulty),
baseCorrectRate: input.baseCorrectRate ?? null,
result: input.result,
memo: input.memo ?? null,
timeSpent: input.timeSpent ?? null,
},
});
// 2. upsert skill snapshot (if tagId present)
let s0 = 0.3;
if (input.tagId) {
const existing = await tx.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId: input.tagId } },
});
const prevS0 = existing?.s0 ?? null;
s0 = this.forget.updateS0({
previousS0: prevS0,
result: input.result,
});
await tx.skillSnapshot.upsert({
where: { userId_tagId: { userId, tagId: input.tagId } },
create: {
userId,
tagId: input.tagId,
s0,
lastUpdatedAt: new Date(),
sampleCount: 1,
},
update: {
s0,
lastUpdatedAt: new Date(),
sampleCount: { increment: 1 },
},
});
} else {
s0 = this.forget.updateS0({ previousS0: null, result: input.result });
}
// 3. schedule next review
const schedule = this.forget.schedule({
s0,
persona: user.persona,
intensity: user.reviewIntensity,
difficulty: D,
lastUpdatedAt: new Date(),
});
const reviewRow = await tx.reviewSchedule.create({
data: {
userId,
studyLogId: log.id,
scheduledAt: schedule.scheduledAt,
predictedP: schedule.predictedP,
iteration: 0,
status: 'pending',
},
});
return { studyLog: log, nextReview: reviewRow, s0 };
});
}
list(
userId: number,
opts: { subjectId?: number; tagId?: number; limit?: number; offset?: number },
) {
return this.prisma.studyLog.findMany({
where: {
userId,
...(opts.subjectId && { subjectId: opts.subjectId }),
...(opts.tagId && { tagId: opts.tagId }),
},
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },
reviewSchedules: {
orderBy: { scheduledAt: 'desc' },
take: 1,
},
},
orderBy: { studiedAt: 'desc' },
take: opts.limit ?? 50,
skip: opts.offset ?? 0,
});
}
async getOne(userId: number, id: number) {
const log = await this.prisma.studyLog.findFirst({
where: { id, userId },
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },
reviewSchedules: { orderBy: { scheduledAt: 'asc' } },
},
});
if (!log) throw new NotFoundException();
return log;
}
}
function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}

View File

@@ -0,0 +1,74 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { SubjectsService } from './subjects.service';
class CreateSubjectDto {
@IsString()
@MinLength(1)
@MaxLength(40)
name: string;
@IsOptional()
@IsString()
color?: string;
}
class UpdateSubjectDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(40)
name?: string;
@IsOptional()
@IsString()
color?: string;
}
@Controller('subjects')
@UseGuards(JwtAuthGuard)
export class SubjectsController {
constructor(private readonly svc: SubjectsService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.svc.list(user.id);
}
@Get(':id')
one(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
return this.svc.getOne(user.id, id);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateSubjectDto) {
return this.svc.create(user.id, dto.name, dto.color ?? '#6366f1');
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateSubjectDto,
) {
return this.svc.update(user.id, id, dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
return this.svc.remove(user.id, id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SubjectsController } from './subjects.controller';
import { SubjectsService } from './subjects.service';
@Module({
controllers: [SubjectsController],
providers: [SubjectsService],
})
export class SubjectsModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class SubjectsService {
constructor(private readonly prisma: PrismaService) {}
list(userId: number) {
return this.prisma.subject.findMany({
where: { userId },
include: { tags: { orderBy: { name: 'asc' } } },
orderBy: { createdAt: 'asc' },
});
}
async getOne(userId: number, id: number) {
const subject = await this.prisma.subject.findFirst({
where: { id, userId },
include: { tags: { orderBy: { name: 'asc' } } },
});
if (!subject) throw new NotFoundException();
return subject;
}
create(userId: number, name: string, color: string) {
return this.prisma.subject.create({
data: { userId, name, color },
});
}
async update(userId: number, id: number, data: { name?: string; color?: string }) {
const owned = await this.prisma.subject.findFirst({ where: { id, userId } });
if (!owned) throw new NotFoundException();
return this.prisma.subject.update({ where: { id }, data });
}
async remove(userId: number, id: number) {
const owned = await this.prisma.subject.findFirst({ where: { id, userId } });
if (!owned) throw new NotFoundException();
await this.prisma.subject.delete({ where: { id } });
return { ok: true };
}
}

View File

@@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsString, MaxLength, MinLength } from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { TagsService } from './tags.service';
class CreateTagDto {
@IsInt()
subjectId: number;
@IsString()
@MinLength(1)
@MaxLength(40)
name: string;
}
@Controller('tags')
@UseGuards(JwtAuthGuard)
export class TagsController {
constructor(private readonly svc: TagsService) {}
@Get('by-subject/:subjectId')
list(
@CurrentUser() user: AuthUser,
@Param('subjectId', ParseIntPipe) subjectId: number,
) {
return this.svc.listForSubject(user.id, subjectId);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateTagDto) {
return this.svc.create(user.id, dto.subjectId, dto.name);
}
@Delete(':id')
remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.svc.remove(user.id, id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
import { TagsService } from './tags.service';
@Module({
controllers: [TagsController],
providers: [TagsService],
})
export class TagsModule {}

View File

@@ -0,0 +1,36 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TagsService {
constructor(private readonly prisma: PrismaService) {}
async listForSubject(userId: number, subjectId: number) {
const subject = await this.prisma.subject.findFirst({
where: { id: subjectId, userId },
});
if (!subject) throw new NotFoundException('subject');
return this.prisma.tag.findMany({
where: { subjectId },
orderBy: { name: 'asc' },
});
}
async create(userId: number, subjectId: number, name: string) {
const subject = await this.prisma.subject.findFirst({
where: { id: subjectId, userId },
});
if (!subject) throw new ForbiddenException();
return this.prisma.tag.create({ data: { subjectId, name } });
}
async remove(userId: number, id: number) {
const tag = await this.prisma.tag.findUnique({
where: { id },
include: { subject: true },
});
if (!tag || tag.subject.userId !== userId) throw new NotFoundException();
await this.prisma.tag.delete({ where: { id } });
return { ok: true };
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*.test.ts"]
}

24
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["src/*"]
}
}
}

2
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

9
frontend/next.config.js Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
compiler: {
styledComponents: true,
},
reactStrictMode: true,
};
module.exports = nextConfig;

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "reloop-frontend",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\" --ignore-path .gitignore"
},
"dependencies": {
"axios": "^1.14.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"recharts": "^2.12.7",
"styled-components": "^6.1.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/styled-components": "^5.1.34",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"prettier": "^3.0.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,377 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type DashboardSummary } from '@/lib/api';
import { PERSONA_META } from '@/lib/constants';
import AppShell from '@/components/layout/AppShell';
import {
Badge,
Button,
Card,
SectionTitle,
Stack,
} from '@/components/ui/primitives';
export default function DashboardPage() {
return (
<AppShell>
<DashboardBody />
</AppShell>
);
}
function DashboardBody() {
const [data, setData] = useState<DashboardSummary | null>(null);
useEffect(() => {
api.get<DashboardSummary>('/dashboard/summary').then((r) => setData(r.data));
}, []);
if (!data) return <Loading> ...</Loading>;
const persona = PERSONA_META[data.user.persona];
const totalWeek = data.weekly.correct + data.weekly.incorrect + data.weekly.partial;
const correctRate = totalWeek === 0 ? 0 : data.weekly.correct / totalWeek;
return (
<Wrap>
<Header>
<Greeting>
<HiEmoji>{persona.emoji}</HiEmoji>
<div>
<HelloText>
{data.user.nickname}, ?
</HelloText>
<PersonaLine>
<PersonaTag $color={persona.color}>{persona.label}</PersonaTag>
<MetaSep>·</MetaSep>
<span>
{data.user.currentGrade} {data.user.targetGrade}
</span>
</PersonaLine>
</div>
</Greeting>
</Header>
{/* Queue cards */}
<Grid3>
<StatCard $accent={theme.color.danger}>
<StatLabel> </StatLabel>
<StatValue>{data.queue.overdue}</StatValue>
<StatHint> </StatHint>
</StatCard>
<StatCard $accent={theme.color.warning}>
<StatLabel> </StatLabel>
<StatValue>{data.queue.soon}</StatValue>
<StatHint> </StatHint>
</StatCard>
<StatCard $accent={theme.color.success}>
<StatLabel> </StatLabel>
<StatValue>{totalWeek === 0 ? '—' : `${Math.round(correctRate * 100)}%`}</StatValue>
<StatHint>{totalWeek} </StatHint>
</StatCard>
</Grid3>
{/* CTA */}
<CTACard>
<CTALeft>
<CTATitle>
{data.queue.total > 0
? `복습할 문제가 ${data.queue.total}개 있어`
: '오늘은 복습할 게 없어. 새 문제 기록해 볼래?'}
</CTATitle>
<CTASub>
{data.queue.total > 0
? '오래된 것부터 순서대로 풀어봐.'
: '학습 기록을 추가하면 자동으로 다음 복습이 잡혀.'}
</CTASub>
</CTALeft>
<CTARight>
{data.queue.total > 0 ? (
<Link href="/review">
<Button $size="lg"> </Button>
</Link>
) : (
<Link href="/study">
<Button $size="lg"> +</Button>
</Link>
)}
</CTARight>
</CTACard>
{/* Top skills */}
{data.topSkills.length > 0 && (
<Section>
<SectionTitle> </SectionTitle>
<SkillGrid>
{data.topSkills.map((s) => (
<SkillCard key={s.id}>
<SkillHeader>
<SubjectDot $color={s.tag.subject.color} />
<SkillSubject>{s.tag.subject.name}</SkillSubject>
</SkillHeader>
<SkillTag>{s.tag.name}</SkillTag>
<SkillBar>
<SkillFill style={{ width: `${Math.round(s.s0 * 100)}%` }} />
</SkillBar>
<SkillMeta>
<span>{Math.round(s.s0 * 100)}%</span>
<span>{s.sampleCount}</span>
</SkillMeta>
</SkillCard>
))}
</SkillGrid>
</Section>
)}
{/* Recent logs */}
{data.recentLogs.length > 0 && (
<Section>
<SectionTitle> </SectionTitle>
<Stack $gap={theme.space.sm}>
{data.recentLogs.map((l) => (
<RecentRow key={l.id}>
<SubjectDot $color={l.subject?.color ?? '#6366f1'} />
<RecentMain>
<RecentTitle>{l.title}</RecentTitle>
<RecentMeta>
{l.subject?.name} {l.tag && `· ${l.tag.name}`}
</RecentMeta>
</RecentMain>
<ResultBadge $result={l.result}>
{l.result === 'correct' ? '정답' : l.result === 'partial' ? '부분' : '오답'}
</ResultBadge>
</RecentRow>
))}
</Stack>
</Section>
)}
</Wrap>
);
}
// ── styled ──
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.xl};
`;
const Header = styled.header``;
const Greeting = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.md};
`;
const HiEmoji = styled.div`
font-size: 36px;
`;
const HelloText = styled.h1`
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
`;
const PersonaLine = styled.div`
font-size: 13px;
color: ${theme.color.textSub};
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
`;
const PersonaTag = styled.span<{ $color: string }>`
color: ${({ $color }) => $color};
font-weight: 700;
`;
const MetaSep = styled.span`
color: ${theme.color.textMute};
`;
const Grid3 = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr;
}
`;
const StatCard = styled(Card)<{ $accent: string }>`
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: ${({ $accent }) => $accent};
}
`;
const StatLabel = styled.div`
font-size: 11px;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
`;
const StatValue = styled.div`
font-size: 36px;
font-weight: 800;
line-height: 1;
color: ${theme.color.textMain};
`;
const StatHint = styled.div`
font-size: 11px;
color: ${theme.color.textMute};
margin-top: 6px;
`;
const CTACard = styled(Card)`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.lg};
background: linear-gradient(
135deg,
${theme.color.surface} 0%,
${theme.color.surface2} 100%
);
border: 1px solid ${theme.color.accent}55;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
align-items: stretch;
}
`;
const CTALeft = styled.div``;
const CTATitle = styled.h3`
font-size: 17px;
font-weight: 700;
margin-bottom: 4px;
`;
const CTASub = styled.p`
font-size: 12px;
color: ${theme.color.textSub};
`;
const CTARight = styled.div`
flex-shrink: 0;
`;
const Section = styled.section``;
const SkillGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: ${theme.space.md};
`;
const SkillCard = styled(Card)`
padding: ${theme.space.md};
`;
const SkillHeader = styled.div`
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
`;
const SubjectDot = styled.span<{ $color: string }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $color }) => $color};
flex-shrink: 0;
`;
const SkillSubject = styled.span`
font-size: 11px;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const SkillTag = styled.div`
font-size: 14px;
font-weight: 700;
margin-bottom: ${theme.space.sm};
`;
const SkillBar = styled.div`
height: 6px;
background: ${theme.color.surface2};
border-radius: 3px;
overflow: hidden;
`;
const SkillFill = styled.div`
height: 100%;
background: linear-gradient(90deg, ${theme.color.accent}, ${theme.color.accent2});
border-radius: 3px;
`;
const SkillMeta = styled.div`
display: flex;
justify-content: space-between;
font-size: 11px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
margin-top: 4px;
`;
const RecentRow = styled(Card)`
padding: ${theme.space.md};
display: flex;
align-items: center;
gap: ${theme.space.md};
`;
const RecentMain = styled.div`
flex: 1;
min-width: 0;
`;
const RecentTitle = styled.div`
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const RecentMeta = styled.div`
font-size: 11px;
color: ${theme.color.textSub};
margin-top: 2px;
`;
const ResultBadge = styled(Badge)<{ $result: string }>`
${({ $result }) => {
if ($result === 'correct') return `background: rgba(34,197,94,0.15); color: ${theme.color.success}; border-color: rgba(34,197,94,0.35);`;
if ($result === 'partial') return `background: rgba(245,158,11,0.15); color: ${theme.color.warning}; border-color: rgba(245,158,11,0.35);`;
return `background: rgba(239,68,68,0.15); color: ${theme.color.danger}; border-color: rgba(239,68,68,0.35);`;
}}
`;

View File

@@ -0,0 +1,31 @@
import type { Metadata, Viewport } from 'next';
import React from 'react';
import StyledComponentsRegistry from '@/styles/registry';
import GlobalStyle from '@/styles/GlobalStyle';
export const metadata: Metadata = {
title: 'ReLoop — 적응형 복습 스케줄링',
description: '페르소나 망각 곡선 기반 수능 학습 최적화 플랫폼',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
themeColor: '#0b1020',
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ko">
<body>
<StyledComponentsRegistry>
<GlobalStyle />
{children}
</StyledComponentsRegistry>
</body>
</html>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api } from '@/lib/api';
import { setToken } from '@/lib/auth';
import {
Button,
Card,
ErrorText,
Input,
Label,
Stack,
} from '@/components/ui/primitives';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
setLoading(true);
try {
const r = await api.post('/auth/login', { email, password });
setToken(r.data.accessToken);
router.replace(r.data.user.onboarded ? '/dashboard' : '/onboarding');
} catch (e) {
setErr('이메일 또는 비밀번호가 맞지 않아');
} finally {
setLoading(false);
}
};
return (
<Page>
<Hero>
<HeroTitle>ReLoop</HeroTitle>
<HeroSub> · </HeroSub>
</Hero>
<FormCard>
<FormTitle></FormTitle>
<form onSubmit={submit}>
<Stack $gap={theme.space.md}>
<div>
<Label></Label>
<Input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Label></Label>
<Input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{err && <ErrorText>{err}</ErrorText>}
<Button type="submit" $block $size="lg" disabled={loading}>
{loading ? '로그인 중...' : '로그인'}
</Button>
</Stack>
</form>
<SubRow>
<span> ?</span>
<Link href="/register"></Link>
</SubRow>
</FormCard>
</Page>
);
}
const Page = styled.div`
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: ${theme.space.lg};
gap: ${theme.space.xl};
`;
const Hero = styled.div`
text-align: center;
`;
const HeroTitle = styled.h1`
font-size: 44px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #818cf8, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
`;
const HeroSub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const FormCard = styled(Card)`
width: 100%;
max-width: 400px;
`;
const FormTitle = styled.h2`
font-size: 18px;
font-weight: 700;
margin-bottom: ${theme.space.lg};
`;
const SubRow = styled.div`
margin-top: ${theme.space.lg};
display: flex;
justify-content: center;
gap: 6px;
font-size: 13px;
color: ${theme.color.textSub};
a {
color: ${theme.color.accent};
font-weight: 600;
}
`;

View File

@@ -0,0 +1,295 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import { theme, PERSONA_ORDER } from '@/styles/theme';
import { api, type Persona, type ReviewIntensity } from '@/lib/api';
import { hasToken } from '@/lib/auth';
import { PERSONA_META, INTENSITY_META } from '@/lib/constants';
import {
Button,
Card,
ErrorText,
Select,
Label,
Stack,
} from '@/components/ui/primitives';
type Step = 1 | 2 | 3;
export default function OnboardingPage() {
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [currentGrade, setCurrentGrade] = useState<number>(4);
const [targetGrade, setTargetGrade] = useState<number>(2);
const [persona, setPersona] = useState<Persona>('mid');
const [intensity, setIntensity] = useState<ReviewIntensity>('moderate');
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!hasToken()) {
router.replace('/login');
}
}, [router]);
const finish = async () => {
setSaving(true);
setErr(null);
try {
await api.patch('/me/onboarding', {
currentGrade,
targetGrade,
persona,
reviewIntensity: intensity,
});
router.replace('/dashboard');
} catch (e) {
setErr('저장에 실패했어. 다시 시도해 줘.');
} finally {
setSaving(false);
}
};
return (
<Page>
<Progress>
<Dot $active={step >= 1} />
<Dot $active={step >= 2} />
<Dot $active={step >= 3} />
</Progress>
{step === 1 && (
<StepCard>
<StepTitle> </StepTitle>
<StepSub> .</StepSub>
<Stack $gap={theme.space.lg}>
<div>
<Label> </Label>
<Select
value={currentGrade}
onChange={(e) => setCurrentGrade(Number(e.target.value))}
>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
</div>
<div>
<Label> </Label>
<Select
value={targetGrade}
onChange={(e) => setTargetGrade(Number(e.target.value))}
>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
</div>
<Button $block $size="lg" onClick={() => setStep(2)}>
</Button>
</Stack>
</StepCard>
)}
{step === 2 && (
<StepCard>
<StepTitle> </StepTitle>
<StepSub> . .</StepSub>
<PersonaGrid>
{PERSONA_ORDER.map((key) => {
const meta = PERSONA_META[key];
const active = persona === key;
return (
<PersonaOption
key={key}
$active={active}
$color={meta.color}
onClick={() => setPersona(key)}
>
<PersonaEmoji>{meta.emoji}</PersonaEmoji>
<PersonaLabel>{meta.label}</PersonaLabel>
<PersonaDesc>{meta.desc}</PersonaDesc>
</PersonaOption>
);
})}
</PersonaGrid>
<ButtonRow>
<Button $variant="ghost" onClick={() => setStep(1)}>
</Button>
<Button $block $size="lg" onClick={() => setStep(3)}>
</Button>
</ButtonRow>
</StepCard>
)}
{step === 3 && (
<StepCard>
<StepTitle> </StepTitle>
<StepSub> . .</StepSub>
<IntensityGrid>
{(['strict', 'moderate', 'relaxed'] as ReviewIntensity[]).map((key) => {
const meta = INTENSITY_META[key];
const active = intensity === key;
return (
<IntensityOption
key={key}
$active={active}
onClick={() => setIntensity(key)}
>
<IntensityLabel>{meta.label}</IntensityLabel>
<IntensityDesc>{meta.desc}</IntensityDesc>
<IntensityThreshold>
P {(meta.threshold * 100).toFixed(0)}%
</IntensityThreshold>
</IntensityOption>
);
})}
</IntensityGrid>
{err && <ErrorText>{err}</ErrorText>}
<ButtonRow>
<Button $variant="ghost" onClick={() => setStep(2)}>
</Button>
<Button $block $size="lg" onClick={finish} disabled={saving}>
{saving ? '저장 중...' : '시작하기'}
</Button>
</ButtonRow>
</StepCard>
)}
</Page>
);
}
const Page = styled.div`
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: ${theme.space.lg};
gap: ${theme.space.xl};
`;
const Progress = styled.div`
display: flex;
gap: ${theme.space.sm};
`;
const Dot = styled.div<{ $active: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
transition: background 0.2s;
`;
const StepCard = styled(Card)`
width: 100%;
max-width: 520px;
`;
const StepTitle = styled.h2`
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
`;
const StepSub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-bottom: ${theme.space.lg};
`;
const PersonaGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: ${theme.space.md};
margin-bottom: ${theme.space.lg};
`;
const PersonaOption = styled.button<{ $active: boolean; $color: string }>`
text-align: left;
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
background: ${({ $active, $color }) =>
$active ? `${$color}15` : theme.color.surface2};
border: 2px solid
${({ $active, $color }) => ($active ? $color : theme.color.border)};
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover {
border-color: ${({ $color }) => $color};
}
`;
const PersonaEmoji = styled.div`
font-size: 28px;
margin-bottom: 4px;
`;
const PersonaLabel = styled.div`
font-size: 15px;
font-weight: 700;
color: ${theme.color.textMain};
margin-bottom: 4px;
`;
const PersonaDesc = styled.div`
font-size: 11px;
line-height: 1.5;
color: ${theme.color.textSub};
`;
const IntensityGrid = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
margin-bottom: ${theme.space.lg};
`;
const IntensityOption = styled.button<{ $active: boolean }>`
text-align: left;
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
background: ${({ $active }) =>
$active ? `${theme.color.accent}15` : theme.color.surface2};
border: 2px solid
${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
`;
const IntensityLabel = styled.div`
font-size: 15px;
font-weight: 700;
`;
const IntensityDesc = styled.div`
font-size: 12px;
color: ${theme.color.textSub};
`;
const IntensityThreshold = styled.div`
font-size: 10px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
margin-top: 2px;
`;
const ButtonRow = styled.div`
display: flex;
gap: ${theme.space.sm};
align-items: center;
`;

13
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { hasToken } from '@/lib/auth';
export default function RootPage() {
const router = useRouter();
useEffect(() => {
router.replace(hasToken() ? '/dashboard' : '/login');
}, [router]);
return null;
}

View File

@@ -0,0 +1,372 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import {
api,
type MeUser,
type Persona,
type ReviewIntensity,
} from '@/lib/api';
import { clearToken } from '@/lib/auth';
import AppShell from '@/components/layout/AppShell';
import {
Badge,
Button,
Card,
ErrorText,
Input,
Label,
SectionTitle,
Select,
Stack,
} from '@/components/ui/primitives';
const PERSONAS: Persona[] = ['senior', 'mid', 'junior', 'crammer'];
const INTENSITIES: ReviewIntensity[] = ['strict', 'moderate', 'relaxed'];
export default function ProfilePage() {
return (
<AppShell>
<ProfileBody />
</AppShell>
);
}
function ProfileBody() {
const router = useRouter();
const [user, setUser] = useState<MeUser | null>(null);
const [nickname, setNickname] = useState('');
const [persona, setPersona] = useState<Persona>('mid');
const [intensity, setIntensity] = useState<ReviewIntensity>('moderate');
const [currentGrade, setCurrentGrade] = useState<number | ''>('');
const [targetGrade, setTargetGrade] = useState<number | ''>('');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.get<MeUser>('/auth/me').then((r) => {
const u = r.data;
setUser(u);
setNickname(u.nickname);
setPersona(u.persona);
setIntensity(u.reviewIntensity);
setCurrentGrade(u.currentGrade ?? '');
setTargetGrade(u.targetGrade ?? '');
});
}, []);
const save = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
setSaved(false);
setSaving(true);
try {
const r = await api.patch<MeUser>('/me/profile', {
nickname,
persona,
reviewIntensity: intensity,
currentGrade: currentGrade === '' ? undefined : currentGrade,
targetGrade: targetGrade === '' ? undefined : targetGrade,
});
setUser(r.data);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch {
setErr('저장에 실패했어.');
} finally {
setSaving(false);
}
};
const logout = () => {
clearToken();
router.replace('/login');
};
if (user === null) return <Loading> ...</Loading>;
return (
<Wrap>
<Header>
<Title></Title>
<Sub> .</Sub>
</Header>
<Card>
<SectionTitle></SectionTitle>
<InfoRow>
<InfoLabel></InfoLabel>
<InfoValue>{user.email}</InfoValue>
</InfoRow>
<InfoRow>
<InfoLabel></InfoLabel>
<InfoValue>
{new Date(user.createdAt).toLocaleDateString('ko-KR')}
</InfoValue>
</InfoRow>
</Card>
<form onSubmit={save}>
<Card>
<SectionTitle> </SectionTitle>
<Stack $gap={theme.space.md}>
<div>
<Label></Label>
<Input
value={nickname}
onChange={(e) => setNickname(e.target.value)}
maxLength={40}
/>
</div>
<Row>
<div>
<Label> </Label>
<Select
value={currentGrade}
onChange={(e) =>
setCurrentGrade(
e.target.value === '' ? '' : Number(e.target.value),
)
}
>
<option value=""></option>
{Array.from({ length: 9 }, (_, i) => i + 1).map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
</div>
<div>
<Label> </Label>
<Select
value={targetGrade}
onChange={(e) =>
setTargetGrade(
e.target.value === '' ? '' : Number(e.target.value),
)
}
>
<option value=""></option>
{Array.from({ length: 9 }, (_, i) => i + 1).map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
</div>
</Row>
<div>
<Label></Label>
<PersonaGrid>
{PERSONAS.map((p) => (
<PersonaCard
key={p}
type="button"
$active={persona === p}
$color={theme.persona[p].color}
onClick={() => setPersona(p)}
>
<PersonaEmoji>{theme.persona[p].emoji}</PersonaEmoji>
<PersonaLabel>{theme.persona[p].label}</PersonaLabel>
<PersonaDesc>{personaDesc(p)}</PersonaDesc>
</PersonaCard>
))}
</PersonaGrid>
</div>
<div>
<Label> </Label>
<IntensityGrid>
{INTENSITIES.map((i) => (
<IntensityCard
key={i}
type="button"
$active={intensity === i}
onClick={() => setIntensity(i)}
>
<IntensityLabel>{intensityLabel(i)}</IntensityLabel>
<IntensityDesc>{intensityDesc(i)}</IntensityDesc>
</IntensityCard>
))}
</IntensityGrid>
</div>
{err && <ErrorText>{err}</ErrorText>}
{saved && <Saved> </Saved>}
<ButtonRow>
<Button type="submit" $size="md" disabled={saving}>
{saving ? '저장 중...' : '변경사항 저장'}
</Button>
</ButtonRow>
</Stack>
</Card>
</form>
<Card>
<SectionTitle></SectionTitle>
<Button $variant="danger" $size="md" onClick={logout}>
</Button>
</Card>
</Wrap>
);
}
function personaDesc(p: Persona): string {
return p === 'senior'
? 'λ=0.1 · 잘 안 잊음'
: p === 'mid'
? 'λ=0.2 · 표준'
: p === 'junior'
? 'λ=0.4 · 빨리 잊음'
: 'λ=0.6 · 벼락치기형';
}
function intensityLabel(i: ReviewIntensity): string {
return i === 'strict' ? '엄격' : i === 'relaxed' ? '느슨' : '보통';
}
function intensityDesc(i: ReviewIntensity): string {
return i === 'strict'
? '높은 P 기준, 더 자주'
: i === 'relaxed'
? '낮은 P 기준, 덜 자주'
: '표준 P 기준';
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
max-width: 720px;
margin: 0 auto;
width: 100%;
`;
const Header = styled.header``;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-top: 4px;
`;
const InfoRow = styled.div`
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid ${theme.color.borderSoft};
&:last-child {
border-bottom: none;
}
`;
const InfoLabel = styled.span`
font-size: 12px;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const InfoValue = styled.span`
font-size: 13px;
color: ${theme.color.textMain};
font-family: ${theme.font.mono};
`;
const Row = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr;
}
`;
const PersonaGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: ${theme.space.sm};
@media (min-width: ${theme.breakpoint.mobile}) {
grid-template-columns: repeat(4, 1fr);
}
`;
const PersonaCard = styled.button<{ $active: boolean; $color: string }>`
display: flex;
flex-direction: column;
align-items: center;
padding: ${theme.space.md};
gap: 4px;
border-radius: ${theme.radius.md};
background: ${({ $active }) =>
$active ? theme.color.surfaceHover : theme.color.surface2};
border: 1px solid
${({ $active, $color }) => ($active ? $color : theme.color.border)};
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
border-color: ${({ $color }) => $color};
}
`;
const PersonaEmoji = styled.span`
font-size: 24px;
`;
const PersonaLabel = styled.span`
font-size: 13px;
font-weight: 700;
`;
const PersonaDesc = styled.span`
font-size: 10px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
`;
const IntensityGrid = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: ${theme.space.sm};
`;
const IntensityCard = styled.button<{ $active: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
padding: ${theme.space.md};
gap: 4px;
border-radius: ${theme.radius.md};
background: ${({ $active }) =>
$active ? theme.color.surfaceHover : theme.color.surface2};
border: 1px solid
${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
cursor: pointer;
&:hover {
border-color: ${theme.color.accent};
}
`;
const IntensityLabel = styled.span`
font-size: 14px;
font-weight: 700;
`;
const IntensityDesc = styled.span`
font-size: 10px;
color: ${theme.color.textMute};
text-align: center;
`;
const ButtonRow = styled.div`
display: flex;
justify-content: flex-end;
`;
const Saved = styled.p`
color: ${theme.color.success};
font-size: 13px;
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;

View File

@@ -0,0 +1,129 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api } from '@/lib/api';
import { setToken } from '@/lib/auth';
import {
Button,
Card,
ErrorText,
Input,
Label,
Stack,
} from '@/components/ui/primitives';
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [nickname, setNickname] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
setLoading(true);
try {
const r = await api.post('/auth/register', { email, password, nickname });
setToken(r.data.accessToken);
router.replace('/onboarding');
} catch (e: any) {
if (e.response?.status === 409) {
setErr('이미 등록된 이메일이야');
} else {
setErr('회원가입 실패 — 다시 시도해 줘');
}
} finally {
setLoading(false);
}
};
return (
<Page>
<FormCard>
<FormTitle></FormTitle>
<form onSubmit={submit}>
<Stack $gap={theme.space.md}>
<div>
<Label></Label>
<Input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Label> (6 )</Label>
<Input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div>
<Label></Label>
<Input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
maxLength={32}
/>
</div>
{err && <ErrorText>{err}</ErrorText>}
<Button type="submit" $block $size="lg" disabled={loading}>
{loading ? '가입 중...' : '가입하기'}
</Button>
</Stack>
</form>
<SubRow>
<span> ?</span>
<Link href="/login"></Link>
</SubRow>
</FormCard>
</Page>
);
}
const Page = styled.div`
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: ${theme.space.lg};
`;
const FormCard = styled(Card)`
width: 100%;
max-width: 400px;
`;
const FormTitle = styled.h2`
font-size: 22px;
font-weight: 700;
margin-bottom: ${theme.space.lg};
`;
const SubRow = styled.div`
margin-top: ${theme.space.lg};
display: flex;
justify-content: center;
gap: 6px;
font-size: 13px;
color: ${theme.color.textSub};
a {
color: ${theme.color.accent};
font-weight: 600;
}
`;

View File

@@ -0,0 +1,156 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type QueueItem } from '@/lib/api';
import AppShell from '@/components/layout/AppShell';
import { Badge, Card, SectionTitle, Stack } from '@/components/ui/primitives';
interface HistoryItem extends QueueItem {
reviewedAt: string | null;
result: 'correct' | 'incorrect' | 'partial' | null;
}
export default function ReviewHistoryPage() {
return (
<AppShell>
<ReviewHistoryBody />
</AppShell>
);
}
function ReviewHistoryBody() {
const [items, setItems] = useState<HistoryItem[] | null>(null);
useEffect(() => {
api
.get<HistoryItem[]>('/reviews/history?limit=100')
.then((r) => setItems(r.data));
}, []);
return (
<Wrap>
<Header>
<Title> </Title>
<Sub> .</Sub>
</Header>
{items === null ? (
<Loading> ...</Loading>
) : items.length === 0 ? (
<Empty> .</Empty>
) : (
<Stack $gap={theme.space.sm}>
{items.map((it) => (
<HistoryCard key={it.id}>
<TopRow>
<Dot $color={it.studyLog.subject.color} />
<SubjectName>{it.studyLog.subject.name}</SubjectName>
{it.studyLog.tag && (
<TagName>· {it.studyLog.tag.name}</TagName>
)}
<Spacer />
{it.status === 'skipped' ? (
<Badge></Badge>
) : it.result ? (
<Badge $variant={resultVariant(it.result)}>
{resultLabel(it.result)}
</Badge>
) : null}
</TopRow>
<ProblemTitle>{it.studyLog.title}</ProblemTitle>
<MetaRow>
<Meta>{it.iteration + 1} </Meta>
<Meta>
{it.reviewedAt
? `${new Date(it.reviewedAt).toLocaleString('ko-KR')} 처리`
: `${new Date(it.scheduledAt).toLocaleString('ko-KR')} 예정`}
</Meta>
</MetaRow>
</HistoryCard>
))}
</Stack>
)}
</Wrap>
);
}
function resultLabel(r: string): string {
return r === 'correct' ? '맞음' : r === 'partial' ? '부분' : '틀림';
}
function resultVariant(r: string): 'success' | 'warning' | 'danger' {
return r === 'correct' ? 'success' : r === 'partial' ? 'warning' : 'danger';
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const Header = styled.header``;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-top: 4px;
`;
const HistoryCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 6px;
`;
const TopRow = styled.div`
display: flex;
align-items: center;
gap: 6px;
`;
const Dot = styled.span<{ $color: string }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $color }) => $color};
`;
const SubjectName = styled.span`
font-size: 12px;
font-weight: 700;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const TagName = styled.span`
font-size: 12px;
color: ${theme.color.textMute};
`;
const Spacer = styled.span`
flex: 1;
`;
const ProblemTitle = styled.h3`
font-size: 15px;
font-weight: 700;
`;
const MetaRow = styled.div`
display: flex;
gap: ${theme.space.md};
`;
const Meta = styled.span`
font-size: 11px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Empty = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textMute};
border: 1px dashed ${theme.color.border};
border-radius: ${theme.radius.md};
`;

View File

@@ -0,0 +1,271 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type QueueItem, type StudyResult } from '@/lib/api';
import AppShell from '@/components/layout/AppShell';
import {
Badge,
Button,
Card,
SectionTitle,
Stack,
} from '@/components/ui/primitives';
export default function ReviewPage() {
return (
<AppShell>
<ReviewBody />
</AppShell>
);
}
function ReviewBody() {
const [queue, setQueue] = useState<QueueItem[] | null>(null);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(() => {
api.get<QueueItem[]>('/reviews/queue').then((r) => setQueue(r.data));
}, []);
useEffect(() => { load(); }, [load]);
const submit = async (id: number, result: StudyResult) => {
setSubmitting(true);
try {
await api.post(`/reviews/${id}/submit`, { result });
setQueue((prev) => prev?.filter((q) => q.id !== id) ?? null);
} finally {
setSubmitting(false);
}
};
const skip = async (id: number) => {
setSubmitting(true);
try {
await api.post(`/reviews/${id}/skip`);
setQueue((prev) => prev?.filter((q) => q.id !== id) ?? null);
} finally {
setSubmitting(false);
}
};
if (queue === null) return <Loading> ...</Loading>;
if (queue.length === 0) {
return (
<Empty>
<EmptyEmoji>🎉</EmptyEmoji>
<EmptyTitle> </EmptyTitle>
<EmptySub> .</EmptySub>
</Empty>
);
}
return (
<Wrap>
<Header>
<Title> </Title>
<Sub>{queue.length} · </Sub>
</Header>
<Stack $gap={theme.space.md}>
{queue.map((q) => (
<ReviewCard key={q.id}>
<CardTop>
<TagLine>
<SubjectDot $color={q.studyLog.subject.color} />
<SubjectName>{q.studyLog.subject.name}</SubjectName>
{q.studyLog.tag && <TagName>· {q.studyLog.tag.name}</TagName>}
</TagLine>
<Meta>
{q.iteration > 0 && <Badge>{q.iteration + 1}</Badge>}
{q.predictedP !== null && (
<Badge $variant="info">P {Math.round(q.predictedP * 100)}%</Badge>
)}
</Meta>
</CardTop>
<ProblemTitle>{q.studyLog.title}</ProblemTitle>
{q.studyLog.memo && <Memo>{q.studyLog.memo}</Memo>}
<Scheduled>
{formatRelative(new Date(q.scheduledAt))}
</Scheduled>
<Actions>
<Button
$variant="danger"
$size="md"
disabled={submitting}
onClick={() => submit(q.id, 'incorrect')}
>
</Button>
<Button
$variant="secondary"
$size="md"
disabled={submitting}
onClick={() => submit(q.id, 'partial')}
>
</Button>
<Button
$size="md"
disabled={submitting}
onClick={() => submit(q.id, 'correct')}
>
</Button>
<Button
$variant="ghost"
$size="md"
disabled={submitting}
onClick={() => skip(q.id)}
>
</Button>
</Actions>
</ReviewCard>
))}
</Stack>
</Wrap>
);
}
function formatRelative(d: Date): string {
const diffMin = Math.round((Date.now() - d.getTime()) / 60_000);
if (diffMin < 0) {
const mins = -diffMin;
if (mins < 60) return `${mins}분 뒤 예정`;
const h = Math.round(mins / 60);
return `${h}시간 뒤 예정`;
}
if (diffMin < 60) return `${diffMin}분 전 예정이었어`;
const hours = Math.round(diffMin / 60);
if (hours < 24) return `${hours}시간 지남`;
const days = Math.round(hours / 24);
return `${days}일 지남`;
}
// ── styled ──
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Empty = styled.div`
padding: ${theme.space.xxl};
text-align: center;
`;
const EmptyEmoji = styled.div`
font-size: 56px;
margin-bottom: ${theme.space.md};
`;
const EmptyTitle = styled.h2`
font-size: 20px;
font-weight: 700;
margin-bottom: 6px;
`;
const EmptySub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
`;
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const Header = styled.header``;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 12px;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
margin-top: 4px;
`;
const ReviewCard = styled(Card)`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const CardTop = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.sm};
`;
const TagLine = styled.div`
display: flex;
align-items: center;
gap: 6px;
`;
const SubjectDot = styled.span<{ $color: string }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $color }) => $color};
`;
const SubjectName = styled.span`
font-size: 12px;
font-weight: 700;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const TagName = styled.span`
font-size: 12px;
color: ${theme.color.textMute};
`;
const Meta = styled.div`
display: flex;
gap: 6px;
`;
const ProblemTitle = styled.h3`
font-size: 16px;
font-weight: 700;
color: ${theme.color.textMain};
`;
const Memo = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
padding: ${theme.space.sm};
background: ${theme.color.surface2};
border-radius: ${theme.radius.sm};
white-space: pre-wrap;
`;
const Scheduled = styled.div`
font-size: 11px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
`;
const Actions = styled.div`
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
gap: ${theme.space.sm};
margin-top: ${theme.space.sm};
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr 1fr 1fr;
button:last-child { grid-column: span 3; }
}
`;

View File

@@ -0,0 +1,311 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type ForgetCurveResponse } from '@/lib/api';
import AppShell from '@/components/layout/AppShell';
import { Badge, Card, SectionTitle, Stack } from '@/components/ui/primitives';
import ForgetCurveChart from '@/components/charts/ForgetCurveChart';
interface SubjectStats {
id: number;
name: string;
color: string;
avgS0: number | null;
tags: Array<{
tagId: number;
name: string;
s0: number | null;
sampleCount: number;
}>;
}
export default function StatsPage() {
return (
<AppShell>
<StatsBody />
</AppShell>
);
}
function StatsBody() {
const [subjects, setSubjects] = useState<SubjectStats[] | null>(null);
const [selectedTag, setSelectedTag] = useState<number | null>(null);
const [curve, setCurve] = useState<ForgetCurveResponse | null>(null);
useEffect(() => {
api.get<SubjectStats[]>('/stats/subjects').then((r) => {
setSubjects(r.data);
const firstWithData = r.data
.flatMap((s) => s.tags)
.find((t) => t.s0 !== null);
if (firstWithData) setSelectedTag(firstWithData.tagId);
});
}, []);
useEffect(() => {
if (selectedTag === null) return;
setCurve(null);
api
.get<ForgetCurveResponse>('/stats/forget-curve', {
params: { tagId: selectedTag, days: 30, steps: 60 },
})
.then((r) => setCurve(r.data));
}, [selectedTag]);
if (subjects === null) return <Loading> ...</Loading>;
return (
<Wrap>
<Header>
<Title></Title>
<Sub> .</Sub>
</Header>
<Card>
<SectionTitle> </SectionTitle>
{selectedTag === null ? (
<Empty>
. .
</Empty>
) : curve === null ? (
<Loading> ...</Loading>
) : (
<>
<CurveHeader>
<CurveTitle>
<Dot $color={curve.tag.subject.color} />
<span>
{curve.tag.subject.name} · {curve.tag.name}
</span>
</CurveTitle>
<CurveMeta>
<Badge $variant="info">
S {Math.round(curve.snapshot.s0 * 100)}%
</Badge>
<Badge> {personaLabel(curve.persona)}</Badge>
<Badge> {intensityLabel(curve.intensity)}</Badge>
<Badge>
{Math.round(curve.difficulty * 100)}%
</Badge>
</CurveMeta>
</CurveHeader>
<ForgetCurveChart
data={curve}
threshold={intensityThreshold(curve.intensity)}
/>
</>
)}
</Card>
<div>
<SectionTitle> </SectionTitle>
<Stack $gap={theme.space.md}>
{subjects.map((s) => (
<SubjectCard key={s.id}>
<SubjectTop>
<Dot $color={s.color} />
<SubjectName>{s.name}</SubjectName>
<Spacer />
<Badge $variant="info">
S{' '}
{s.avgS0 === null ? '—' : `${Math.round(s.avgS0 * 100)}%`}
</Badge>
</SubjectTop>
{s.tags.length === 0 ? (
<SubNote> </SubNote>
) : (
<TagList>
{s.tags.map((t) => (
<TagRow
key={t.tagId}
type="button"
onClick={() =>
t.s0 !== null ? setSelectedTag(t.tagId) : null
}
$active={t.tagId === selectedTag}
disabled={t.s0 === null}
>
<TagName>{t.name}</TagName>
<Bar>
<BarFill $value={t.s0 ?? 0} $color={s.color} />
</Bar>
<TagValue>
{t.s0 === null
? '—'
: `${Math.round(t.s0 * 100)}%`}
</TagValue>
<SampleCount>n={t.sampleCount}</SampleCount>
</TagRow>
))}
</TagList>
)}
</SubjectCard>
))}
</Stack>
</div>
</Wrap>
);
}
function personaLabel(p: string): string {
return p === 'senior'
? '상위권'
: p === 'mid'
? '중위권'
: p === 'junior'
? '하위권'
: '벼락치기';
}
function intensityLabel(i: string): string {
return i === 'strict' ? '엄격' : i === 'relaxed' ? '느슨' : '보통';
}
function intensityThreshold(i: string): number {
return i === 'strict' ? 0.7 : i === 'relaxed' ? 0.35 : 0.5;
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const Header = styled.header``;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-top: 4px;
`;
const CurveHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.md};
margin-bottom: ${theme.space.md};
flex-wrap: wrap;
`;
const CurveTitle = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.sm};
font-size: 15px;
font-weight: 700;
`;
const CurveMeta = styled.div`
display: flex;
gap: 6px;
flex-wrap: wrap;
`;
const SubjectCard = styled(Card)`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const SubjectTop = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.sm};
`;
const Dot = styled.span<{ $color: string }>`
width: 10px;
height: 10px;
border-radius: 50%;
background: ${({ $color }) => $color};
`;
const SubjectName = styled.h3`
font-size: 15px;
font-weight: 700;
`;
const Spacer = styled.span`
flex: 1;
`;
const SubNote = styled.p`
font-size: 12px;
color: ${theme.color.textMute};
`;
const TagList = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
margin-top: ${theme.space.sm};
`;
const TagRow = styled.button<{ $active: boolean }>`
display: grid;
grid-template-columns: 140px 1fr 50px 50px;
align-items: center;
gap: ${theme.space.sm};
padding: 8px 10px;
background: ${({ $active }) =>
$active ? theme.color.surfaceHover : 'transparent'};
border: 1px solid
${({ $active }) => ($active ? theme.color.accent : 'transparent')};
border-radius: ${theme.radius.sm};
text-align: left;
cursor: pointer;
&:hover:not(:disabled) {
background: ${theme.color.surface2};
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 100px 1fr 44px;
}
`;
const TagName = styled.span`
font-size: 13px;
color: ${theme.color.textMain};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const Bar = styled.div`
height: 6px;
background: ${theme.color.surface2};
border-radius: 999px;
overflow: hidden;
`;
const BarFill = styled.div<{ $value: number; $color: string }>`
height: 100%;
width: ${({ $value }) => `${Math.round($value * 100)}%`};
background: ${({ $color }) => $color};
transition: width 0.3s ease;
`;
const TagValue = styled.span`
font-size: 11px;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
text-align: right;
`;
const SampleCount = styled.span`
font-size: 10px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
text-align: right;
@media (max-width: ${theme.breakpoint.mobile}) {
display: none;
}
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Empty = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textMute};
font-size: 13px;
`;

View File

@@ -0,0 +1,215 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type StudyLog, type Subject } from '@/lib/api';
import AppShell from '@/components/layout/AppShell';
import {
Badge,
Button,
Card,
SectionTitle,
Select,
Stack,
} from '@/components/ui/primitives';
export default function StudyHistoryPage() {
return (
<AppShell>
<HistoryBody />
</AppShell>
);
}
function HistoryBody() {
const [subjects, setSubjects] = useState<Subject[]>([]);
const [subjectFilter, setSubjectFilter] = useState<number | ''>('');
const [logs, setLogs] = useState<StudyLog[] | null>(null);
useEffect(() => {
api.get<Subject[]>('/subjects').then((r) => setSubjects(r.data));
}, []);
useEffect(() => {
const params: Record<string, number> = { limit: 100 };
if (subjectFilter !== '') params.subjectId = subjectFilter;
api
.get<StudyLog[]>('/study-logs', { params })
.then((r) => setLogs(r.data));
}, [subjectFilter]);
return (
<Wrap>
<Header>
<div>
<Title> </Title>
<Sub> .</Sub>
</div>
<Link href="/study">
<Button $size="md">+ </Button>
</Link>
</Header>
<FilterRow>
<Select
value={subjectFilter}
onChange={(e) =>
setSubjectFilter(e.target.value === '' ? '' : Number(e.target.value))
}
>
<option value=""> </option>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</FilterRow>
{logs === null ? (
<Loading> ...</Loading>
) : logs.length === 0 ? (
<Empty>
<EmptyEmoji>📝</EmptyEmoji>
<EmptyTitle> </EmptyTitle>
<Link href="/study">
<Button $size="md"> </Button>
</Link>
</Empty>
) : (
<Stack $gap={theme.space.sm}>
{logs.map((log) => (
<LogCard key={log.id}>
<TopRow>
<Dot $color={log.subject?.color ?? '#6366f1'} />
<SubjectName>{log.subject?.name}</SubjectName>
{log.tag && <TagName>· {log.tag.name}</TagName>}
<Spacer />
<ResultBadge $variant={resultVariant(log.result)}>
{resultLabel(log.result)}
</ResultBadge>
</TopRow>
<LogTitle>{log.title}</LogTitle>
{log.memo && <Memo>{log.memo}</Memo>}
<MetaRow>
<Meta> {Math.round(log.difficulty * 100)}%</Meta>
{log.baseCorrectRate !== null && (
<Meta> {Math.round(log.baseCorrectRate * 100)}%</Meta>
)}
{log.timeSpent !== null && (
<Meta>{Math.round((log.timeSpent ?? 0) / 60)} </Meta>
)}
<Meta>{new Date(log.studiedAt).toLocaleString('ko-KR')}</Meta>
</MetaRow>
</LogCard>
))}
</Stack>
)}
</Wrap>
);
}
function resultLabel(r: string): string {
return r === 'correct' ? '맞음' : r === 'partial' ? '부분' : '틀림';
}
function resultVariant(r: string): 'success' | 'warning' | 'danger' {
return r === 'correct' ? 'success' : r === 'partial' ? 'warning' : 'danger';
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const Header = styled.header`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.md};
`;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-top: 4px;
`;
const FilterRow = styled.div`
max-width: 280px;
`;
const LogCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 6px;
`;
const TopRow = styled.div`
display: flex;
align-items: center;
gap: 6px;
`;
const Dot = styled.span<{ $color: string }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $color }) => $color};
`;
const SubjectName = styled.span`
font-size: 12px;
font-weight: 700;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const TagName = styled.span`
font-size: 12px;
color: ${theme.color.textMute};
`;
const Spacer = styled.span`
flex: 1;
`;
const ResultBadge = styled(Badge)``;
const LogTitle = styled.h3`
font-size: 15px;
font-weight: 700;
`;
const Memo = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
white-space: pre-wrap;
`;
const MetaRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.space.md};
margin-top: 4px;
`;
const Meta = styled.span`
font-size: 11px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Empty = styled.div`
padding: ${theme.space.xxl};
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: ${theme.space.md};
`;
const EmptyEmoji = styled.div`
font-size: 56px;
`;
const EmptyTitle = styled.h2`
font-size: 18px;
font-weight: 700;
`;

View File

@@ -0,0 +1,387 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type StudyResult, type Subject } from '@/lib/api';
import AppShell from '@/components/layout/AppShell';
import {
Button,
Card,
ErrorText,
HelpText,
Input,
Label,
SectionTitle,
Select,
Stack,
Textarea,
} from '@/components/ui/primitives';
export default function StudyPage() {
return (
<AppShell>
<StudyBody />
</AppShell>
);
}
function StudyBody() {
const router = useRouter();
const [subjects, setSubjects] = useState<Subject[] | null>(null);
const [subjectId, setSubjectId] = useState<number | ''>('');
const [tagId, setTagId] = useState<number | ''>('');
const [title, setTitle] = useState('');
const [difficulty, setDifficulty] = useState(0.5);
const [baseCorrectRate, setBaseCorrectRate] = useState<string>('');
const [result, setResult] = useState<StudyResult>('correct');
const [memo, setMemo] = useState('');
const [timeSpent, setTimeSpent] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api.get<Subject[]>('/subjects').then((r) => {
setSubjects(r.data);
if (r.data.length > 0) setSubjectId(r.data[0].id);
});
}, []);
const currentSubject = useMemo(
() => subjects?.find((s) => s.id === subjectId) ?? null,
[subjects, subjectId],
);
const currentTags = currentSubject?.tags ?? [];
useEffect(() => {
setTagId('');
}, [subjectId]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!subjectId || !title.trim()) {
setErr('과목과 제목은 필수야');
return;
}
setErr(null);
setSubmitting(true);
try {
await api.post('/study-logs', {
subjectId,
tagId: tagId || undefined,
title: title.trim(),
difficulty,
baseCorrectRate: baseCorrectRate === '' ? undefined : Number(baseCorrectRate) / 100,
result,
memo: memo.trim() || undefined,
timeSpent: timeSpent === '' ? undefined : Number(timeSpent),
});
router.push('/dashboard');
} catch {
setErr('저장에 실패했어. 다시 시도해줘.');
} finally {
setSubmitting(false);
}
};
if (subjects === null) return <Loading> ...</Loading>;
if (subjects.length === 0) {
return (
<Empty>
<EmptyEmoji>📚</EmptyEmoji>
<EmptyTitle> </EmptyTitle>
<EmptySub> .</EmptySub>
<Button $size="md" onClick={() => router.push('/subjects')}>
</Button>
</Empty>
);
}
return (
<Wrap>
<Header>
<Title> </Title>
<Sub> .</Sub>
</Header>
<form onSubmit={submit}>
<Card>
<Stack $gap={theme.space.md}>
<SectionTitle> </SectionTitle>
<Row>
<Field>
<Label></Label>
<Select
value={subjectId}
onChange={(e) => setSubjectId(Number(e.target.value))}
>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</Field>
<Field>
<Label> ()</Label>
<Select
value={tagId}
onChange={(e) =>
setTagId(e.target.value === '' ? '' : Number(e.target.value))
}
>
<option value=""> </option>
{currentTags.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
</Field>
</Row>
<div>
<Label> </Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예) 2024 수능 수학 15번"
required
/>
</div>
<SectionTitle> </SectionTitle>
<ResultGrid>
<ResultChoice
type="button"
$active={result === 'correct'}
$tone="success"
onClick={() => setResult('correct')}
>
<ResultMark></ResultMark>
<ResultLabel></ResultLabel>
</ResultChoice>
<ResultChoice
type="button"
$active={result === 'partial'}
$tone="warning"
onClick={() => setResult('partial')}
>
<ResultMark></ResultMark>
<ResultLabel></ResultLabel>
</ResultChoice>
<ResultChoice
type="button"
$active={result === 'incorrect'}
$tone="danger"
onClick={() => setResult('incorrect')}
>
<ResultMark></ResultMark>
<ResultLabel></ResultLabel>
</ResultChoice>
</ResultGrid>
<div>
<Label> · {Math.round(difficulty * 100)}%</Label>
<input
type="range"
min={0}
max={1}
step={0.05}
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
style={{ width: '100%' }}
/>
<HelpText>0% , 100% .</HelpText>
</div>
<Row>
<Field>
<Label> ()</Label>
<Input
type="number"
min={0}
max={100}
value={baseCorrectRate}
onChange={(e) => setBaseCorrectRate(e.target.value)}
placeholder="예) 45"
/>
<HelpText> (%) .</HelpText>
</Field>
<Field>
<Label> (, )</Label>
<Input
type="number"
min={0}
value={timeSpent}
onChange={(e) => setTimeSpent(e.target.value)}
placeholder="예) 180"
/>
</Field>
</Row>
<div>
<Label> ()</Label>
<Textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="어디서 막혔는지, 무엇을 배웠는지 짧게 남겨봐."
/>
</div>
{err && <ErrorText>{err}</ErrorText>}
<ButtonRow>
<Button
type="button"
$variant="ghost"
$size="md"
disabled={submitting}
onClick={() => router.back()}
>
</Button>
<Button type="submit" $size="md" disabled={submitting}>
{submitting ? '저장 중...' : '기록하고 복습 예약'}
</Button>
</ButtonRow>
</Stack>
</Card>
</form>
</Wrap>
);
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
max-width: 720px;
margin: 0 auto;
width: 100%;
`;
const Header = styled.header``;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-top: 4px;
`;
const Row = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: ${theme.space.md};
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr;
}
`;
const Field = styled.div`
min-width: 0;
`;
const ResultGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: ${theme.space.sm};
`;
const ResultChoice = styled.button<{
$active: boolean;
$tone: 'success' | 'warning' | 'danger';
}>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
background: ${({ $active }) =>
$active ? theme.color.surfaceHover : theme.color.surface2};
border: 1px solid
${({ $active, $tone }) =>
$active
? $tone === 'success'
? theme.color.success
: $tone === 'warning'
? theme.color.warning
: theme.color.danger
: theme.color.border};
color: ${({ $active, $tone }) =>
$active
? $tone === 'success'
? theme.color.success
: $tone === 'warning'
? theme.color.warning
: theme.color.danger
: theme.color.textSub};
cursor: pointer;
min-height: 72px;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
border-color: ${theme.color.accent};
}
`;
const ResultMark = styled.span`
font-size: 22px;
font-weight: 800;
`;
const ResultLabel = styled.span`
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const ButtonRow = styled.div`
display: flex;
gap: ${theme.space.sm};
justify-content: flex-end;
margin-top: ${theme.space.sm};
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Empty = styled.div`
padding: ${theme.space.xxl};
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: ${theme.space.md};
`;
const EmptyEmoji = styled.div`
font-size: 56px;
`;
const EmptyTitle = styled.h2`
font-size: 20px;
font-weight: 700;
`;
const EmptySub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
`;

View File

@@ -0,0 +1,205 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type Subject, type Tag } from '@/lib/api';
import AppShell from '@/components/layout/AppShell';
import {
Badge,
Button,
Card,
ErrorText,
Input,
Label,
SectionTitle,
Stack,
} from '@/components/ui/primitives';
export default function SubjectDetailPage() {
return (
<AppShell>
<SubjectDetailBody />
</AppShell>
);
}
function SubjectDetailBody() {
const params = useParams<{ id: string }>();
const router = useRouter();
const subjectId = Number(params.id);
const [subject, setSubject] = useState<Subject | null>(null);
const [tags, setTags] = useState<Tag[] | null>(null);
const [tagName, setTagName] = useState('');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const load = useCallback(() => {
api.get<Subject>(`/subjects/${subjectId}`).then((r) => setSubject(r.data));
api
.get<Tag[]>(`/tags/by-subject/${subjectId}`)
.then((r) => setTags(r.data));
}, [subjectId]);
useEffect(() => {
if (!Number.isFinite(subjectId)) return;
load();
}, [subjectId, load]);
const addTag = async (e: React.FormEvent) => {
e.preventDefault();
if (!tagName.trim()) return;
setErr(null);
setSaving(true);
try {
await api.post('/tags', { subjectId, name: tagName.trim() });
setTagName('');
load();
} catch {
setErr('태그 추가에 실패했어.');
} finally {
setSaving(false);
}
};
const removeTag = async (id: number) => {
if (!confirm('이 태그를 삭제할까? 연결된 학습기록은 태그 없는 상태가 돼.')) return;
try {
await api.delete(`/tags/${id}`);
load();
} catch {
alert('삭제 실패');
}
};
if (subject === null || tags === null) return <Loading> ...</Loading>;
return (
<Wrap>
<BackRow>
<Button $variant="ghost" $size="sm" onClick={() => router.push('/subjects')}>
</Button>
</BackRow>
<Header>
<TitleLine>
<Dot $color={subject.color} />
<Title>{subject.name}</Title>
<Badge>{tags.length} </Badge>
</TitleLine>
<Sub> .</Sub>
</Header>
<Card>
<SectionTitle> </SectionTitle>
<form onSubmit={addTag}>
<Stack $gap={theme.space.md}>
<div>
<Label></Label>
<Input
value={tagName}
onChange={(e) => setTagName(e.target.value)}
placeholder="예) 확률과 통계, 수열, 극한..."
maxLength={40}
/>
</div>
{err && <ErrorText>{err}</ErrorText>}
<div>
<Button type="submit" $size="md" disabled={saving || !tagName.trim()}>
{saving ? '저장 중...' : '태그 추가'}
</Button>
</div>
</Stack>
</form>
</Card>
<div>
<SectionTitle> </SectionTitle>
{tags.length === 0 ? (
<Empty> .</Empty>
) : (
<TagGrid>
{tags.map((t) => (
<TagCard key={t.id}>
<TagName>{t.name}</TagName>
<Button
$variant="ghost"
$size="sm"
onClick={() => removeTag(t.id)}
>
</Button>
</TagCard>
))}
</TagGrid>
)}
</div>
</Wrap>
);
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const BackRow = styled.div``;
const Header = styled.header``;
const TitleLine = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.sm};
`;
const Dot = styled.span<{ $color: string }>`
width: 14px;
height: 14px;
border-radius: 50%;
background: ${({ $color }) => $color};
`;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-top: 6px;
`;
const TagGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: ${theme.space.sm};
`;
const TagCard = styled(Card)`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${theme.space.md};
`;
const TagName = styled.span`
font-size: 14px;
font-weight: 600;
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Empty = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textMute};
border: 1px dashed ${theme.color.border};
border-radius: ${theme.radius.md};
`;

View File

@@ -0,0 +1,254 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type Subject } from '@/lib/api';
import AppShell from '@/components/layout/AppShell';
import {
Badge,
Button,
Card,
ErrorText,
Input,
Label,
SectionTitle,
Stack,
} from '@/components/ui/primitives';
const PALETTE = [
'#6366f1',
'#8b5cf6',
'#ec4899',
'#f43f5e',
'#f59e0b',
'#10b981',
'#14b8a6',
'#0ea5e9',
];
export default function SubjectsPage() {
return (
<AppShell>
<SubjectsBody />
</AppShell>
);
}
function SubjectsBody() {
const [subjects, setSubjects] = useState<Subject[] | null>(null);
const [name, setName] = useState('');
const [color, setColor] = useState(PALETTE[0]);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const load = useCallback(() => {
api.get<Subject[]>('/subjects').then((r) => setSubjects(r.data));
}, []);
useEffect(() => {
load();
}, [load]);
const create = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setErr(null);
setSaving(true);
try {
await api.post('/subjects', { name: name.trim(), color });
setName('');
setColor(PALETTE[0]);
load();
} catch {
setErr('과목 생성에 실패했어.');
} finally {
setSaving(false);
}
};
const remove = async (id: number) => {
if (!confirm('이 과목과 연결된 태그·학습기록은 어떻게 되는지 확인하고 진행해. 정말 삭제할까?')) return;
try {
await api.delete(`/subjects/${id}`);
load();
} catch {
alert('삭제에 실패했어. 연결된 데이터가 있을 수 있어.');
}
};
return (
<Wrap>
<Header>
<Title></Title>
<Sub> .</Sub>
</Header>
<Card>
<SectionTitle> </SectionTitle>
<form onSubmit={create}>
<Stack $gap={theme.space.md}>
<div>
<Label></Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예) 국어, 수학, 한국사..."
maxLength={40}
/>
</div>
<div>
<Label></Label>
<Palette>
{PALETTE.map((c) => (
<Swatch
key={c}
type="button"
$color={c}
$active={c === color}
onClick={() => setColor(c)}
aria-label={c}
/>
))}
</Palette>
</div>
{err && <ErrorText>{err}</ErrorText>}
<div>
<Button type="submit" $size="md" disabled={saving || !name.trim()}>
{saving ? '저장 중...' : '과목 추가'}
</Button>
</div>
</Stack>
</form>
</Card>
<div>
<SectionTitle> {subjects ? `(${subjects.length})` : ''}</SectionTitle>
{subjects === null ? (
<Loading> ...</Loading>
) : subjects.length === 0 ? (
<Empty> . .</Empty>
) : (
<Grid>
{subjects.map((s) => (
<SubjectCard key={s.id}>
<CardTop>
<Dot $color={s.color} />
<SubjectName>{s.name}</SubjectName>
<Badge>{s.tags?.length ?? 0} </Badge>
</CardTop>
<CardActions>
<Link href={`/subjects/${s.id}`}>
<Button $variant="secondary" $size="sm">
</Button>
</Link>
<Button
$variant="ghost"
$size="sm"
onClick={() => remove(s.id)}
>
</Button>
</CardActions>
</SubjectCard>
))}
</Grid>
)}
</div>
</Wrap>
);
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const Header = styled.header``;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
`;
const Sub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
margin-top: 4px;
`;
const Palette = styled.div`
display: flex;
gap: ${theme.space.sm};
flex-wrap: wrap;
`;
const Swatch = styled.button<{ $color: string; $active: boolean }>`
width: 36px;
height: 36px;
border-radius: 50%;
background: ${({ $color }) => $color};
border: 2px solid
${({ $active }) => ($active ? 'white' : 'transparent')};
box-shadow: ${({ $active }) =>
$active ? '0 0 0 2px rgba(99,102,241,0.6)' : 'none'};
cursor: pointer;
transition: transform 0.1s ease;
&:hover {
transform: scale(1.08);
}
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: ${theme.space.md};
`;
const SubjectCard = styled(Card)`
display: flex;
flex-direction: column;
gap: ${theme.space.md};
`;
const CardTop = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.sm};
`;
const Dot = styled.span<{ $color: string }>`
width: 12px;
height: 12px;
border-radius: 50%;
background: ${({ $color }) => $color};
`;
const SubjectName = styled.h3`
font-size: 16px;
font-weight: 700;
flex: 1;
min-width: 0;
`;
const CardActions = styled.div`
display: flex;
gap: ${theme.space.sm};
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const Empty = styled.div`
padding: ${theme.space.xl};
text-align: center;
color: ${theme.color.textMute};
border: 1px dashed ${theme.color.border};
border-radius: ${theme.radius.md};
`;

View File

@@ -0,0 +1,94 @@
'use client';
import React from 'react';
import {
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { theme } from '@/styles/theme';
import type { ForgetCurveResponse } from '@/lib/api';
interface Props {
data: ForgetCurveResponse;
threshold?: number;
}
export default function ForgetCurveChart({ data, threshold = 0.5 }: Props) {
const points = data.points.map((p) => ({
day: Number(p.t.toFixed(2)),
memory: Number((p.s * 100).toFixed(1)),
p: Number((p.p * 100).toFixed(1)),
}));
return (
<div style={{ width: '100%', height: 320 }}>
<ResponsiveContainer>
<LineChart data={points} margin={{ top: 12, right: 16, left: 0, bottom: 8 }}>
<CartesianGrid stroke={theme.color.border} strokeDasharray="3 3" />
<XAxis
dataKey="day"
stroke={theme.color.textMute}
tick={{ fontSize: 11 }}
label={{
value: 'days',
position: 'insideBottomRight',
offset: -4,
fill: theme.color.textMute,
fontSize: 11,
}}
/>
<YAxis
stroke={theme.color.textMute}
tick={{ fontSize: 11 }}
domain={[0, 100]}
unit="%"
/>
<Tooltip
contentStyle={{
background: theme.color.surface,
border: `1px solid ${theme.color.border}`,
borderRadius: 8,
fontSize: 12,
}}
labelFormatter={(v) => `${v}일 후`}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine
y={threshold * 100}
stroke={theme.color.warning}
strokeDasharray="4 4"
label={{
value: `임계값 ${Math.round(threshold * 100)}%`,
fill: theme.color.warning,
fontSize: 10,
position: 'insideTopRight',
}}
/>
<Line
type="monotone"
dataKey="memory"
name="기억 강도 S(t)"
stroke={theme.color.accent}
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="p"
name="정답 확률 P"
stroke={theme.color.accent2}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { api, type MeUser } from '@/lib/api';
import { hasToken, clearToken } from '@/lib/auth';
import BottomNav from './BottomNav';
import SideNav from './SideNav';
interface Props {
children: React.ReactNode;
requireOnboarding?: boolean;
}
const PUBLIC_ROUTES = ['/', '/login', '/register'];
export default function AppShell({ children, requireOnboarding = true }: Props) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<MeUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!hasToken()) {
router.replace('/login');
return;
}
api
.get<MeUser>('/auth/me')
.then((r) => {
setUser(r.data);
setLoading(false);
if (requireOnboarding && !r.data.onboarded && pathname !== '/onboarding') {
router.replace('/onboarding');
}
})
.catch(() => {
clearToken();
router.replace('/login');
});
}, [router, pathname, requireOnboarding]);
if (loading || !user) {
return (
<Center>
<LoadingMark> ...</LoadingMark>
</Center>
);
}
const showNav = !PUBLIC_ROUTES.includes(pathname) && pathname !== '/onboarding';
return (
<Layout>
{showNav && <SideNav user={user} />}
<Main $withNav={showNav}>{children}</Main>
{showNav && <BottomNav />}
</Layout>
);
}
const Layout = styled.div`
display: flex;
min-height: 100vh;
`;
const Main = styled.main<{ $withNav: boolean }>`
flex: 1;
padding: ${theme.space.xl};
padding-bottom: ${({ $withNav }) => ($withNav ? '88px' : theme.space.xl)};
max-width: 1200px;
margin: 0 auto;
width: 100%;
min-width: 0;
@media (max-width: ${theme.breakpoint.tablet}) {
padding: ${theme.space.md};
padding-bottom: ${({ $withNav }) => ($withNav ? '80px' : theme.space.md)};
}
@media (min-width: ${theme.breakpoint.desktop}) {
padding-bottom: ${theme.space.xl};
}
`;
const Center = styled.div`
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
`;
const LoadingMark = styled.div`
font-family: ${theme.font.mono};
font-size: 13px;
color: ${theme.color.textSub};
`;

View File

@@ -0,0 +1,70 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
const TABS = [
{ href: '/dashboard', label: '대시', icon: '🏠' },
{ href: '/review', label: '복습', icon: '📖' },
{ href: '/study', label: '학습', icon: '✏️' },
{ href: '/stats', label: '통계', icon: '📊' },
{ href: '/profile', label: '프로필', icon: '👤' },
];
export default function BottomNav() {
const pathname = usePathname();
return (
<Nav>
{TABS.map((t) => {
const active = pathname.startsWith(t.href);
return (
<Tab key={t.href} href={t.href} $active={active}>
<Icon>{t.icon}</Icon>
<Label>{t.label}</Label>
</Tab>
);
})}
</Nav>
);
}
const Nav = styled.nav`
display: none;
@media (max-width: ${theme.breakpoint.tablet}) {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: ${theme.color.surface};
border-top: 1px solid ${theme.color.border};
z-index: 100;
padding-bottom: env(safe-area-inset-bottom);
}
`;
const Tab = styled(Link)<{ $active: boolean }>`
flex: 1;
min-height: 64px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: ${({ $active }) => ($active ? theme.color.accent : theme.color.textMute)};
transition: color 0.15s;
border-top: 2px solid ${({ $active }) => ($active ? theme.color.accent : 'transparent')};
`;
const Icon = styled.span`
font-size: 20px;
`;
const Label = styled.span`
font-size: 10px;
font-weight: 600;
`;

View File

@@ -0,0 +1,196 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { PERSONA_META } from '@/lib/constants';
import { clearToken } from '@/lib/auth';
import type { MeUser } from '@/lib/api';
const NAV = [
{ href: '/dashboard', label: '대시보드', icon: '🏠' },
{ href: '/review', label: '복습', icon: '📖' },
{ href: '/study', label: '학습 기록', icon: '✏️' },
{ href: '/subjects', label: '과목', icon: '📚' },
{ href: '/stats', label: '통계', icon: '📊' },
{ href: '/profile', label: '프로필', icon: '👤' },
];
export default function SideNav({ user }: { user: MeUser }) {
const pathname = usePathname();
const router = useRouter();
const handleLogout = () => {
clearToken();
router.replace('/login');
};
const personaMeta = PERSONA_META[user.persona];
return (
<Aside>
<Brand>
<Logo>ReLoop</Logo>
<BrandSub> </BrandSub>
</Brand>
<UserCard>
<PersonaEmoji $color={personaMeta.color}>{personaMeta.emoji}</PersonaEmoji>
<UserInfo>
<Nickname>{user.nickname}</Nickname>
<UserMeta>
{personaMeta.label} ·{' '}
{user.currentGrade ? `${user.currentGrade}등급` : '미설정'}
</UserMeta>
</UserInfo>
</UserCard>
<NavList>
{NAV.map((item) => (
<NavItem
key={item.href}
href={item.href}
$active={pathname.startsWith(item.href)}
>
<Icon>{item.icon}</Icon>
<span>{item.label}</span>
</NavItem>
))}
</NavList>
<Bottom>
<LogoutBtn onClick={handleLogout}></LogoutBtn>
</Bottom>
</Aside>
);
}
const Aside = styled.aside`
width: 240px;
flex-shrink: 0;
padding: ${theme.space.xl} ${theme.space.lg};
border-right: 1px solid ${theme.color.border};
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
@media (max-width: ${theme.breakpoint.tablet}) {
display: none;
}
`;
const Brand = styled.div``;
const Logo = styled.h1`
font-size: 24px;
font-weight: 800;
color: ${theme.color.textMain};
letter-spacing: -0.02em;
`;
const BrandSub = styled.span`
font-size: 11px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
letter-spacing: 0.06em;
text-transform: uppercase;
`;
const UserCard = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.md};
padding: ${theme.space.md};
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
`;
const PersonaEmoji = styled.div<{ $color: string }>`
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: ${({ $color }) => $color}22;
border: 1px solid ${({ $color }) => $color}66;
border-radius: ${theme.radius.md};
font-size: 20px;
flex-shrink: 0;
`;
const UserInfo = styled.div`
min-width: 0;
`;
const Nickname = styled.div`
font-size: 14px;
font-weight: 700;
color: ${theme.color.textMain};
`;
const UserMeta = styled.div`
font-size: 11px;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;
const NavList = styled.nav`
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
`;
const NavItem = styled(Link)<{ $active: boolean }>`
display: flex;
align-items: center;
gap: ${theme.space.sm};
padding: 10px 14px;
border-radius: ${theme.radius.md};
font-size: 14px;
font-weight: 500;
color: ${({ $active }) => ($active ? theme.color.textMain : theme.color.textSub)};
background: ${({ $active }) => ($active ? theme.color.surface : 'transparent')};
border: 1px solid ${({ $active }) => ($active ? theme.color.border : 'transparent')};
transition: color 0.15s, background 0.15s;
&:hover {
color: ${theme.color.textMain};
background: ${theme.color.surface};
}
`;
const Icon = styled.span`
font-size: 18px;
width: 22px;
display: inline-flex;
justify-content: center;
`;
const Bottom = styled.div`
margin-top: auto;
`;
const LogoutBtn = styled.button`
width: 100%;
padding: 10px;
background: transparent;
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
color: ${theme.color.textSub};
font-size: 13px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
&:hover {
color: ${theme.color.danger};
border-color: ${theme.color.danger};
}
`;

View File

@@ -0,0 +1,259 @@
'use client';
import styled, { css } from 'styled-components';
import { theme } from '@/styles/theme';
export const Card = styled.div<{ $hoverable?: boolean }>`
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
padding: ${theme.space.lg};
${({ $hoverable }) =>
$hoverable &&
css`
transition: border-color 0.15s ease, transform 0.15s ease;
cursor: pointer;
&:hover {
border-color: ${theme.color.accent};
transform: translateY(-1px);
}
`}
`;
export const Button = styled.button<{
$variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
$block?: boolean;
$size?: 'sm' | 'md' | 'lg';
}>`
display: inline-flex;
align-items: center;
justify-content: center;
gap: ${theme.space.sm};
border-radius: ${theme.radius.md};
font-weight: 600;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
white-space: nowrap;
min-height: 44px;
${({ $size }) => {
switch ($size) {
case 'sm':
return css`
padding: 6px 12px;
font-size: 13px;
min-height: 36px;
`;
case 'lg':
return css`
padding: 14px 24px;
font-size: 16px;
min-height: 52px;
`;
case 'md':
default:
return css`
padding: 10px 18px;
font-size: 14px;
`;
}
}}
${({ $block }) => $block && 'width: 100%;'}
${({ $variant = 'primary' }) => {
switch ($variant) {
case 'primary':
return css`
background: ${theme.color.accent};
color: white;
border: 1px solid ${theme.color.accent};
&:hover:not(:disabled) {
background: ${theme.color.accentHover};
border-color: ${theme.color.accentHover};
}
`;
case 'secondary':
return css`
background: ${theme.color.surface2};
color: ${theme.color.textMain};
border: 1px solid ${theme.color.border};
&:hover:not(:disabled) {
border-color: ${theme.color.accent};
background: ${theme.color.surfaceHover};
}
`;
case 'ghost':
return css`
background: transparent;
color: ${theme.color.textSub};
border: 1px solid transparent;
&:hover:not(:disabled) {
color: ${theme.color.textMain};
border-color: ${theme.color.border};
}
`;
case 'danger':
return css`
background: ${theme.color.danger};
color: white;
border: 1px solid ${theme.color.danger};
&:hover:not(:disabled) {
filter: brightness(1.1);
}
`;
}
}}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:active:not(:disabled) {
transform: translateY(1px);
}
`;
export const Input = styled.input`
width: 100%;
padding: 12px 14px;
background: ${theme.color.surface2};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
color: ${theme.color.textMain};
font-size: 16px;
min-height: 48px;
transition: border-color 0.15s ease;
&::placeholder {
color: ${theme.color.textMute};
}
&:focus {
outline: none;
border-color: ${theme.color.accent};
}
`;
export const Select = styled.select`
width: 100%;
padding: 12px 14px;
background: ${theme.color.surface2};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
color: ${theme.color.textMain};
font-size: 16px;
min-height: 48px;
cursor: pointer;
&:focus {
outline: none;
border-color: ${theme.color.accent};
}
`;
export const Textarea = styled.textarea`
width: 100%;
padding: 12px 14px;
background: ${theme.color.surface2};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
color: ${theme.color.textMain};
font-size: 16px;
resize: vertical;
min-height: 84px;
font-family: inherit;
&::placeholder {
color: ${theme.color.textMute};
}
&:focus {
outline: none;
border-color: ${theme.color.accent};
}
`;
export const Label = styled.label`
display: block;
font-size: 12px;
font-weight: 600;
color: ${theme.color.textSub};
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
`;
export const Badge = styled.span<{ $variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' }>`
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: ${theme.radius.pill};
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
${({ $variant = 'default' }) => {
switch ($variant) {
case 'success':
return css`
background: rgba(34, 197, 94, 0.15);
color: ${theme.color.success};
border: 1px solid rgba(34, 197, 94, 0.35);
`;
case 'warning':
return css`
background: rgba(245, 158, 11, 0.15);
color: ${theme.color.warning};
border: 1px solid rgba(245, 158, 11, 0.35);
`;
case 'danger':
return css`
background: rgba(239, 68, 68, 0.15);
color: ${theme.color.danger};
border: 1px solid rgba(239, 68, 68, 0.35);
`;
case 'info':
return css`
background: rgba(56, 189, 248, 0.15);
color: ${theme.color.info};
border: 1px solid rgba(56, 189, 248, 0.35);
`;
default:
return css`
background: ${theme.color.surface2};
color: ${theme.color.textSub};
border: 1px solid ${theme.color.border};
`;
}
}}
`;
export const SectionTitle = styled.h2`
font-size: 14px;
font-weight: 700;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: ${theme.space.md};
`;
export const Stack = styled.div<{ $gap?: string; $horizontal?: boolean }>`
display: flex;
flex-direction: ${({ $horizontal }) => ($horizontal ? 'row' : 'column')};
gap: ${({ $gap }) => $gap ?? theme.space.md};
`;
export const ErrorText = styled.p`
color: ${theme.color.danger};
font-size: 13px;
margin-top: 4px;
`;
export const HelpText = styled.p`
color: ${theme.color.textMute};
font-size: 12px;
`;

127
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,127 @@
'use client';
import axios, { AxiosError } from 'axios';
import { getToken, clearToken } from './auth';
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL ?? 'https://reloop-api.nabomhalang.co.kr/api';
export const api = axios.create({
baseURL: API_BASE_URL,
withCredentials: false,
});
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(r) => r,
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token invalid / expired — clear and redirect on next render
clearToken();
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);
export type Persona = 'senior' | 'mid' | 'junior' | 'crammer';
export type ReviewIntensity = 'strict' | 'moderate' | 'relaxed';
export type StudyResult = 'correct' | 'incorrect' | 'partial';
export type ReviewStatus = 'pending' | 'done' | 'skipped' | 'expired';
export interface MeUser {
id: number;
email: string;
nickname: string;
persona: Persona;
currentGrade: number | null;
targetGrade: number | null;
reviewIntensity: ReviewIntensity;
onboarded: boolean;
createdAt: string;
}
export interface Subject {
id: number;
name: string;
color: string;
tags?: Tag[];
}
export interface Tag {
id: number;
name: string;
subjectId: number;
}
export interface StudyLog {
id: number;
subjectId: number;
tagId: number | null;
title: string;
difficulty: number;
baseCorrectRate: number | null;
result: StudyResult;
memo: string | null;
studiedAt: string;
timeSpent: number | null;
subject?: { id: number; name: string; color: string };
tag?: { id: number; name: string } | null;
reviewSchedules?: Array<{
id: number;
scheduledAt: string;
status: ReviewStatus;
predictedP: number | null;
}>;
}
export interface QueueItem {
id: number;
studyLogId: number;
scheduledAt: string;
predictedP: number | null;
iteration: number;
status: ReviewStatus;
studyLog: StudyLog & {
subject: { id: number; name: string; color: string };
tag: { id: number; name: string } | null;
};
}
export interface DashboardSummary {
user: {
nickname: string;
persona: Persona;
currentGrade: number | null;
targetGrade: number | null;
reviewIntensity: ReviewIntensity;
onboarded: boolean;
};
queue: { overdue: number; soon: number; total: number };
weekly: { correct: number; incorrect: number; partial: number };
recentLogs: StudyLog[];
topSkills: Array<{
id: number;
s0: number;
sampleCount: number;
tag: { id: number; name: string; subject: Subject };
}>;
}
export interface ForgetCurveResponse {
tag: { id: number; name: string; subject: Subject };
snapshot: { s0: number; lastUpdatedAt: string; sampleCount: number };
persona: Persona;
intensity: ReviewIntensity;
difficulty: number;
points: Array<{ t: number; s: number; p: number }>;
}

22
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
'use client';
const TOKEN_KEY = 'reloop.token';
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(TOKEN_KEY);
}
export function hasToken(): boolean {
return !!getToken();
}

View File

@@ -0,0 +1,56 @@
import type { Persona, ReviewIntensity } from './api';
export const PERSONA_META: Record<
Persona,
{ label: string; emoji: string; color: string; lambda: number; desc: string }
> = {
senior: {
label: '상위권',
emoji: '🧊',
color: '#34d399',
lambda: 0.1,
desc: '잘 안 까먹음. 복습 간격이 넓어도 유지돼.',
},
mid: {
label: '중위권',
emoji: '🌊',
color: '#60a5fa',
lambda: 0.2,
desc: '평균적인 망각 속도. 꾸준한 복습으로 관리.',
},
junior: {
label: '하위권',
emoji: '🔥',
color: '#fbbf24',
lambda: 0.4,
desc: '금방 까먹어서 자주 복습해야 해.',
},
crammer: {
label: '벼락치기형',
emoji: '⚡',
color: '#f472b6',
lambda: 0.6,
desc: '단기 집중형. 망각 속도가 매우 빨라 잦은 복습 필수.',
},
};
export const INTENSITY_META: Record<
ReviewIntensity,
{ label: string; desc: string; threshold: number }
> = {
strict: {
label: '빡세게',
desc: '아직 맞출 확률이 꽤 높을 때 미리 복습. 자주 등장.',
threshold: 0.7,
},
moderate: {
label: '적당히',
desc: '반반일 때 복습. 균형잡힌 기본 설정.',
threshold: 0.5,
},
relaxed: {
label: '느슨하게',
desc: '많이 까먹은 후 복습. 간격이 넓음.',
threshold: 0.35,
},
};

View File

@@ -0,0 +1,85 @@
'use client';
import { createGlobalStyle } from 'styled-components';
import { theme } from './theme';
const GlobalStyle = createGlobalStyle`
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: ${theme.font.sans};
background: ${theme.color.bgGradient};
background-attachment: fixed;
color: ${theme.color.textMain};
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.55;
}
/* Subtle radial accent glows behind everything */
body::before {
content: '';
position: fixed;
inset: -50%;
background:
radial-gradient(circle at 20% 30%, rgba(99, 102, 241, 0.10) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(139, 92, 246, 0.08) 0%, transparent 50%);
z-index: -1;
pointer-events: none;
}
a {
color: inherit;
text-decoration: none;
}
button, input, select, textarea {
font-family: inherit;
font-size: inherit;
}
/* iOS zoom prevention — inputs must be ≥ 16px */
input, select, textarea {
font-size: 16px;
}
button {
cursor: pointer;
border: none;
background: transparent;
color: inherit;
}
ul, ol { list-style: none; }
::selection {
background: ${theme.color.accent};
color: white;
}
/* Scrollbar — subtle */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: ${theme.color.border};
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: ${theme.color.textMute};
}
`;
export default GlobalStyle;

View File

@@ -0,0 +1,30 @@
'use client';
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
/**
* styled-components SSR registry for Next.js 14 App Router.
*/
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
const [sheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = sheet.getStyleElement();
sheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== 'undefined') {
return <>{children}</>;
}
return (
<StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>
);
}

View File

@@ -0,0 +1,86 @@
/**
* ReLoop v2 design tokens.
*
* Philosophy: 학습앱 = 집중 + 깔끔. 어두운 그라데이션 배경 유지하되
* 정보가 흐리지 않게 surface contrast 를 분명히. 모바일 first (≥ 360px).
*/
export const theme = {
color: {
bg: '#0b1020',
bgGradient:
'linear-gradient(160deg, #0b1020 0%, #141934 45%, #1a1530 100%)',
surface: '#141934',
surface2: '#1c2340',
surfaceHover: '#242c4a',
border: '#2a3256',
borderSoft: '#1f2642',
textMain: '#f1f5f9',
textSub: '#94a3b8',
textMute: '#64748b',
accent: '#6366f1', // indigo — 주 액센트
accentHover: '#818cf8',
accent2: '#8b5cf6', // violet — 보조
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
info: '#38bdf8',
},
persona: {
senior: { label: '상위권', color: '#34d399', emoji: '🧊' },
mid: { label: '중위권', color: '#60a5fa', emoji: '🌊' },
junior: { label: '하위권', color: '#fbbf24', emoji: '🔥' },
crammer: { label: '벼락치기형', color: '#f472b6', emoji: '⚡' },
},
space: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
xxl: '48px',
},
radius: {
sm: '6px',
md: '10px',
lg: '16px',
xl: '20px',
pill: '9999px',
},
font: {
sans:
"'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
mono:
"'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
},
shadow: {
sm: '0 2px 8px rgba(0, 0, 0, 0.3)',
md: '0 6px 20px rgba(0, 0, 0, 0.35)',
lg: '0 16px 40px rgba(0, 0, 0, 0.45)',
glow: '0 0 24px rgba(99, 102, 241, 0.25)',
},
breakpoint: {
mobile: '480px',
tablet: '768px',
desktop: '1024px',
wide: '1280px',
},
};
export type Theme = typeof theme;
export const PERSONA_ORDER: Array<keyof typeof theme.persona> = [
'senior',
'mid',
'junior',
'crammer',
];

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}