fix: 전면 결함 스윕 — 4관점 감사 확정 결함 일괄 수정
All checks were successful
CI / backend-test (push) Successful in 1m10s
CI / frontend-build (push) Successful in 40s
CI / backend-dep-scan (push) Successful in 28s

4개 전문 감사관(프론트·백엔드·일관성·UX) 병렬 검증 결과 반영.

버그/깨짐(HIGH·MED):
- HtmlCssPage: 존재하지 않는 /learn/computer → /learn/hardware(깨진 링크, 대시보드로 튕김)
- MentorAnalyticsService: 체크리스트 활동(Progress.checkedAt)이 '마지막 활동'에 누락돼
  체크리스트만 한 학생이 '관심 필요'로 오분류되던 버그 수정(+회귀 테스트)
- 퀴즈 핫스팟 평균 정수절삭 → Math.round로 통일

접근성/UX(MED):
- CodeEditor: 코드 입력창에 aria-label 추가(WCAG 4.1.2, 스크린리더 이름 부여)
- 멘토 제출물 표: tr role=button → 셀 안 button으로(표 의미구조 보존 + 키보드)
- 대시보드 체크박스 토글 실패 시 무피드백 → try/catch + 에러 안내

콘텐츠/문서 드리프트:
- 초급 미니프로젝트 JS 예제 var→let(let/const 가르치며 var 쓰던 자기모순)
- README 강좌 수(71→83+48), BACKUP.md 복원검증 카운트(퀴즈655·문서19·코딩25)
- 브랜드/코스수 주석 정리(수습 플랫폼→학습 플랫폼, 72개→비수치화), 퀴즈 결과 '점'→'%'

테스트 백엔드 51→52, 프론트 30 유지. 운영 배포·스모크 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-20 14:01:05 +09:00
parent bc2807db79
commit 1b559f5da2
14 changed files with 107 additions and 49 deletions

View File

@ -1,4 +1,4 @@
# 미림 앱 (mirim-app) — 어썸데브 수습 학습 플랫폼
# 미림 앱 (mirim-app) — 어썸데브 학습 플랫폼
> **이 저장소는 여러분(수습생)의 것입니다.**
> 미림마이스터고 수습생 4명이 직접 보고, 배우고, 고치는 학습용 웹 앱입니다.
@ -8,7 +8,7 @@
## 무엇을 하는 앱인가?
- 수습 기간(**8주**, 9월~10월) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제·실습가이드)를 한곳에서 봅니다.
- **학습 센터**에서 컴퓨터 기초부터 디자인까지 **강좌 71개**를 순서대로 배우고, 각 코스 끝에서 **퀴즈**로 확인합니다.
- **학습 센터**에서 수습 트랙 **강좌 83개**(컴퓨터 기초~앱 개발)와 수준별 과정(초급·중급·고급·특급) **48개**를 순서대로 배우고, 각 코스 끝에서 **퀴즈**로 확인합니다.
- **코딩 문제 25개**(JavaScript·SQL·Java)를 브라우저에서 직접 풀고 자동 채점받습니다.
- 주차별 **체크리스트**로 오늘 할 일을 확인하고, **과제를 제출**하면 멘토가 **피드백**을 남깁니다.
- 멘토는 학생별 진도·제출물·인적사항을 한 화면에서 관리합니다.

View File

