const CONFIDENCE = [ { id: 'sure', label: '확실해요' }, { id: 'unsure', label: '애매해요' }, { id: 'guess', label: '찍었어요' }, ]; const CONFIDENCE_LABEL = (id) => CONFIDENCE.find((c) => c.id === id)?.label ?? '미응답'; const DIFFICULTY_LABEL = { easy: '쉬움', medium: '보통', hard: '어려움' }; const state = { config: {}, questions: [], answers: [], confidences: [], current: 0, record: null, recordKey: null, }; const REQUEST_TIMEOUT = 60000; const request = async (url, options = {}) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); let res; try { res = await fetch(url, { ...options, signal: controller.signal }); } catch (err) { throw new Error( err.name === 'AbortError' ? '응답이 너무 오래 걸려 중단했습니다. 잠시 후 다시 시도해 주세요.' : '서버에 연결하지 못했습니다. 서버가 실행 중인지 확인해 주세요.' ); } finally { clearTimeout(timer); } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `요청에 실패했습니다 (${res.status})`); return data; }; const post = (url, body) => request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}), }); const api = { chapters: () => request('/api/chapters'), questions: (config, amount) => post('/api/questions', { ...config, amount }), saveTest: (payload) => post('/api/tests', payload), tests: () => request('/api/tests'), test: (id) => request(`/api/tests/${id}`), explain: (testId, index) => post(`/api/tests/${testId}/questions/${index}/explain`), twin: (testId, index) => post(`/api/tests/${testId}/questions/${index}/twin`), ask: (testId, index, message) => post(`/api/tests/${testId}/questions/${index}/ask`, { message }), feedback: (testId) => post(`/api/tests/${testId}/feedback`), }; const showScreen = (element) => { document.getElementById('app').replaceChildren(element); window.scrollTo({ top: 0 }); }; const showLoading = (message) => { const box = document.createElement('div'); box.className = 'screen loading'; box.setAttribute('role', 'status'); box.innerHTML = `

`; box.querySelector('p').textContent = message; showScreen(box); }; const showError = (message, onBack) => { const box = document.createElement('div'); box.className = 'screen'; box.innerHTML = `

`; box.querySelector('.error-box').textContent = message; const back = document.createElement('button'); back.className = 'block'; back.style.marginTop = '16px'; back.textContent = '처음으로'; back.onclick = onBack ?? (() => showStartScreen()); box.querySelector('.card').appendChild(back); showScreen(box); }; const createChipGroup = (items, { selected, onSelect } = {}) => { const group = document.createElement('div'); group.className = 'chips'; group.setAttribute('role', 'group'); const buttons = items.map((item) => { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'chip'; chip.textContent = item.label; chip.setAttribute('aria-pressed', String(item.id === selected)); chip.onclick = () => { buttons.forEach((b) => b.setAttribute('aria-pressed', 'false')); chip.setAttribute('aria-pressed', 'true'); onSelect?.(item.id); }; group.appendChild(chip); return chip; }); return group; }; const createTopbar = (rightButtons = []) => { const bar = document.createElement('div'); bar.className = 'topbar'; const brand = document.createElement('button'); brand.type = 'button'; brand.className = 'brand'; brand.textContent = 'CogMind'; brand.onclick = () => showStartScreen(); bar.appendChild(brand); const right = document.createElement('div'); right.className = 'topbar-actions'; rightButtons.forEach((button) => right.appendChild(button)); bar.appendChild(right); return bar; }; const createButton = (label, onClick, className = '') => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; if (className) button.className = className; button.onclick = onClick; return button; }; const formatDate = (iso) => new Date(iso).toLocaleString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); const shuffle = (items) => { const copy = [...items]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy; }; const ROUTES = { start: { url: () => '/', title: 'CogMind - 메타인지 학습 진단' }, quiz: { url: (r) => `/quiz/${r.index + 1}`, title: '문제 풀이 - CogMind' }, test: { url: (r) => `/tests/${r.id}`, title: '학습 진단 결과 - CogMind' }, history: { url: () => '/history', title: '지난 기록 - CogMind' }, }; const parseLocation = () => { const [first, second] = location.pathname.split('/').filter(Boolean); if (first === 'history') return { name: 'history' }; if (first === 'tests' && second) return { name: 'test', id: second }; if (first === 'quiz') return { name: 'quiz', index: Math.max(0, Number(second || 1) - 1) }; return { name: 'start' }; }; let pendingError = null; const navigate = (route, { replace = false } = {}) => { history[replace ? 'replaceState' : 'pushState'](route, '', ROUTES[route.name].url(route)); renderRoute(route); }; const renderRoute = async (route) => { document.title = ROUTES[route.name]?.title ?? ROUTES.start.title; if (route.name === 'history') { showScreen(document.createElement('history-page')); return; } if (route.name === 'test') { if (state.record?.id === route.id) { showReport(state.record); return; } showLoading('기록을 불러오는 중이에요'); try { showReport(await api.test(route.id)); } catch (err) { showError(err.message, () => navigate({ name: 'history' }, { replace: true })); } return; } if (route.name === 'quiz') { if (!state.questions.length) { navigate({ name: 'start' }, { replace: true }); return; } state.current = Math.min(route.index, state.questions.length - 1); showScreen(document.createElement('test-page')); return; } const page = document.createElement('option-page'); if (pendingError) { page.dataset.error = pendingError; pendingError = null; } showScreen(page); }; window.addEventListener('popstate', (event) => renderRoute(event.state ?? parseLocation())); const showStartScreen = (errorMessage) => { pendingError = errorMessage ?? null; navigate({ name: 'start' }); }; const showHistoryScreen = () => navigate({ name: 'history' }); const showHistoryDetail = (id) => navigate({ name: 'test', id }); const goToQuestion = (index, options) => navigate({ name: 'quiz', index }, options); const startQuiz = async (config, amount) => { showLoading('AI가 문제를 만들고 있어요'); try { const { results } = await api.questions(config, amount); state.config = config; state.questions = results.map((q) => ({ ...q, options: shuffle([q.correct_answer, ...q.incorrect_answers]), })); state.answers = []; state.confidences = []; state.record = null; state.recordKey = null; goToQuestion(0); } catch (err) { showStartScreen(err.message); } }; const finishQuiz = async () => { const answers = state.questions.map((q, i) => ({ question: q.question, correct_answer: q.correct_answer, incorrect_answers: q.incorrect_answers, user_answer: state.answers[i] ?? null, confidence: state.confidences[i] ?? null, })); const key = JSON.stringify(answers.map((a) => [a.user_answer, a.confidence])); if (state.record && state.recordKey === key) { navigate({ name: 'test', id: state.record.id }); return; } showLoading('결과를 정리하고 있어요'); try { state.record = await api.saveTest({ ...state.config, questions: answers }); state.recordKey = key; navigate({ name: 'test', id: state.record.id }); } catch (err) { showError(err.message, () => goToQuestion(state.questions.length - 1, { replace: true })); } }; const showReport = (record) => { const screen = document.createElement('div'); screen.className = 'screen'; screen.appendChild( createTopbar([ createButton('지난 기록', () => showHistoryScreen()), createButton('새 시험', () => showStartScreen()), ]) ); screen.appendChild(renderReport(record)); showScreen(screen); }; window.addEventListener('DOMContentLoaded', () => renderRoute(history.state ?? parseLocation()) );