chore: PS 데이터 잔재 전면 제거 + 국어 통계 차단 확인

- backend study-logs: psProblemId input/include/응답매핑/검증 제거
- frontend study-logs/[id]: PS 전용 렌더링(PsTypeBadge, TierBadge, BOJ 링크,
  normalizePsTags, getTierColor 등) 전부 제거, problem null 시 fallback 유지
- frontend study/history: PS 뱃지 렌더링/헬퍼 제거
- frontend onboarding: GoalType 'ps' 항목, BOJ 핸들 연결 UI, 관련 state 제거
- frontend api.ts: GoalType 'ps', psProblem 타입, getBojTierLabel 제거

국어 통계는 backend stats.service 의 name='수학' 필터로 이미 차단 확인.
Prisma schema 의 PsProblem/PsBookmark 모델은 유지 (마이그레이션 없음).
This commit is contained in:
reloop
2026-04-16 11:52:04 +09:00
parent 03ce2c64fd
commit 07be0c581a
5 changed files with 24 additions and 399 deletions

View File

@@ -18,7 +18,6 @@ export interface CreateStudyLogInput {
subjectId: number;
tagId?: number;
problemId?: number;
psProblemId?: number;
title: string;
difficulty: number;
baseCorrectRate?: number | null;
@@ -306,15 +305,6 @@ export class StudyLogsService {
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },
psProblem: {
select: {
bojId: true,
title: true,
titleKo: true,
level: true,
tags: true,
},
},
reviewSchedules: {
orderBy: { scheduledAt: "desc" },
take: 1,
@@ -332,15 +322,6 @@ export class StudyLogsService {
include: {
subject: { select: { id: true, name: true, color: true } },
tag: { select: { id: true, name: true } },
psProblem: {
select: {
bojId: true,
title: true,
titleKo: true,
level: true,
tags: true,
},
},
problem: {
include: {
passage: true,
@@ -370,16 +351,6 @@ export class StudyLogsService {
baseCorrectRate: log.baseCorrectRate,
subject: log.subject,
tag: log.tag,
psProblem: log.psProblem
? {
bojId: log.psProblem.bojId,
title: log.psProblem.title,
titleKo: log.psProblem.titleKo,
level: log.psProblem.level,
tags: log.psProblem.tags,
bojUrl: `https://www.acmicpc.net/problem/${log.psProblem.bojId}`,
}
: null,
problem: log.problem
? {
id: log.problem.id,
@@ -466,12 +437,6 @@ export class StudyLogsService {
if (!problem) throw new NotFoundException("problem");
}
if (input.psProblemId) {
const psProblem = await db.psProblem.findUnique({
where: { id: input.psProblemId },
});
if (!psProblem) throw new NotFoundException("psProblem");
}
}
private async createInTransaction(
@@ -492,7 +457,6 @@ export class StudyLogsService {
subjectId: input.subjectId,
tagId: input.tagId ?? null,
problemId: input.problemId ?? null,
psProblemId: input.psProblemId ?? null,
title: input.title,
difficulty: clamp01(input.difficulty),
baseCorrectRate: input.baseCorrectRate ?? null,

View File

@@ -16,7 +16,6 @@ import { theme, PERSONA_ORDER } from '@/styles/theme';
type Step = 1 | 2 | 3 | 4;
const STEP_COUNT = 4;
const BOJ_HANDLE_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
const DEFAULT_REVIEW_INTENSITY: ReviewIntensity = 'moderate';
const GOAL_TYPE_OPTIONS: Array<{
value: GoalType;
@@ -28,11 +27,6 @@ const GOAL_TYPE_OPTIONS: Array<{
label: '수학 수능',
desc: '수능 수학 단원별 복습 + 4단원 전략',
},
{
value: 'ps',
label: 'PS (Solved.ac)',
desc: '백준 문제 풀이 이력을 자동으로 복습 큐에 편입',
},
{
value: 'certificate',
label: '자격증 시험',
@@ -76,16 +70,8 @@ function OnboardingBody() {
const [currentGrade, setCurrentGrade] = useState(4);
const [targetGrade, setTargetGrade] = useState(2);
const [focusUnits, setFocusUnits] = useState<MathUnit[]>(['common']);
const [bojHandle, setBojHandle] = useState('');
const [syncAfterSave, setSyncAfterSave] = useState(false);
const [persona, setPersona] = useState<Persona>('mid');
useEffect(() => {
if (goalType !== 'ps' && syncAfterSave) {
setSyncAfterSave(false);
}
}, [goalType, syncAfterSave]);
useEffect(() => {
let cancelled = false;
@@ -95,15 +81,15 @@ function OnboardingBody() {
if (cancelled) return;
const me = response.data;
setNickname(me.nickname);
const rawGoal = me.goalType as string | null | undefined;
const nextGoal: GoalType =
me.goalType ??
(me.focusUnits && me.focusUnits.length > 0
? 'math-suneung'
: me.bojHandle
? 'ps'
rawGoal && rawGoal !== 'ps'
? (rawGoal as GoalType)
: me.focusUnits && me.focusUnits.length > 0
? 'math-suneung'
: me.targetExamYear === null
? 'none'
: 'math-suneung');
: 'math-suneung';
setGoalType(nextGoal);
setTargetExamYear(me.targetExamYear ?? upcomingExamYear);
setCurrentGrade(me.currentGrade ?? 4);
@@ -111,8 +97,6 @@ function OnboardingBody() {
const nextUnits =
me.focusUnits && me.focusUnits.length > 0 ? me.focusUnits : (['common'] as MathUnit[]);
setFocusUnits(nextUnits);
setBojHandle(me.bojHandle ?? '');
setSyncAfterSave(false);
setPersona(me.persona);
})
.catch(() => {
@@ -131,7 +115,7 @@ function OnboardingBody() {
};
}, [upcomingExamYear]);
const skipGradeStep = goalType === 'none' || goalType === 'ps';
const skipGradeStep = goalType === 'none';
const effectiveStepCount = skipGradeStep ? STEP_COUNT - 1 : STEP_COUNT;
const visibleStep = skipGradeStep && step >= 3 ? step - 1 : step;
@@ -166,17 +150,6 @@ function OnboardingBody() {
setErr('최소 1개 이상의 단원을 선택해 주세요.');
return;
}
if (goalType === 'ps') {
const trimmedHandle = bojHandle.trim();
if (trimmedHandle && !BOJ_HANDLE_REGEX.test(trimmedHandle)) {
setErr('BOJ 핸들을 다시 확인해 주세요.');
return;
}
if (!trimmedHandle && syncAfterSave) {
setErr('동기화를 하려면 BOJ 핸들을 입력해야 합니다.');
return;
}
}
}
setStep((current) => Math.min(current + 1, STEP_COUNT) as Step);
@@ -204,7 +177,6 @@ function OnboardingBody() {
setSaving(true);
setErr(null);
const trimmedHandle = bojHandle.trim();
const trimmedNickname = nickname.trim();
const payload: Record<string, unknown> = {
nickname: trimmedNickname,
@@ -213,7 +185,6 @@ function OnboardingBody() {
focusSubjects: goalType === 'none' ? [] : ['수학'],
focusUnits: goalType === 'math-suneung' ? focusUnits : [],
onboarded: true,
bojHandle: goalType === 'ps' ? trimmedHandle || null : null,
};
if (goalType === 'math-suneung') {
@@ -223,11 +194,7 @@ function OnboardingBody() {
}
const requestBody = Object.fromEntries(
Object.entries(payload).filter(([key, value]) => {
if (value === undefined) return false;
if (value === null && key !== 'bojHandle') return false;
return true;
}),
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null),
) as Record<string, unknown>;
try {
@@ -248,10 +215,6 @@ function OnboardingBody() {
if (goalType === 'math-suneung' && focusUnits.length === 0) {
setFocusUnits(['common']);
}
if (goalType === 'ps') {
setBojHandle('');
setSyncAfterSave(false);
}
setStep(4);
return;
}
@@ -344,9 +307,7 @@ function OnboardingBody() {
</FieldHint>
) : (
<FieldHint>
{goalType === 'ps'
? 'PS 트랙은 D-day 없이 Solved.ac 동기화만 설정하면 됩니다.'
: '해당 GoalType 전용 콘텐츠는 준비 중입니다. 기본 루틴으로 먼저 시작할 수 있어요.'}
GoalType . .
</FieldHint>
)}
</Field>
@@ -408,40 +369,9 @@ function OnboardingBody() {
<SelectionMeta>{focusUnits.length} </SelectionMeta>
</>
) : goalType === 'ps' ? (
<>
<StepTitle> .</StepTitle>
<StepDesc>Solved.ac .</StepDesc>
<Field>
<FieldName>BOJ </FieldName>
<TextInput
value={bojHandle}
onChange={(event) => setBojHandle(event.target.value)}
placeholder="예) reloop_dev"
maxLength={20}
/>
<FieldHint>//-/_ 3~20. .</FieldHint>
</Field>
<ToggleRow>
<ToggleControl>
<ToggleCheckbox
type="checkbox"
checked={syncAfterSave}
onChange={(event) => setSyncAfterSave(event.target.checked)}
/>
<ToggleVisual $active={syncAfterSave}>
<span />
</ToggleVisual>
<ToggleLabel> </ToggleLabel>
</ToggleControl>
<ToggleHint> 100 .</ToggleHint>
</ToggleRow>
</>
) : (
<>
<StepTitle> · PS .</StepTitle>
<StepTitle> .</StepTitle>
<StepDesc> GoalType은 . .</StepDesc>
<PlaceholderCard>
<PlaceholderText>
@@ -805,61 +735,6 @@ const SelectionMeta = styled.div`
font-family: ${theme.font.mono};
`;
const ToggleRow = styled.div`
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
`;
const ToggleControl = styled.label`
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
width: fit-content;
`;
const ToggleCheckbox = styled.input`
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
`;
const ToggleVisual = styled.span<{ $active: boolean }>`
width: 42px;
height: 24px;
border-radius: 999px;
background: ${({ $active }) => ($active ? theme.color.brandIndigo : 'rgba(255, 255, 255, 0.15)')};
padding: 3px;
display: inline-flex;
align-items: center;
span {
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff;
transform: translateX(${({ $active }) => ($active ? '18px' : '0')});
transition: transform 0.2s ease;
}
`;
const ToggleLabel = styled.span`
color: ${theme.color.textBright};
font-weight: 600;
font-size: 14px;
`;
const ToggleHint = styled.span`
color: ${theme.color.textSub};
font-size: 12px;
`;
const PlaceholderCard = styled.div`
border-radius: 18px;
border: 1px dashed ${theme.color.borderSoftAlpha};

View File

@@ -14,7 +14,6 @@ import {
import { Icon } from '@/components/ui/Icon';
import { Button, Card, Textarea } from '@/components/ui/primitives';
import {
getBojTierLabel,
getStudyLog,
resolveUploadUrl,
updateStudyLog,
@@ -87,9 +86,6 @@ function StudyLogDetailBody() {
const problemImageUrl = detail ? resolveUploadUrl(problem?.imageUrl ?? problem?.pageImageUrl) : null;
const passageImageUrl = detail ? resolveUploadUrl(passage?.imageUrl) : null;
const shortAnswer = isShortAnswerProblem(problem);
const psProblem = !problem && detail?.psProblem ? detail.psProblem : null;
const psTags = useMemo(() => normalizePsTags(psProblem?.tags ?? null), [psProblem]);
const psTierLabel = psProblem ? getBojTierLabel(psProblem.level) : null;
const saveMemo = async () => {
if (!detail) return;
@@ -133,17 +129,15 @@ function StudyLogDetailBody() {
<HeaderTop>
<div>
<Eyebrow>
{psProblem
? `Solved.ac PS · BOJ ${psProblem.bojId}`
: problem
? `${problem.number}번 · ${problemSet?.year ?? '-'}학년도 ${problemSet?.examType ?? ''} ${problemSet?.subjectName ?? ''}`
: '일반 학습 기록'}
{problem
? `${problem.number}번 · ${problemSet?.year ?? '-'}학년도 ${problemSet?.examType ?? ''} ${problemSet?.subjectName ?? ''}`
: '일반 학습 기록'}
</Eyebrow>
<Title>
{psProblem ? psProblem.titleKo ?? psProblem.title : problem?.title ?? '문제 정보가 없는 학습 기록'}
{problem?.title ?? '문제 정보가 없는 학습 기록'}
</Title>
<MetaLine>
{(psProblem ? detail.subject.name : problemSet?.title ?? detail.subject.name) ?? detail.subject.name}
{(problemSet?.title ?? detail.subject.name) ?? detail.subject.name}
{detail.tag ? ` · ${detail.tag.name}` : ''}
</MetaLine>
</div>
@@ -161,34 +155,8 @@ function StudyLogDetailBody() {
<ContentGrid>
<MainColumn>
<SectionCard>
<SectionTitle>{psProblem ? 'PS 문제' : '문제 보기'}</SectionTitle>
{psProblem ? (
<PsProblemPreview>
<PsBadgeRow>
<PsTypeBadge>PS</PsTypeBadge>
{psTierLabel ? (
<TierBadge $tierColor={getTierColor(psProblem.level)}>{psTierLabel}</TierBadge>
) : null}
</PsBadgeRow>
<PsMeta> BOJ {psProblem.bojId}</PsMeta>
{psTags ? (
<PsTagList>
{psTags.map((tag) => (
<PsTag key={tag.key}>{tag.displayName}</PsTag>
))}
</PsTagList>
) : null}
<PsNotice>
Solved.ac PS . .
</PsNotice>
<Link href={psProblem.bojUrl} target="_blank" rel="noreferrer">
<ExternalLinkButton as="span" $variant="secondary">
<Icon name="arrow-right" size={16} />
</ExternalLinkButton>
</Link>
</PsProblemPreview>
) : (
<SectionTitle> </SectionTitle>
{problem ? (
<>
{passage && (
<PassageBlock
@@ -200,11 +168,13 @@ function StudyLogDetailBody() {
)}
<ProblemStage
imageUrl={problemImageUrl}
alt={`${problemSet?.subjectName ?? detail.subject.name} ${problem?.number ?? ''}`}
alt={`${problemSet?.subjectName ?? detail.subject.name} ${problem.number}`}
/>
</>
) : (
<NoProblemNotice> .</NoProblemNotice>
)}
{!psProblem && (detail.chosenAnswer !== null || problem?.answerNumber !== null) ? (
{(detail.chosenAnswer !== null || problem?.answerNumber !== null) ? (
<ChoiceList>
{shortAnswer ? (
<AnswerSummary>
@@ -617,63 +587,8 @@ const StateCard = styled(Card)`
border-radius: 18px;
`;
const PsProblemPreview = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const PsBadgeRow = styled.div`
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
`;
const PsTypeBadge = styled.span`
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.32);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: ${theme.color.textBright};
`;
const TierBadge = styled.span<{ $tierColor: string }>`
padding: 4px 12px;
border-radius: 999px;
background: ${({ $tierColor }) => `${$tierColor}22`};
border: 1px solid ${({ $tierColor }) => `${$tierColor}7a`};
font-size: 12px;
font-weight: 600;
color: ${theme.color.textBright};
`;
const PsMeta = styled.p`
const NoProblemNotice = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 14px;
`;
const PsTagList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const PsTag = styled.span`
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
font-size: 12px;
color: ${theme.color.textBright};
`;
const PsNotice = styled.p`
margin: 8px 0 0;
padding: 12px;
border-radius: 14px;
border: 1px dashed ${theme.color.borderSoftAlpha};
@@ -682,56 +597,3 @@ const PsNotice = styled.p`
font-size: 13px;
line-height: 1.4;
`;
const ExternalLinkButton = styled(Button)`
align-self: flex-start;
margin-top: 4px;
`;
type NormalizedPsTag = { key: string; displayName: string };
function normalizePsTags(raw: unknown): NormalizedPsTag[] | null {
if (!Array.isArray(raw)) return null;
const tags: NormalizedPsTag[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const key = typeof (entry as { key?: unknown }).key === 'string' ? (entry as { key: string }).key : null;
if (!key) continue;
const asAny = entry as { displayName?: unknown; displayNames?: unknown };
const displayName =
(typeof asAny.displayName === 'string' && asAny.displayName) ||
pickPreferredDisplayName(asAny.displayNames) ||
key;
tags.push({ key, displayName });
}
return tags.length > 0 ? tags : null;
}
function pickPreferredDisplayName(raw: unknown): string | null {
if (!Array.isArray(raw)) return null;
let fallback: string | null = null;
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const name = typeof (entry as { name?: unknown }).name === 'string' ? (entry as { name: string }).name : null;
if (!name) continue;
const language =
typeof (entry as { language?: unknown }).language === 'string'
? (entry as { language: string }).language.toLowerCase()
: null;
if (language === 'ko') {
return name;
}
if (!fallback) {
fallback = name;
}
}
return fallback;
}
const TIER_COLORS = ['#cd7f32', '#b0bec5', '#fbbf24', '#38bdf8', '#60a5fa', '#f472b6'];
function getTierColor(level: number) {
if (!level || level <= 0) return theme.color.border;
const tierIndex = Math.min(TIER_COLORS.length - 1, Math.max(0, Math.floor((level - 1) / 5)));
return TIER_COLORS[tierIndex] ?? theme.color.brandIndigo;
}

View File

@@ -7,7 +7,7 @@ import AppShell from '@/components/layout/AppShell';
import DatePicker from '@/components/ui/DatePicker';
import { Icon } from '@/components/ui/Icon';
import Select from '@/components/ui/Select';
import { api, getBojTierLabel, type StudyLog, type StudyResult, type Subject } from '@/lib/api';
import { api, type StudyLog, type StudyResult, type Subject } from '@/lib/api';
import { theme } from '@/styles/theme';
const PAGE_SIZE = 20;
@@ -309,7 +309,6 @@ function HistoryBody() {
<RowLink href={`/study-logs/${log.id}`}>
<ItemTitleGroup>
<ItemTitle>{log.title}</ItemTitle>
{renderPsBadges(log.psProblem)}
</ItemTitleGroup>
<ItemMeta>
{log.subject?.name ?? '과목 미지정'}
@@ -363,7 +362,6 @@ function HistoryBody() {
<MobileCardTop>
<MobileTitleStack>
<MobileItemTitle>{log.title}</MobileItemTitle>
{renderPsBadges(log.psProblem)}
</MobileTitleStack>
<ResultChip $result={log.result}>
<Icon name={resultIcon(log.result)} size={14} weight="bold" />
@@ -532,18 +530,6 @@ function getVisiblePages(
return [1, 'ellipsis', currentPage - 1, currentPage, currentPage + 1, 'ellipsis', totalPages];
}
function renderPsBadges(psProblem: StudyLog['psProblem'] | null | undefined) {
if (!psProblem) return null;
const tierLabel = getBojTierLabel(psProblem.level);
const tierColor = getTierColor(psProblem.level);
return (
<PsBadgeRow>
<PsBadge>PS</PsBadge>
<PsTierBadge $tierColor={tierColor}>{tierLabel}</PsTierBadge>
</PsBadgeRow>
);
}
function resultLabel(result: StudyResult): string {
if (result === 'correct') return '맞음';
if (result === 'partial') return '부분';
@@ -614,14 +600,6 @@ function formatDuration(seconds: number | null): string {
return `${minutes}m ${remainSeconds}s`;
}
const TIER_COLORS = ['#cd7f32', '#b0bec5', '#fbbf24', '#38bdf8', '#60a5fa', '#f472b6'];
function getTierColor(level: number) {
if (!level || level <= 0) return theme.color.border;
const tierIndex = Math.min(TIER_COLORS.length - 1, Math.max(0, Math.floor((level - 1) / 5)));
return TIER_COLORS[tierIndex] ?? theme.color.brandIndigo;
}
const PageWrap = styled.div`
display: flex;
flex-direction: column;
@@ -1005,34 +983,6 @@ const TagText = styled.span`
font-size: 13px;
`;
const PsBadgeRow = styled.div`
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
`;
const PsBadge = styled.span`
padding: 2px 8px;
border-radius: 999px;
background: rgba(248, 113, 113, 0.16);
border: 1px solid rgba(248, 113, 113, 0.32);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: ${theme.color.textBright};
`;
const PsTierBadge = styled.span<{ $tierColor: string }>`
padding: 2px 8px;
border-radius: 999px;
background: ${({ $tierColor }) => `${$tierColor}22`};
border: 1px solid ${({ $tierColor }) => `${$tierColor}6a`};
font-size: 11px;
font-weight: 600;
color: ${theme.color.textBright};
`;
const DatePrimary = styled.div`
color: ${theme.color.textMain};
font-size: 13px;

View File

@@ -40,7 +40,7 @@ export type StudyResult = 'correct' | 'incorrect' | 'partial';
export type ReviewStatus = 'pending' | 'done' | 'skipped' | 'expired';
export type SubscriptionTier = 'free' | 'pro' | 'school';
export type OrganizationRole = 'admin' | 'teacher' | 'student';
export type GoalType = 'math-suneung' | 'ps' | 'certificate' | 'language' | 'none';
export type GoalType = 'math-suneung' | 'certificate' | 'language' | 'none';
export type MathUnit = 'common' | 'prob_stat' | 'calculus' | 'geometry';
export const MATH_UNIT_LABEL: Record<MathUnit, string> = {
@@ -299,13 +299,6 @@ export interface StudyLog {
timeSpent: number | null;
subject?: { id: number; name: string; color: string };
tag?: { id: number; name: string } | null;
psProblem?: {
bojId: number;
title: string;
titleKo: string | null;
level: number;
tags: unknown;
} | null;
reviewSchedules?: Array<{
id: number;
scheduledAt: string;
@@ -328,14 +321,6 @@ export interface StudyLogDetail {
baseCorrectRate: number | null;
subject: { id: number; name: string; color: string };
tag: { id: number; name: string } | null;
psProblem: {
bojId: number;
title: string;
titleKo: string | null;
level: number;
tags: unknown;
bojUrl: string;
} | null;
problem: {
id: number;
number: number;
@@ -734,14 +719,3 @@ export async function getReviewDay(date: string): Promise<DayResponse> {
return response.data;
}
// ─── BOJ Tier ──────────────────────────────────────────────────────────────
const BOJ_TIER_NAMES = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Ruby'] as const;
const BOJ_TIER_DIVISIONS = ['V', 'IV', 'III', 'II', 'I'] as const;
export function getBojTierLabel(level: number) {
if (!level || level <= 0) return 'Unrated';
const tierIndex = Math.min(BOJ_TIER_NAMES.length - 1, Math.max(0, Math.floor((level - 1) / 5)));
const divisionIndex = Math.max(0, (level - 1) % 5);
return `${BOJ_TIER_NAMES[tierIndex]} ${BOJ_TIER_DIVISIONS[divisionIndex]}`;
}