@ -2,6 +2,7 @@ package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.CourseProgress;
import dev.awesomedev.mirim.domain.Progress;
import dev.awesomedev.mirim.domain.QuizResult;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.Submission;
@ -169,8 +170,13 @@ public class MentorAnalyticsService {
last = later(last, cp.getCompletedAt());
}
// 체크리스트 완료
int checklistDone = progressRepository.findByUser(u).size();
// 체크리스트 완료 + 체크한 시각도 "활동"으로 반영한다.
// ( 그러면 최근에 체크리스트만 열심히 학생이 '관심 필요' 잘못 잡힌다.)
List<Progress> progresses = progressRepository.findByUser(u);
for (Progress pr : progresses) {
last = later(last, pr.getCheckedAt());
}
int checklistDone = progresses.size();
// 과제 리뷰 대기(SUBMITTED) 집계 + 활동 시각
for (Submission s : submissionRepository.findByUserOrderBySubmittedAtDesc(u)) {
@ -189,7 +195,8 @@ public class MentorAnalyticsService {
// 어려운 지점 = 평균이 낮은 top 5 (응시/시도가 있는 것만)
List<QuizHotspot> hardestQuizzes = quizAgg.entrySet().stream()
.map(e -> new QuizHotspot(e.getKey(), e.getValue()[0] / e.getValue()[1], e.getValue()[1]))
.map(e -> new QuizHotspot(e.getKey(),
(int) Math.round((double) e.getValue()[0] / e.getValue()[1]), e.getValue()[1]))
.sorted(Comparator.comparingInt(QuizHotspot::avgScorePct))
.limit(5)
.toList();

View File

@ -2,6 +2,7 @@ package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CodingProblem;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.Progress;
import dev.awesomedev.mirim.domain.QuizResult;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.User;
@ -116,6 +117,22 @@ class MentorAnalyticsServiceTest {
assertEquals(0, row.quizAvgPct());
}
@Test
@DisplayName("체크리스트 체크만 한 학생도 그 시각이 '마지막 활동'에 반영된다")
void checklist_counts_as_activity() {
User a = student("", "DEV");
StudentProfile pa = profile(a, "2026");
Progress pr = mock(Progress.class);
when(pr.getCheckedAt()).thenReturn(NOW.minus(Duration.ofDays(2)));
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa));
when(progressRepo.findByUser(a)).thenReturn(List.of(pr));
var row = service.analytics(null).students().get(0);
assertEquals(2L, row.daysSinceActive()); // 체크리스트 체크(2일 ) 활동으로 잡힘
assertEquals(1, row.checklistDone());
}
@Test
@DisplayName("어려운 코딩 문제는 통과율 낮은 순으로 정렬된다")
void coding_hotspots_sorted_by_pass_rate() {

View File

@ -116,7 +116,7 @@ sudo rm -rf ~/gtest
| 대상 | 결과 |
|---|---|
| PostgreSQL | 임시 DB에 복원 → 운영 원본과 **완전 일치** (사용자 5 / 퀴즈 355 / 문서 17 / 체크리스트 46 / 코딩 20) ✅ |
| PostgreSQL | 임시 DB에 복원 → 운영 원본과 **완전 일치** (퀴즈 655 / 문서 19 / 체크리스트 46 / 코딩 25) ✅ |
| Gitea | 백업 속 저장소에서 clone 성공 → HEAD·커밋 20개·파일 269개가 운영과 **완전 일치** ✅ |
## 무엇이 백업되고, 무엇이 안 되나

View File

@ -2,7 +2,7 @@
// /login , /mentor MENTOR .
//
// : courseCatalog.jsx .map
// . 100 .
// . .
import { Suspense } from 'react';
import { Routes, Route, NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
@ -179,7 +179,7 @@ export default function App() {
<Route path="/" element={<DashboardPage />} />
{/* 학습 센터: 허브 + 코스들(카탈로그에서 자동 생성) */}
<Route path="/learn" element={<LearnHubPage />} />
{/* : CourseShell 72
{/* : CourseShell
하단 "완료" 바가 자동으로 붙는다 공통 기능은 감싸는 컴포넌트로. */}
{ALL_COURSES.map((course) => (
<Route
@ -198,7 +198,7 @@ export default function App() {
/>
))}
{/* : :slug " " .
72 퀴즈 라우트를 하나로 처리한다 QuizPage가 useParams() 꺼내 쓴다. */}
수많은 퀴즈 라우트를 하나로 처리한다 QuizPage가 useParams() 꺼내 쓴다. */}
<Route path="/learn/:slug/quiz" element={<QuizPage />} />
<Route path="/setup" element={<SetupGuidePage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />

View File

@ -11,7 +11,7 @@ import { useRef } from 'react';
* 라이브러리를 고를 때는 "이게 해 주는 일" 말고 "내가 진짜 필요한 일" 먼저 적어 보자.
* 나중에 자동완성·문법 하이라이팅이 정말 필요해지면 그때 바꾸면 된다.
*/
export default function CodeEditor({ value, onChange, readOnly = false, minRows = 12 }) {
export default function CodeEditor({ value, onChange, readOnly = false, minRows = 12, ariaLabel = '코드 작성 입력란' }) {
const textareaRef = useRef(null);
// . minRows .
@ -62,6 +62,7 @@ export default function CodeEditor({ value, onChange, readOnly = false, minRows
<textarea
ref={textareaRef}
className="code-editor__area"
aria-label={ariaLabel}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}

View File

@ -1,7 +1,7 @@
// : ""
// ( + ) .
//
// : 72 72 .
// : 100 .
// (App.jsx )
// " " React .
import { useEffect, useState } from 'react';

View File

@ -241,7 +241,7 @@ export default function CodingProblemPage() {
<div className="card">
<h2>코드 작성</h2>
<CodeEditor value={code} onChange={setCode} />
<CodeEditor value={code} onChange={setCode} ariaLabel={`${problem.title} 코드 작성 입력란`} />
<div className="coding-actions">
{(isJs || isSql) && (

View File

@ -123,6 +123,7 @@ export default function DashboardPage() {
const [items, setItems] = useState([]); //
const [checkedIds, setCheckedIds] = useState(new Set()); // id
const [loading, setLoading] = useState(true);
const [toggleError, setToggleError] = useState(''); //
// : week useEffect ( [week]).
// .
@ -151,16 +152,23 @@ export default function DashboardPage() {
async function toggle(itemId) {
// , (checked) .
const res = await client.post(`/progress/${itemId}/toggle`);
setCheckedIds((prev) => {
const next = new Set(prev);
if (res.data.checked) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
// : (··500) "
// " . .
setToggleError('');
try {
const res = await client.post(`/progress/${itemId}/toggle`);
setCheckedIds((prev) => {
const next = new Set(prev);
if (res.data.checked) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
} catch {
setToggleError('저장 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
}
}
const doneCount = items.filter((item) => checkedIds.has(item.id)).length;
@ -233,6 +241,10 @@ export default function DashboardPage() {
))}
</div>
{toggleError && (
<p className="error-text" role="alert" style={{ marginBottom: 10 }}>{toggleError}</p>
)}
{loading ? (
<p className="empty">불러오는 ...</p>
) : items.length === 0 ? (

View File

@ -376,7 +376,7 @@ export default function HtmlCssPage() {
<Section n={5} title="색과 글꼴" sub="#2159C5의 비밀, 그리고 다크 모드의 원리">
<p>
<Link to="/learn/computer">컴퓨터의 구성</Link> 코스의 <strong>"색이 숫자"</strong> 파트를
<Link to="/learn/hardware">컴퓨터의 구성</Link> 코스의 <strong>"색이 숫자"</strong> 파트를
기억하나요? 컴퓨터에게 색은 빨강·초록·파랑의 양을 적은 숫자였죠. CSS에서는 숫자를
<span className="icode"> #RRGGBB</span> 형태의 <strong>hex 코드</strong> 씁니다.
</p>

View File

@ -559,23 +559,22 @@ export default function MentorPage() {
</thead>
<tbody>
{submissions.map((submission) => (
// a11y: , <tr onClick> .
// role=button + tabIndex + Enter/Space .
<tr
key={submission.id}
onClick={() => selectRow(submission)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectRow(submission);
}
}}
role="button"
tabIndex={0}
aria-label={`${submission.studentName}${submission.assignmentTitle} 제출물 열기`}
style={{ cursor: 'pointer' }}
>
<td style={{ fontWeight: 600 }}>{submission.studentName}</td>
// a11y: <tr> <button> .
// ( <tr role="button">, ''
// - . <tr> row ,
// ·Enter·Space· , .)
<tr key={submission.id} className={selectedId === submission.id ? 'is-selected' : undefined}>
<td style={{ fontWeight: 600 }}>
<button
type="button"
className="linklike"
onClick={() => selectRow(submission)}
aria-expanded={selectedId === submission.id}
aria-label={`${submission.studentName}${submission.assignmentTitle} 제출물 열기`}
>
{submission.studentName}
</button>
</td>
<td>{submission.assignmentTitle}</td>
<td className="muted">
{new Date(submission.submittedAt).toLocaleString('ko-KR')}

View File

@ -101,7 +101,7 @@ export default function QuizPage() {
<div className="chip-row" style={{ alignItems: 'center' }}>
<div className="stat">
<span className="stat-num">{result.score} / {result.total}</span>
<span className="stat-label">{percent}</span>
<span className="stat-label">{percent}%</span>
</div>
</div>
</>

View File

@ -72,8 +72,8 @@ const CODE_RANDOM = `// 정답 숫자 뽑기 — 게임의 심장
// Math.floor() (: 72) 0~99
// + 1 1~100
var answer = Math.floor(Math.random() * 100) + 1;
var tries = 0; // 0
let answer = Math.floor(Math.random() * 100) + 1;
let tries = 0; // 0
// answer
// '' .
@ -83,7 +83,7 @@ const CODE_CHECK = `// check() — "도전!" 버튼이 눌릴 때마다 실행
function check() {
// . ''
// Number() .
var guess = Number(document.getElementById('guess').value);
let guess = Number(document.getElementById('guess').value);
//
tries = tries + 1;
@ -105,15 +105,15 @@ const CODE_UI = `<!-- ① 버튼과 함수 잇기: onclick="check()" 한 줄이
<button onclick="check()">도전!</button>
<script>
var answer = Math.floor(Math.random() * 100) + 1;
var tries = 0;
let answer = Math.floor(Math.random() * 100) + 1;
let tries = 0;
function check() {
var guess = Number(document.getElementById('guess').value);
let guess = Number(document.getElementById('guess').value);
tries = tries + 1;
// (id)
var result = document.getElementById('result');
let result = document.getElementById('result');
// innerText = ''
if (guess === answer) {

View File

@ -178,7 +178,7 @@ a {
text-overflow: ellipsis;
}
/* 화면이 좁아지면 로고의 부제("습 플랫폼")부터 접어 메뉴에 폭을 양보한다.
/* 화면이 좁아지면 로고의 부제("습 플랫폼")부터 접어 메뉴에 폭을 양보한다.
학습 포인트: 무엇을 먼저 버릴지 정하는 것도 반응형 설계다 브랜드보다 기능이 먼저다. */
@media (max-width: 1080px) {
.topnav-logo span {
@ -362,6 +362,28 @@ a {
outline: 2px solid var(--primary-soft);
}
/* 안에서 '텍스트처럼 보이는 버튼' 버튼 기본 외형을 없애고 밑줄 호버만.
(클릭 가능한 tr 대신 버튼을 쓰기 위한 스타일 의미구조 보존) */
.linklike {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.linklike:hover {
text-decoration: underline;
}
.linklike:focus-visible {
outline: 2px solid var(--primary-soft);
border-radius: 4px;
}
tr.is-selected {
background: var(--primary-soft);
}
.field {
margin-bottom: 14px;
}