fix: 전면 결함 스윕 — 4관점 감사 확정 결함 일괄 수정
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:
parent
bc2807db79
commit
1b559f5da2
@ -1,4 +1,4 @@
|
||||
# 미림 앱 (mirim-app) — 어썸데브 수습 학습 플랫폼
|
||||
# 미림 앱 (mirim-app) — 어썸데브 학습 플랫폼
|
||||
|
||||
> **이 저장소는 여러분(수습생)의 것입니다.**
|
||||
> 미림마이스터고 수습생 4명이 직접 보고, 배우고, 고치는 학습용 웹 앱입니다.
|
||||
@ -8,7 +8,7 @@
|
||||
## 무엇을 하는 앱인가?
|
||||
|
||||
- 수습 기간(**8주**, 9월~10월) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제·실습가이드)를 한곳에서 봅니다.
|
||||
- **학습 센터**에서 컴퓨터 기초부터 디자인까지 **강좌 71개**를 순서대로 배우고, 각 코스 끝에서 **퀴즈**로 확인합니다.
|
||||
- **학습 센터**에서 수습 트랙 **강좌 83개**(컴퓨터 기초~앱 개발)와 수준별 과정(초급·중급·고급·특급) **48개**를 순서대로 배우고, 각 코스 끝에서 **퀴즈**로 확인합니다.
|
||||
- **코딩 문제 25개**(JavaScript·SQL·Java)를 브라우저에서 직접 풀고 자동 채점받습니다.
|
||||
- 주차별 **체크리스트**로 오늘 할 일을 확인하고, **과제를 제출**하면 멘토가 **피드백**을 남깁니다.
|
||||
- 멘토는 학생별 진도·제출물·인적사항을 한 화면에서 관리합니다.
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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개가 운영과 **완전 일치** ✅ |
|
||||
|
||||
## 무엇이 백업되고, 무엇이 안 되나
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// 이 파일이 하는 일: 모든 코스 페이지를 감싸는 "껍데기" — 코스 본문 아래에
|
||||
// 완료 체크 바(뒤로가기 + 완료 버튼)를 붙여 준다.
|
||||
//
|
||||
// 학습 포인트: 코스가 72개인데 완료 버튼을 72개 파일에 각각 넣으면 유지보수 지옥이 된다.
|
||||
// 학습 포인트: 코스가 100개가 넘는데 완료 버튼을 파일마다 각각 넣으면 유지보수 지옥이 된다.
|
||||
// 라우트에서 코스를 이 컴포넌트로 한 번 감싸면(App.jsx 참고) 모든 코스가 자동으로
|
||||
// 완료 바를 갖는다 — "공통 기능은 감싸는 컴포넌트로"가 React의 대표 패턴이다.
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -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,6 +152,10 @@ export default function DashboardPage() {
|
||||
|
||||
async function toggle(itemId) {
|
||||
// 서버에 토글을 요청하고, 서버가 알려준 최종 상태(checked)로 화면을 맞춘다.
|
||||
// 학습 포인트: 저장이 실패하면(오프라인·세션만료·500) 조용히 넘어가면 학생은 "체크를
|
||||
// 눌렀는데 아무 반응 없음"을 겪는다. 실패를 잡아 사용자에게 알린다.
|
||||
setToggleError('');
|
||||
try {
|
||||
const res = await client.post(`/progress/${itemId}/toggle`);
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@ -161,6 +166,9 @@ export default function DashboardPage() {
|
||||
}
|
||||
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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
// 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)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectRow(submission);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={selectedId === submission.id}
|
||||
aria-label={`${submission.studentName}의 ${submission.assignmentTitle} 제출물 열기`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td style={{ fontWeight: 600 }}>{submission.studentName}</td>
|
||||
{submission.studentName}
|
||||
</button>
|
||||
</td>
|
||||
<td>{submission.assignmentTitle}</td>
|
||||
<td className="muted">
|
||||
{new Date(submission.submittedAt).toLocaleString('ko-KR')}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user