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:
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user