From 11e8b1fc1aa8097ca107cf7d57d335fb8cbcaf1f Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:03:35 +0900 Subject: [PATCH 01/14] =?UTF-8?q?docs(phase-3):=20Preact=20island=EC=99=80?= =?UTF-8?q?=20=EC=A3=BC=EA=B4=80=EC=8B=9D=20=EC=A0=95=EA=B7=9C=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20PLAN=EC=97=90=20=EA=B3=A0=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .tasks/phase-3-wiki-quiz/LOG.md | 10 ++++++++++ .tasks/phase-3-wiki-quiz/PLAN.md | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .tasks/phase-3-wiki-quiz/LOG.md diff --git a/.tasks/phase-3-wiki-quiz/LOG.md b/.tasks/phase-3-wiki-quiz/LOG.md new file mode 100644 index 0000000..9527328 --- /dev/null +++ b/.tasks/phase-3-wiki-quiz/LOG.md @@ -0,0 +1,10 @@ +# Phase 3 LOG + +- Harness: cursor +- Started: 2026-09-10 +- Status: PLAN-READY (Preact island · 주관식 NFKC 정규화). base `feat/phase-2-content` (#59 ready, 미머지). + +## Notes + +- 스택: `feat/phase-2-content` ← `feat/phase-3-quiz`. 머지는 사람. #56 → #58 → #59 먼저. +- Quiz island는 Home three와 별개. `client:visible` Preact. JS 꺼져도 noscript 목록. diff --git a/.tasks/phase-3-wiki-quiz/PLAN.md b/.tasks/phase-3-wiki-quiz/PLAN.md index ba47f83..e7304fb 100644 --- a/.tasks/phase-3-wiki-quiz/PLAN.md +++ b/.tasks/phase-3-wiki-quiz/PLAN.md @@ -21,5 +21,6 @@ ## 승인 요청 -- [ ] island = Preact 동의? (React 유지 희망 시 코멘트) -- [ ] 주관식 정규화 수준 동의? +- [x] island = Preact 동의? — 2026-09-10 스택 진행. Phase 1.5에서 `@astrojs/preact` 이미 설치. +- [x] 주관식 정규화 수준 동의? — PLAN 기본안 (NFKC + trim + lower + 공백/마침표 제거). + From 5c54601552becb1510b648a913a211f99f832ff7 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:07:37 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(phase-3):=20TIL=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=ED=80=B4=EC=A6=88=20=EC=8A=A4=ED=82=A4=EB=A7=88=EC=99=80=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=2020=EB=AC=B8=ED=95=AD=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zod 스키마·4덱×5문항과 til quizRefs 링크를 연결한다. --- src/content/quiz.ts | 39 ++++++++++ src/content/quiz/design-system.ts | 70 +++++++++++++++++ src/content/quiz/refactoring.ts | 65 ++++++++++++++++ src/content/quiz/tdd.ts | 73 ++++++++++++++++++ src/content/quiz/ts-basics.ts | 75 +++++++++++++++++++ src/content/til/refactoring-javascript/01.mdx | 2 + .../til/tdd/fullstack-tdd-by-newbie.mdx | 2 + .../til/ts/enum-to-template-literal.mdx | 2 + src/lib/quiz/decks.ts | 28 +++++++ 9 files changed, 356 insertions(+) create mode 100644 src/content/quiz.ts create mode 100644 src/content/quiz/design-system.ts create mode 100644 src/content/quiz/refactoring.ts create mode 100644 src/content/quiz/tdd.ts create mode 100644 src/content/quiz/ts-basics.ts create mode 100644 src/lib/quiz/decks.ts diff --git a/src/content/quiz.ts b/src/content/quiz.ts new file mode 100644 index 0000000..ec6841a --- /dev/null +++ b/src/content/quiz.ts @@ -0,0 +1,39 @@ +import { z } from "astro/zod"; + +export const quizDeckIds = ["ts-basics", "tdd", "refactoring", "design-system"] as const; + +export type QuizDeckId = (typeof quizDeckIds)[number]; + +const quizDeckSchema = z.enum(quizDeckIds); + +const quizBaseSchema = z.object({ + id: z.string(), + deck: quizDeckSchema, + prompt: z.string(), + explanation: z.string(), + /** Published content path, e.g. /til/ts/enum-to-template-literal */ + source: z.string(), +}); + +export const mcqQuizItemSchema = quizBaseSchema.extend({ + type: z.literal("mcq"), + choices: z.tuple([z.string(), z.string(), z.string(), z.string()]), + answerIndex: z.number().int().min(0).max(3), +}); + +export const shortQuizItemSchema = quizBaseSchema.extend({ + type: z.literal("short"), + answer: z.string(), +}); + +export const quizItemSchema = z.discriminatedUnion("type", [ + mcqQuizItemSchema, + shortQuizItemSchema, +]); + +export type McqQuizItem = z.infer; +export type ShortQuizItem = z.infer; +export type QuizItem = z.infer; + +export const parseQuizItems = (items: unknown[]): QuizItem[] => + items.map((item) => quizItemSchema.parse(item)); diff --git a/src/content/quiz/design-system.ts b/src/content/quiz/design-system.ts new file mode 100644 index 0000000..309facb --- /dev/null +++ b/src/content/quiz/design-system.ts @@ -0,0 +1,70 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "design-system" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "design-system-01", + deck: "design-system", + type: "mcq", + prompt: "GetIt-UI monorepo에서 Atomic Design으로 구성한 패키지 깊이는?", + choices: [ + "atoms, molecules, organisms 정도", + "atoms만", + "templates까지 전부", + "pages 레벨까지", + ], + answerIndex: 0, + explanation: "컴포넌트 라이브러리이므로 atoms·molecules·organisms 정도까지만 구성한다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-02", + deck: "design-system", + type: "short", + prompt: "Storybook으로 컴포넌트 결과를 별도 페이지 없이 확인할 때 강조한 개발 경험(영문)?", + answer: "HMR", + explanation: "별도 페이지 구성 없이 HMR로 컴포넌트 구현 결과를 바로 볼 수 있다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-03", + deck: "design-system", + type: "mcq", + prompt: "스타일링에 CSS Module을 선호한 이유로 맞는 것은?", + choices: [ + "CSS 그대로 쓰면서 className을 JS로 제어할 수 있다", + "styled-components보다 타입 추론이 강하다", + "Storybook과만 호환된다", + "IE에서 grid를 쓸 수 없어서", + ], + answerIndex: 0, + explanation: "CSS-in-JS 대신 CSS Module로 className 적용을 컨트롤하는 방식을 선호했다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-04", + deck: "design-system", + type: "short", + prompt: "monorepo workspace에 쓴 Yarn berry 기능 이름(영문 두 단어)?", + answer: "PnP", + explanation: "Yarn berry PnP 모드에서 workspace 플러그인으로 monorepo를 구성했다.", + source: "/articles/design-system/01", + }, + { + id: "design-system-05", + deck: "design-system", + type: "mcq", + prompt: "Vite 라이브러리 모드에서 CSS Module 스타일이 다른 패키지에 안 먹힐 때 쓴 해결책은?", + choices: [ + "vite-plugin-css-injected-by-js", + "styled-components 테마 provider", + "Webpack 5로 전환", + "CSS layers만 켜기", + ], + answerIndex: 0, + explanation: + "라이브러리 빌드 시 CSS import가 빠지는 문제를 css-injected-by-js 플러그인으로 해결했다.", + source: "/articles/design-system/01", + }, +]); diff --git a/src/content/quiz/refactoring.ts b/src/content/quiz/refactoring.ts new file mode 100644 index 0000000..843651d --- /dev/null +++ b/src/content/quiz/refactoring.ts @@ -0,0 +1,65 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "refactoring" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "refactoring-01", + deck: "refactoring", + type: "mcq", + prompt: "리팩토링의 핵심 정의로 맞는 것은?", + choices: [ + "동작을 유지하면서 코드 품질을 향상하는 것", + "새 기능을 추가하면서 테스트를 작성하는 것", + "인터페이스(동작)를 변경해 API를 개선하는 것", + "성능만 최적화하고 동작은 바꿀 수 있는 것", + ], + answerIndex: 0, + explanation: "리팩토링의 요점은 행동을 유지하면서 품질을 향상하는 것이다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-02", + deck: "refactoring", + type: "short", + prompt: "테스트 없이 리팩토링할 수 없다는 문장에서 '없다' 앞에 오는 조건(한글)?", + answer: "거치지않은", + explanation: "「테스트를 거치지 않은 코드는 리팩토링할 수 없다」는 원칙을 강조한다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-03", + deck: "refactoring", + type: "mcq", + prompt: "다음 중 리팩토링이 아닌 것은?", + choices: [ + "새로운 코드와 기능을 만드는 것", + "함수와 모듈을 추출해 인터페이스를 단순화하는 것", + "변수·함수 이름을 개선하는 것", + "구현 세부 사항만 바꾸는 것", + ], + answerIndex: 0, + explanation: + "새 코드·기능 추가는 리팩토링이 아니며, 인터페이스 변경은 테스트를 깨뜨릴 수 있다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-04", + deck: "refactoring", + type: "short", + prompt: "number * 2 대신 number << 1로 바꿀 때 주의해야 하는 것(한글 두 단어)?", + answer: "구현세부사항", + explanation: "곱셈과 비트 시프트는 구현 세부 사항이며, 큰 수에서는 동작이 달라질 수 있다.", + source: "/til/refactoring-javascript/01", + }, + { + id: "refactoring-05", + deck: "refactoring", + type: "mcq", + prompt: "코드 품질 원칙 예시로 글에 나온 것은?", + choices: ["SOLID", "CAP", "ACID", "CRUD"], + answerIndex: 0, + explanation: "SOLID, DRY, KISS, GRASP, YAGNI 등 품질 원칙을 나열한다.", + source: "/til/refactoring-javascript/01", + }, +]); diff --git a/src/content/quiz/tdd.ts b/src/content/quiz/tdd.ts new file mode 100644 index 0000000..9abeb8c --- /dev/null +++ b/src/content/quiz/tdd.ts @@ -0,0 +1,73 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "tdd" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "tdd-01", + deck: "tdd", + type: "mcq", + prompt: "글에서 정리한 테스트 구조의 given/when/then은 무엇을 검증하는가?", + choices: [ + "주어진 상황에서 성공·실패 조건과 그 결과", + "Mock 객체의 생성 순서", + "E2E 시나리오의 UI 픽셀 단위 일치", + "커버리지 100% 달성 여부", + ], + answerIndex: 0, + explanation: + "모든 테스트는 given 컨텍스트에서 when(성공/실패 조건)과 then(결과)을 검증한다고 정리했다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-02", + deck: "tdd", + type: "short", + prompt: "단위 테스트에서 외부 의존을 Mock으로 대체하는 이유를 한 단어로?", + answer: "가정", + explanation: + "단위 테스트는 함수/클래스 내부 동작만 보므로 외부는 정상 동작한다고 가정하고 Mock으로 분리한다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-03", + deck: "tdd", + type: "mcq", + prompt: "파일럿 프로젝트에서 Frontend TDD가 실패한 주요 원인은?", + choices: [ + "단위 테스트 범위를 넘는 e2e 성격의 검증을 하려 했다", + "Jest 대신 Vitest를 쓰지 않았다", + "Vue.js에서 TDD가 원천적으로 불가능하다", + "MongoDB와 TypeORM 조합 때문이다", + ], + answerIndex: 0, + explanation: + "컴ponent 단위 테스트에서 이벤트·API 등 e2e에서 해야 할 것을 단위 테스트에서 하려다 혼란이 컸다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-04", + deck: "tdd", + type: "short", + prompt: "통합·E2E 테스트가 단위 테스트와 달리 검증하는 범위를 한글 두 글자로?", + answer: "기능", + explanation: "E2E/통합 테스트는 내부 구현보다 end-to-end 기능과 input→output을 검증한다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, + { + id: "tdd-05", + deck: "tdd", + type: "mcq", + prompt: "Nest.js 전환 후 TDD에 다시 관심을 갖게 된 계기는?", + choices: [ + "수작업 회귀 확인이 반복되어 귀찮아졌기 때문", + "Vue에서 React로 바꿨기 때문", + "테스트 없는 리팩토링이 더 빠르다는 걸 깨달았기 때문", + "Jest 공식 문서가 부족했기 때문", + ], + answerIndex: 0, + explanation: + "모노레포·Nest 전환 후 변경 검증을 손으로 반복하다 보니 TDD로 자동화하고 싶어졌다.", + source: "/til/tdd/fullstack-tdd-by-newbie", + }, +]); diff --git a/src/content/quiz/ts-basics.ts b/src/content/quiz/ts-basics.ts new file mode 100644 index 0000000..75c5bae --- /dev/null +++ b/src/content/quiz/ts-basics.ts @@ -0,0 +1,75 @@ +import { parseQuizItems, type QuizItem } from "../quiz.ts"; + +export const deckId = "ts-basics" as const; + +export const items: QuizItem[] = parseQuizItems([ + { + id: "ts-basics-01", + deck: "ts-basics", + type: "mcq", + prompt: "TypeScript enum을 피하는 대표적인 이유는?", + choices: [ + "tree-shaking이 어렵고 메모리 낭비가 생길 수 있다", + "런타임에 enum 키를 동적으로 추가할 수 없다", + "union 타입과 완전히 동일한 타입 추론을 제공한다", + "class-validator에서 IsEnum과 함께 쓸 수 없다", + ], + answerIndex: 0, + explanation: + "enum은 transpile 후에도 객체로 남아 tree-shaking이 어렵고, 불필요한 메모리 사용으로 이어질 수 있다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-02", + deck: "ts-basics", + type: "short", + prompt: + "class-validator IsEnum에 넘기기 위해 enum 대신 쓰는 Readonly 객체 패턴의 핵심 타입 이름은?", + answer: "ReadonlyRecord", + explanation: + "literal union 타입과 ReadonlyRecord로 key/value가 고정된 객체를 만들면 IsEnum에 넘길 수 있고 tree-shaking에도 유리하다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-03", + deck: "ts-basics", + type: "mcq", + prompt: + "서로 다른 두 enum 타입 PeriodA | PeriodCommon을 union하면 실무에서 어떤 문제가 생기는가?", + choices: [ + "타입 에러 없이 선언되지만 실질적으로 사용할 수 없는 union이 된다", + "자동으로 하나의 enum 객체로 merge된다", + "intersection과 동일하게 never가 된다", + "런타임에 PeriodA 값만 허용된다", + ], + answerIndex: 0, + explanation: + "enum끼리 union은 문법상 허용되지만 PeriodA | PeriodCommon처럼 실사용 불가능한 타입이 된다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-04", + deck: "ts-basics", + type: "short", + prompt: "PeriodA와 PeriodCommon을 하나의 객체로 합칠 때 spread로 만든 상수 이름은?", + answer: "Periods", + explanation: "각 literal 타입별 ReadonlyRecord 객체를 spread해 Periods 하나로 합친다.", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "ts-basics-05", + deck: "ts-basics", + type: "mcq", + prompt: "template literal 타입을 literal union과 함께 쓰면 얻는 이점은?", + choices: [ + "MarketNames와 CategoryNames를 조합한 새 타입을 선언하기 쉽다", + "enum union 문제를 자동으로 해결한다", + "런타임 validation 없이 API 스키마를 생성한다", + "class-validator 의존성을 제거한다", + ], + answerIndex: 0, + explanation: + "`${MarketNames}-${CategoryNames}`처럼 문자열 조합 타입을 선언·자동완성하기 편해진다.", + source: "/til/ts/enum-to-template-literal", + }, +]); diff --git a/src/content/til/refactoring-javascript/01.mdx b/src/content/til/refactoring-javascript/01.mdx index 12db315..5c57d18 100644 --- a/src/content/til/refactoring-javascript/01.mdx +++ b/src/content/til/refactoring-javascript/01.mdx @@ -4,6 +4,8 @@ summary: "리팩토링의 정의와 품질 균형" pubDate: 2023-06-01 tags: ["refactoring", "javascript", "study"] visibility: public +quizRefs: + - /wiki/quiz?deck=refactoring --- > 리팩토링은 코드를 변경하는 것이 아니다 diff --git a/src/content/til/tdd/fullstack-tdd-by-newbie.mdx b/src/content/til/tdd/fullstack-tdd-by-newbie.mdx index e1d24d3..294a406 100644 --- a/src/content/til/tdd/fullstack-tdd-by-newbie.mdx +++ b/src/content/til/tdd/fullstack-tdd-by-newbie.mdx @@ -4,6 +4,8 @@ summary: "주간회고 — Full-stack TDD 경험 회고" pubDate: 2022-01-24 tags: ["tdd", "testing", "career"] visibility: public +quizRefs: + - /wiki/quiz?deck=tdd --- - 주간회고 02. TDD 뉴비의 Full-stack(?) TDD 1편 diff --git a/src/content/til/ts/enum-to-template-literal.mdx b/src/content/til/ts/enum-to-template-literal.mdx index 1c8df5d..4102666 100644 --- a/src/content/til/ts/enum-to-template-literal.mdx +++ b/src/content/til/ts/enum-to-template-literal.mdx @@ -4,6 +4,8 @@ summary: "enum 대신 literal + ReadonlyRecord 패턴 정리" pubDate: 2022-01-20 tags: ["typescript", "enum", "class-validator"] visibility: public +quizRefs: + - /wiki/quiz?deck=ts-basics --- ### 3줄 요약 diff --git a/src/lib/quiz/decks.ts b/src/lib/quiz/decks.ts new file mode 100644 index 0000000..fd67ab7 --- /dev/null +++ b/src/lib/quiz/decks.ts @@ -0,0 +1,28 @@ +import { items as designSystemItems } from "../../content/quiz/design-system.ts"; +import { items as refactoringItems } from "../../content/quiz/refactoring.ts"; +import { items as tddItems } from "../../content/quiz/tdd.ts"; +import { items as tsBasicsItems } from "../../content/quiz/ts-basics.ts"; +import { quizDeckIds, type QuizDeckId, type QuizItem } from "../../content/quiz.ts"; + +export { quizDeckIds, type QuizDeckId, type QuizItem }; + +const deckCatalog: Record = { + "ts-basics": tsBasicsItems, + tdd: tddItems, + refactoring: refactoringItems, + "design-system": designSystemItems, +}; + +export const deckLabels: Record = { + "ts-basics": "TypeScript 기초", + tdd: "TDD", + refactoring: "리팩토링", + "design-system": "Design System", +}; + +export const getDeckItems = (deckId: QuizDeckId): QuizItem[] => deckCatalog[deckId]; + +export const getAllQuizItems = (): QuizItem[] => quizDeckIds.flatMap((id) => deckCatalog[id]); + +export const isQuizDeckId = (value: string): value is QuizDeckId => + (quizDeckIds as readonly string[]).includes(value); From c000388252ffd35fb04fee36f79bcb0ce7e7ab9a Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:07:37 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(phase-3):=20Fisher-Yates=20=EC=B6=9C?= =?UTF-8?q?=EC=A0=9C=20=EC=97=94=EC=A7=84=EA=B3=BC=20=EC=B1=84=EC=A0=90=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shuffle·grade·session·localStorage progress와 vitest를 포함한다. --- src/lib/quiz/grade.test.ts | 30 ++++++++++++ src/lib/quiz/grade.ts | 22 +++++++++ src/lib/quiz/progress.ts | 43 +++++++++++++++++ src/lib/quiz/session.test.ts | 90 ++++++++++++++++++++++++++++++++++++ src/lib/quiz/session.ts | 59 +++++++++++++++++++++++ src/lib/quiz/shuffle.test.ts | 37 +++++++++++++++ src/lib/quiz/shuffle.ts | 23 +++++++++ 7 files changed, 304 insertions(+) create mode 100644 src/lib/quiz/grade.test.ts create mode 100644 src/lib/quiz/grade.ts create mode 100644 src/lib/quiz/progress.ts create mode 100644 src/lib/quiz/session.test.ts create mode 100644 src/lib/quiz/session.ts create mode 100644 src/lib/quiz/shuffle.test.ts create mode 100644 src/lib/quiz/shuffle.ts diff --git a/src/lib/quiz/grade.test.ts b/src/lib/quiz/grade.test.ts new file mode 100644 index 0000000..4f4bdd2 --- /dev/null +++ b/src/lib/quiz/grade.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { gradeMCQ, gradeShort, normalizeShortAnswer } from "./grade.ts"; + +describe("normalizeShortAnswer", () => { + it("NFKC·trim·소문자·공백/마침표를 제거한다", () => { + expect(normalizeShortAnswer(" Readonly Record. ")).toBe("readonlyrecord"); + expect(normalizeShortAnswer("ABC")).toBe("abc"); + }); +}); + +describe("gradeMCQ", () => { + it("선택 인덱스가 정답과 같으면 true", () => { + expect(gradeMCQ({ pickedIndex: 2, answerIndex: 2 })).toBe(true); + }); + + it("선택 인덱스가 다르면 false", () => { + expect(gradeMCQ({ pickedIndex: 0, answerIndex: 1 })).toBe(false); + }); +}); + +describe("gradeShort", () => { + it("정규화 후 일치하면 true", () => { + expect(gradeShort({ given: " ReadonlyRecord ", answer: "readonly record" })).toBe(true); + }); + + it("정규화 후 불일치하면 false", () => { + expect(gradeShort({ given: "enum", answer: "ReadonlyRecord" })).toBe(false); + }); +}); diff --git a/src/lib/quiz/grade.ts b/src/lib/quiz/grade.ts new file mode 100644 index 0000000..3d71b44 --- /dev/null +++ b/src/lib/quiz/grade.ts @@ -0,0 +1,22 @@ +export const normalizeShortAnswer = (value: string): string => + value + .normalize("NFKC") + .trim() + .toLowerCase() + .replace(/[\s.]+/g, ""); + +type GradeMcqArgs = { + pickedIndex: number; + answerIndex: number; +}; + +export const gradeMCQ = ({ pickedIndex, answerIndex }: GradeMcqArgs): boolean => + pickedIndex === answerIndex; + +type GradeShortArgs = { + given: string; + answer: string; +}; + +export const gradeShort = ({ given, answer }: GradeShortArgs): boolean => + normalizeShortAnswer(given) === normalizeShortAnswer(answer); diff --git a/src/lib/quiz/progress.ts b/src/lib/quiz/progress.ts new file mode 100644 index 0000000..0b4e6a9 --- /dev/null +++ b/src/lib/quiz/progress.ts @@ -0,0 +1,43 @@ +import type { QuizDeckId } from "../../content/quiz.ts"; + +export const QUIZ_PROGRESS_KEY = "quiz-progress-v1"; + +export type DeckProgress = { + seen: number; + correct: number; +}; + +export type QuizProgressStore = Partial>; + +export const readQuizProgress = (): QuizProgressStore => { + if (typeof localStorage === "undefined") return {}; + + try { + const raw = localStorage.getItem(QUIZ_PROGRESS_KEY); + if (!raw) return {}; + return JSON.parse(raw) as QuizProgressStore; + } catch { + return {}; + } +}; + +export const writeQuizProgress = (store: QuizProgressStore): void => { + if (typeof localStorage === "undefined") return; + localStorage.setItem(QUIZ_PROGRESS_KEY, JSON.stringify(store)); +}; + +type RecordAnswerArgs = { + deckId: QuizDeckId; + correct: boolean; +}; + +export const recordDeckAnswer = ({ deckId, correct }: RecordAnswerArgs): DeckProgress => { + const store = readQuizProgress(); + const current = store[deckId] ?? { seen: 0, correct: 0 }; + const next: DeckProgress = { + seen: current.seen + 1, + correct: current.correct + (correct ? 1 : 0), + }; + writeQuizProgress({ ...store, [deckId]: next }); + return next; +}; diff --git a/src/lib/quiz/session.test.ts b/src/lib/quiz/session.test.ts new file mode 100644 index 0000000..9452d7b --- /dev/null +++ b/src/lib/quiz/session.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import type { QuizItem } from "../../content/quiz.ts"; + +import { createSeededRng } from "./shuffle.ts"; +import { buildSession, isSessionMcq } from "./session.ts"; + +const sampleItems: QuizItem[] = [ + { + id: "m1", + deck: "ts-basics", + type: "mcq", + prompt: "Q1", + choices: ["a", "b", "c", "d"], + answerIndex: 1, + explanation: "e1", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "m2", + deck: "ts-basics", + type: "mcq", + prompt: "Q2", + choices: ["a", "b", "c", "d"], + answerIndex: 0, + explanation: "e2", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "s1", + deck: "ts-basics", + type: "short", + prompt: "Q3", + answer: "foo", + explanation: "e3", + source: "/til/ts/enum-to-template-literal", + }, + { + id: "s2", + deck: "ts-basics", + type: "short", + prompt: "Q4", + answer: "bar", + explanation: "e4", + source: "/til/ts/enum-to-template-literal", + }, +]; + +describe("buildSession", () => { + it("limit 이하 개수를 반환한다", () => { + const session = buildSession({ items: sampleItems, limit: 3, rng: createSeededRng(1) }); + expect(session.length).toBeLessThanOrEqual(3); + }); + + it("mcq 항목은 보기 순서가 셔플된다", () => { + const session = buildSession({ + items: sampleItems, + limit: 4, + mcqRatio: 1, + rng: createSeededRng(5), + }); + const mcq = session.filter(isSessionMcq); + expect(mcq.length).toBeGreaterThan(0); + mcq.forEach((item) => { + expect(item.shuffledChoices).toHaveLength(4); + expect(item.shuffledAnswerIndex).toBeGreaterThanOrEqual(0); + expect(item.shuffledAnswerIndex).toBeLessThanOrEqual(3); + expect(item.shuffledChoices[item.shuffledAnswerIndex]).toBe(item.choices[item.answerIndex]); + }); + }); + + it("같은 seed면 동일한 세션 순서를 만든다", () => { + const first = buildSession({ items: sampleItems, limit: 4, rng: createSeededRng(11) }); + const second = buildSession({ items: sampleItems, limit: 4, rng: createSeededRng(11) }); + expect(first.map((item) => item.id)).toEqual(second.map((item) => item.id)); + }); +}); + +describe("isSessionMcq", () => { + it("mcq 세션 항목만 true", () => { + const session = buildSession({ items: sampleItems, limit: 4, rng: createSeededRng(3) }); + session.forEach((item) => { + if (item.type === "mcq") { + expect(isSessionMcq(item)).toBe(true); + } else { + expect(isSessionMcq(item)).toBe(false); + } + }); + }); +}); diff --git a/src/lib/quiz/session.ts b/src/lib/quiz/session.ts new file mode 100644 index 0000000..b2c2a84 --- /dev/null +++ b/src/lib/quiz/session.ts @@ -0,0 +1,59 @@ +import type { McqQuizItem, QuizItem } from "../../content/quiz.ts"; + +import { shuffle, type Rng } from "./shuffle.ts"; + +export type SessionMcqItem = McqQuizItem & { + shuffledChoices: [string, string, string, string]; + shuffledAnswerIndex: number; +}; + +export type SessionItem = QuizItem | SessionMcqItem; + +export const isSessionMcq = (item: SessionItem): item is SessionMcqItem => + item.type === "mcq" && "shuffledChoices" in item; + +type BuildSessionArgs = { + items: QuizItem[]; + mcqRatio?: number; + limit?: number; + rng?: Rng; +}; + +const shuffleMcqChoices = (item: McqQuizItem, rng: Rng): SessionMcqItem => { + const order = shuffle([0, 1, 2, 3], rng); + const shuffledChoices = order.map((index) => item.choices[index]) as [ + string, + string, + string, + string, + ]; + const shuffledAnswerIndex = order.indexOf(item.answerIndex); + + return { + ...item, + shuffledChoices, + shuffledAnswerIndex: shuffledAnswerIndex >= 0 ? shuffledAnswerIndex : 0, + }; +}; + +export const buildSession = ({ + items, + mcqRatio = 0.6, + limit = 10, + rng = Math.random, +}: BuildSessionArgs): SessionItem[] => { + const mcqItems = items.filter((item): item is McqQuizItem => item.type === "mcq"); + const shortItems = items.filter((item) => item.type === "short"); + const mcqCount = Math.min(Math.round(limit * mcqRatio), mcqItems.length); + const shortCount = Math.min(limit - mcqCount, shortItems.length); + const adjustedMcqCount = Math.min(limit - shortCount, mcqCount); + + const selected = [ + ...shuffle(mcqItems, rng).slice(0, adjustedMcqCount), + ...shuffle(shortItems, rng).slice(0, shortCount), + ]; + + return shuffle(selected, rng).map((item) => + item.type === "mcq" ? shuffleMcqChoices(item, rng) : item, + ); +}; diff --git a/src/lib/quiz/shuffle.test.ts b/src/lib/quiz/shuffle.test.ts new file mode 100644 index 0000000..e9f157f --- /dev/null +++ b/src/lib/quiz/shuffle.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { createSeededRng, shuffle } from "./shuffle.ts"; + +describe("shuffle", () => { + it("같은 seed면 동일한 순서를 반환한다", () => { + const source = ["a", "b", "c", "d", "e"]; + const first = shuffle(source, createSeededRng(42)); + const second = shuffle(source, createSeededRng(42)); + expect(first).toEqual(second); + }); + + it("요소 개수와 멀티셋을 유지한다", () => { + const source = [1, 2, 2, 3]; + const result = shuffle(source, createSeededRng(7)); + expect(result).toHaveLength(source.length); + expect(result.sort()).toEqual(source.sort()); + }); + + it("원본 배열을 변경하지 않는다", () => { + const source = ["x", "y", "z"]; + const copy = [...source]; + shuffle(source, createSeededRng(1)); + expect(source).toEqual(copy); + }); +}); + +describe("createSeededRng", () => { + it("0 이상 1 미만 값을 생성한다", () => { + const rng = createSeededRng(99); + Array.from({ length: 20 }, () => { + const value = rng(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + }); + }); +}); diff --git a/src/lib/quiz/shuffle.ts b/src/lib/quiz/shuffle.ts new file mode 100644 index 0000000..005d17b --- /dev/null +++ b/src/lib/quiz/shuffle.ts @@ -0,0 +1,23 @@ +export type Rng = () => number; + +export const createSeededRng = (seed: number): Rng => { + let state = seed >>> 0; + + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +export const shuffle = (items: readonly T[], rng: Rng = Math.random): T[] => { + const result = [...items]; + + for (let i = result.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + + return result; +}; From 0e79fd71b5062ad18715c6008dde923515464a54 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:07:37 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat(phase-3):=20QuizDeck=20island?= =?UTF-8?q?=EC=99=80=20wiki/quiz=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preact flash card UI, 키보드·aria-live·noscript 정적 목록을 제공한다. --- src/components/quiz/QuizDeck.stories.tsx | 39 +++ src/components/quiz/QuizDeck.stylex.ts | 216 +++++++++++++ src/components/quiz/QuizDeck.tsx | 382 +++++++++++++++++++++++ src/pages/wiki/quiz.astro | 97 ++++++ 4 files changed, 734 insertions(+) create mode 100644 src/components/quiz/QuizDeck.stories.tsx create mode 100644 src/components/quiz/QuizDeck.stylex.ts create mode 100644 src/components/quiz/QuizDeck.tsx create mode 100644 src/pages/wiki/quiz.astro diff --git a/src/components/quiz/QuizDeck.stories.tsx b/src/components/quiz/QuizDeck.stories.tsx new file mode 100644 index 0000000..5d4f5e4 --- /dev/null +++ b/src/components/quiz/QuizDeck.stories.tsx @@ -0,0 +1,39 @@ +import { deckLabels } from "../../lib/quiz/decks.ts"; +import { items as tsBasicsItems } from "../../content/quiz/ts-basics.ts"; + +import { QuizDeck } from "./QuizDeck.tsx"; + +export default { + title: "quiz/QuizDeck", + component: QuizDeck, + parameters: { + docs: { + description: { + component: + "Wiki flash card island. 키보드 1–4·Enter·화살표, aria-live 피드백, prefers-reduced-motion 시 페이드.", + }, + }, + }, +}; + +export const TsBasics = { + render: () => ( + + ), +}; + +export const ShortSession = { + render: () => ( + + ), +}; diff --git a/src/components/quiz/QuizDeck.stylex.ts b/src/components/quiz/QuizDeck.stylex.ts new file mode 100644 index 0000000..3a84f5c --- /dev/null +++ b/src/components/quiz/QuizDeck.stylex.ts @@ -0,0 +1,216 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../styles/tokens.stylex.ts"; + +export const quizDeckStyles = stylex.create({ + root: { + display: "flex", + flexDirection: "column", + gap: tokens.space[6], + maxWidth: "42rem", + }, + header: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "space-between", + gap: tokens.space[3], + }, + stats: { + display: "flex", + flexWrap: "wrap", + gap: tokens.space[4], + fontSize: tokens.fontSize.sm, + color: tokens.color.inkSecondary, + fontFamily: tokens.fontFamily.sans, + }, + progressTrack: { + width: "100%", + height: "6px", + borderRadius: tokens.radius.pill, + backgroundColor: tokens.color.surfaceMuted, + overflow: "hidden", + }, + progressFill: { + height: "100%", + backgroundColor: tokens.color.accent, + transitionProperty: "width", + transitionDuration: tokens.duration.normal, + }, + cardShell: { + perspective: "1200px", + minHeight: "18rem", + }, + cardShellReduced: { + perspective: "none", + minHeight: "18rem", + }, + card: { + position: "relative", + width: "100%", + minHeight: "18rem", + transformStyle: "preserve-3d", + transitionProperty: "transform", + transitionDuration: tokens.duration.slow, + transitionTimingFunction: "ease", + }, + cardFlipped: { + transform: "rotateY(180deg)", + }, + cardFade: { + position: "relative", + width: "100%", + minHeight: "18rem", + transitionProperty: "opacity", + transitionDuration: tokens.duration.normal, + }, + cardFace: { + position: "absolute", + inset: 0, + display: "flex", + flexDirection: "column", + gap: tokens.space[4], + padding: tokens.space[6], + borderRadius: tokens.radius.md, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.surface, + backfaceVisibility: "hidden", + }, + cardBack: { + transform: "rotateY(180deg)", + }, + cardFaceStatic: { + display: "flex", + flexDirection: "column", + gap: tokens.space[4], + padding: tokens.space[6], + borderRadius: tokens.radius.md, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.surface, + }, + cardFaceHidden: { + opacity: 0, + pointerEvents: "none", + position: "absolute", + inset: 0, + }, + prompt: { + fontSize: tokens.fontSize.lg, + fontWeight: 600, + lineHeight: tokens.lineHeight.tight, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + margin: 0, + }, + choices: { + display: "flex", + flexDirection: "column", + gap: tokens.space[2], + margin: 0, + padding: 0, + listStyle: "none", + }, + choiceButton: { + display: "flex", + alignItems: "flex-start", + gap: tokens.space[3], + width: "100%", + padding: tokens.space[3], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.canvas, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + textAlign: "left", + cursor: "pointer", + transitionProperty: "border-color, background-color", + transitionDuration: tokens.duration.fast, + }, + choiceSelected: { + borderColor: tokens.color.accent, + backgroundColor: tokens.color.accentSoft, + }, + choiceCorrect: { + borderColor: tokens.color.accentInk, + backgroundColor: tokens.color.accentSoft, + }, + choiceWrong: { + borderColor: "#c62828", + backgroundColor: "#ffebee", + }, + shortInput: { + width: "100%", + padding: tokens.space[3], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.md, + color: tokens.color.ink, + backgroundColor: tokens.color.canvas, + }, + actions: { + display: "flex", + flexWrap: "wrap", + gap: tokens.space[3], + marginTop: "auto", + }, + primaryButton: { + paddingBlock: tokens.space[2], + paddingInline: tokens.space[4], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.accentInk, + backgroundColor: tokens.color.accentInk, + color: tokens.color.canvas, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + fontWeight: 500, + cursor: "pointer", + }, + secondaryButton: { + paddingBlock: tokens.space[2], + paddingInline: tokens.space[4], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: "transparent", + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + cursor: "pointer", + }, + feedback: { + fontSize: tokens.fontSize.sm, + fontFamily: tokens.fontFamily.sans, + color: tokens.color.inkSecondary, + margin: 0, + }, + explanation: { + fontSize: tokens.fontSize.sm, + lineHeight: tokens.lineHeight.body, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + margin: 0, + }, + sourceLink: { + fontSize: tokens.fontSize.sm, + color: tokens.color.accentInk, + fontFamily: tokens.fontFamily.sans, + }, + hint: { + fontSize: tokens.fontSize.sm, + color: tokens.color.inkTertiary, + fontFamily: tokens.fontFamily.mono, + }, +}); diff --git a/src/components/quiz/QuizDeck.tsx b/src/components/quiz/QuizDeck.tsx new file mode 100644 index 0000000..29d7bb1 --- /dev/null +++ b/src/components/quiz/QuizDeck.tsx @@ -0,0 +1,382 @@ +import * as stylex from "@stylexjs/stylex"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; + +import type { QuizDeckId, QuizItem } from "../../content/quiz.ts"; +import { gradeMCQ, gradeShort } from "../../lib/quiz/grade.ts"; +import { readQuizProgress, recordDeckAnswer } from "../../lib/quiz/progress.ts"; +import { buildSession, isSessionMcq, type SessionItem } from "../../lib/quiz/session.ts"; + +import { quizDeckStyles } from "./QuizDeck.stylex.ts"; + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +type HistoryEntry = { + id: string; + correct: boolean; +}; + +type QuizDeckProps = { + deckId: QuizDeckId; + deckLabel: string; + items: QuizItem[]; + sessionLimit?: number; +}; + +const useReducedMotion = () => { + const [reduced, setReduced] = useState(false); + + useEffect(() => { + const media = window.matchMedia(REDUCED_MOTION_QUERY); + const sync = () => setReduced(media.matches); + sync(); + media.addEventListener("change", sync); + return () => media.removeEventListener("change", sync); + }, []); + + return reduced; +}; + +export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDeckProps) { + const reducedMotion = useReducedMotion(); + const inputRef = useRef(null); + const [order, setOrder] = useState(() => + buildSession({ items, limit: sessionLimit }), + ); + const [idx, setIdx] = useState(0); + const [flipped, setFlipped] = useState(false); + const [picked, setPicked] = useState(undefined); + const [shortValue, setShortValue] = useState(""); + const [revealed, setRevealed] = useState(false); + const [score, setScore] = useState(0); + const [, setHistory] = useState([]); + const [liveMessage, setLiveMessage] = useState(""); + const [storedProgress, setStoredProgress] = useState(() => readQuizProgress()[deckId]); + + const current = order[idx]; + const total = order.length; + const finished = idx >= total; + + const resetCard = useCallback(() => { + setFlipped(false); + setPicked(undefined); + setShortValue(""); + setRevealed(false); + }, []); + + const restartSession = useCallback(() => { + setOrder(buildSession({ items, limit: sessionLimit })); + setIdx(0); + setScore(0); + setHistory([]); + resetCard(); + setLiveMessage("새 세션을 시작했습니다."); + }, [items, resetCard, sessionLimit]); + + const goNext = useCallback(() => { + if (idx >= total - 1) { + setIdx(total); + setLiveMessage(`세션 완료. ${score} / ${total} 정답.`); + return; + } + setIdx((value) => value + 1); + resetCard(); + setLiveMessage(`다음 문제 ${idx + 2} / ${total}`); + }, [idx, resetCard, score, total]); + + const goPrev = useCallback(() => { + if (idx <= 0) return; + setIdx((value) => value - 1); + resetCard(); + setLiveMessage(`이전 문제 ${idx} / ${total}`); + }, [idx, resetCard, total]); + + const evaluate = useCallback(() => { + if (!current || revealed) return; + + const correct = + current.type === "mcq" && isSessionMcq(current) + ? gradeMCQ({ + pickedIndex: picked ?? -1, + answerIndex: current.shuffledAnswerIndex, + }) + : current.type === "short" + ? gradeShort({ given: shortValue, answer: current.answer }) + : false; + + if (current.type === "mcq" && picked === undefined) { + setLiveMessage("보기를 선택한 뒤 제출하세요."); + return; + } + + if (current.type === "short" && shortValue.trim() === "") { + setLiveMessage("답을 입력한 뒤 제출하세요."); + return; + } + + setRevealed(true); + setFlipped(true); + setHistory((entries) => [...entries, { id: current.id, correct }]); + if (correct) setScore((value) => value + 1); + + const progress = recordDeckAnswer({ deckId, correct }); + setStoredProgress(progress); + setLiveMessage(correct ? "정답입니다." : "오답입니다."); + }, [current, deckId, picked, revealed, shortValue]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (finished) return; + const target = event.target as HTMLElement | null; + const typing = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA"; + + if (!flipped && event.key === "Enter" && !typing) { + event.preventDefault(); + setFlipped(true); + setLiveMessage("문제를 확인했습니다. 답을 입력하세요."); + return; + } + + if (revealed) { + if (event.key === "ArrowRight") { + event.preventDefault(); + goNext(); + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + goPrev(); + } + return; + } + + if (current?.type === "mcq" && !typing) { + const choice = Number(event.key); + if (choice >= 1 && choice <= 4) { + event.preventDefault(); + setPicked(choice - 1); + setLiveMessage(`${choice}번 보기를 선택했습니다.`); + } + } + + if (event.key === "Enter") { + event.preventDefault(); + evaluate(); + } + + if (!typing && event.key === "ArrowLeft") { + event.preventDefault(); + goPrev(); + } + if (!typing && event.key === "ArrowRight" && revealed) { + event.preventDefault(); + goNext(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [current, evaluate, finished, flipped, goNext, goPrev, revealed]); + + useEffect(() => { + if (flipped && current?.type === "short") { + inputRef.current?.focus(); + } + }, [current?.type, flipped]); + + const progressPercent = useMemo( + () => (total === 0 ? 0 : Math.round(((idx + (revealed ? 1 : 0)) / total) * 100)), + [idx, revealed, total], + ); + + if (finished) { + return ( +
+
+

{deckLabel} 완료

+
+

+ {liveMessage || `${score} / ${total} 정답`} +

+ {storedProgress && ( +

+ 누적 — seen {storedProgress.seen}, correct {storedProgress.correct} +

+ )} +
+ +
+
+ ); + } + + if (!current) return null; + + const mcq = isSessionMcq(current) ? current : undefined; + const choices = mcq?.shuffledChoices ?? []; + + const front = ( +
+

{current.prompt}

+

Enter — 뒤집기 · ←/→ — 이동

+
+ +
+
+ ); + + const back = ( +
+

{current.prompt}

+ + {current.type === "mcq" && mcq && ( +
    + {choices.map((choice, choiceIndex) => { + const selected = picked === choiceIndex; + const showResult = revealed; + const isCorrect = choiceIndex === mcq.shuffledAnswerIndex; + + return ( +
  • + +
  • + ); + })} +
+ )} + + {current.type === "short" && ( + setShortValue((event.target as HTMLInputElement).value)} + /> + )} + + {revealed && ( + <> +

{current.explanation}

+ + 출처 보기 + + + )} + +
+ {!revealed && ( + + )} + {revealed && ( + + )} + +
+
+ ); + + return ( +
+
+

{deckLabel}

+
+ + {idx + 1} / {total} + + 점수 {score} + {storedProgress && ( + + 누적 {storedProgress.correct}/{storedProgress.seen} + + )} +
+
+ +
+
+
+ +

+ {liveMessage} +

+ + {reducedMotion ? ( +
+
+ {front} +
+
+ {back} +
+
+ ) : ( +
+
+ {front} + {back} +
+
+ )} + +

키보드: 1–4 선택 · Enter 제출/뒤집기 · ←/→ 이동

+
+ ); +} + +export default QuizDeck; diff --git a/src/pages/wiki/quiz.astro b/src/pages/wiki/quiz.astro new file mode 100644 index 0000000..6a08a69 --- /dev/null +++ b/src/pages/wiki/quiz.astro @@ -0,0 +1,97 @@ +--- +import QuizDeck from '../../components/quiz/QuizDeck.tsx'; +import Heading from '../../components/ui/Heading.astro'; +import Stack from '../../components/ui/Stack.astro'; +import Text from '../../components/ui/Text.astro'; +import { + deckLabels, + getAllQuizItems, + getDeckItems, + isQuizDeckId, + quizDeckIds, + type QuizDeckId, +} from '../../lib/quiz/decks.ts'; +import Page from '../../layouts/Page.astro'; + +const deckParam = Astro.url.searchParams.get('deck') ?? ''; +const selectedDeck: QuizDeckId | undefined = isQuizDeckId(deckParam) ? deckParam : undefined; +const deckItems = selectedDeck ? getDeckItems(selectedDeck) : []; +const allItems = getAllQuizItems(); +const sessionLimit = Math.min(10, deckItems.length); +--- + + + +
+ Wiki Quiz + + TIL·기사 내용을 flash card로 복습합니다. JavaScript 없이도 아래 noscript 목록에서 문제를 볼 수 + 있습니다. + +
+ + + + { + selectedDeck && deckItems.length > 0 && ( +
+ +
+ ) + } + + +
+
From 5db3320ec1ff100384c026723152e53453c4e3a5 Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:08:36 +0900 Subject: [PATCH 05/14] =?UTF-8?q?docs(phase-3):=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EC=99=80=20quiz=20island=20gzip=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20LOG=EC=97=90=20=EA=B8=B0=EB=A1=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #60 draft·번들 13.6KB·게이트 결과를 남긴다. --- .tasks/phase-3-wiki-quiz/LOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.tasks/phase-3-wiki-quiz/LOG.md b/.tasks/phase-3-wiki-quiz/LOG.md index 9527328..a48b464 100644 --- a/.tasks/phase-3-wiki-quiz/LOG.md +++ b/.tasks/phase-3-wiki-quiz/LOG.md @@ -2,9 +2,17 @@ - Harness: cursor - Started: 2026-09-10 -- Status: PLAN-READY (Preact island · 주관식 NFKC 정규화). base `feat/phase-2-content` (#59 ready, 미머지). +- Status: PR-DRAFT #60 (`feat/phase-3-quiz` → `feat/phase-2-content`). base #59 먼저 머지. ## Notes - 스택: `feat/phase-2-content` ← `feat/phase-3-quiz`. 머지는 사람. #56 → #58 → #59 먼저. - Quiz island는 Home three와 별개. `client:visible` Preact. JS 꺼져도 noscript 목록. + +## 2026-09-10 implement + +- 스키마 `src/content/quiz.ts` + 덱 4×5=20 (`ts-basics`, `tdd`, `refactoring`, `design-system`). til 3편 `quizRefs`. +- 엔진 `shuffle` / `grade`(NFKC+trim+lower+공백·마침표 제거) / `session` / `progress` — vitest 21 passed. +- UI `QuizDeck` Preact + `/wiki/quiz` (덱 picker, noscript 정적 목록, 키보드 1–4·Enter·←→, aria-live, reduced-motion 페이드). +- **Quiz island gzip** (`dist/_astro/` quiz 페이지 로드 청크 합): **13,608 bytes** (QuizDeck 3,702 + Preact shared). 목표 30KB 이내. +- PR: #60 draft. 게이트: check·oxlint·vitest·lint:stylex·build·build-storybook green. oxfmt는 base부터 `.tasks/**/PLAN.md` 2건 drift(phase-3 src 무관). From dbac990f6f8ef9f77b5852b0dfc8813661a6d4aa Mon Sep 17 00:00:00 2001 From: gitgitWi Date: Thu, 10 Sep 2026 03:13:07 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix(phase-3):=20QuizDeck=EB=A5=BC=20?= =?UTF-8?q?=ED=95=AD=EC=83=81=20hydrate=ED=95=98=EA=B3=A0=20=EB=8D=B1=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=9D=84=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSG HTML에 astro-island가 포함되도록 전 덱 props를 전달한다. --- src/components/quiz/QuizDeck.stories.tsx | 29 ++-- src/components/quiz/QuizDeck.stylex.ts | 34 ++++ src/components/quiz/QuizDeck.tsx | 210 ++++++++++++++++------- src/pages/wiki/quiz.astro | 60 +++---- 4 files changed, 223 insertions(+), 110 deletions(-) diff --git a/src/components/quiz/QuizDeck.stories.tsx b/src/components/quiz/QuizDeck.stories.tsx index 5d4f5e4..739a1e3 100644 --- a/src/components/quiz/QuizDeck.stories.tsx +++ b/src/components/quiz/QuizDeck.stories.tsx @@ -1,8 +1,13 @@ -import { deckLabels } from "../../lib/quiz/decks.ts"; +import type { QuizDeckId } from "../../content/quiz.ts"; import { items as tsBasicsItems } from "../../content/quiz/ts-basics.ts"; +import { deckLabels, getDeckItems, quizDeckIds } from "../../lib/quiz/decks.ts"; import { QuizDeck } from "./QuizDeck.tsx"; +const allDecks = Object.fromEntries( + quizDeckIds.map((deckId) => [deckId, getDeckItems(deckId)]), +) as Record>; + export default { title: "quiz/QuizDeck", component: QuizDeck, @@ -18,22 +23,22 @@ export default { export const TsBasics = { render: () => ( - + ), }; -export const ShortSession = { +export const DeckPickerOnly = { render: () => ( ), }; diff --git a/src/components/quiz/QuizDeck.stylex.ts b/src/components/quiz/QuizDeck.stylex.ts index 3a84f5c..c2a4628 100644 --- a/src/components/quiz/QuizDeck.stylex.ts +++ b/src/components/quiz/QuizDeck.stylex.ts @@ -213,4 +213,38 @@ export const quizDeckStyles = stylex.create({ color: tokens.color.inkTertiary, fontFamily: tokens.fontFamily.mono, }, + deckNav: { + display: "flex", + flexWrap: "wrap", + gap: tokens.space[2], + margin: 0, + padding: 0, + listStyle: "none", + }, + deckButton: { + paddingBlock: tokens.space[2], + paddingInline: tokens.space[3], + borderRadius: tokens.radius.sm, + borderWidth: "1px", + borderStyle: "solid", + borderColor: tokens.color.border, + backgroundColor: tokens.color.canvas, + color: tokens.color.ink, + fontFamily: tokens.fontFamily.sans, + fontSize: tokens.fontSize.sm, + cursor: "pointer", + textDecoration: "none", + }, + deckButtonActive: { + borderColor: tokens.color.accentInk, + backgroundColor: tokens.color.accentSoft, + color: tokens.color.accentInk, + fontWeight: 600, + }, + deckPrompt: { + fontSize: tokens.fontSize.md, + color: tokens.color.inkSecondary, + fontFamily: tokens.fontFamily.sans, + margin: 0, + }, }); diff --git a/src/components/quiz/QuizDeck.tsx b/src/components/quiz/QuizDeck.tsx index 29d7bb1..2411f15 100644 --- a/src/components/quiz/QuizDeck.tsx +++ b/src/components/quiz/QuizDeck.tsx @@ -2,6 +2,7 @@ import * as stylex from "@stylexjs/stylex"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import type { QuizDeckId, QuizItem } from "../../content/quiz.ts"; +import { isQuizDeckId } from "../../lib/quiz/decks.ts"; import { gradeMCQ, gradeShort } from "../../lib/quiz/grade.ts"; import { readQuizProgress, recordDeckAnswer } from "../../lib/quiz/progress.ts"; import { buildSession, isSessionMcq, type SessionItem } from "../../lib/quiz/session.ts"; @@ -10,18 +11,35 @@ import { quizDeckStyles } from "./QuizDeck.stylex.ts"; const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; -type HistoryEntry = { - id: string; - correct: boolean; +type CardSnapshot = { + flipped: boolean; + picked?: number; + shortValue: string; + revealed: boolean; + correct?: boolean; }; +type DeckCatalog = Record; + type QuizDeckProps = { - deckId: QuizDeckId; - deckLabel: string; - items: QuizItem[]; + deckIds: readonly QuizDeckId[]; + deckLabels: Record; + decks: DeckCatalog; sessionLimit?: number; }; +const emptySnapshot = (): CardSnapshot => ({ + flipped: false, + shortValue: "", + revealed: false, +}); + +const readDeckFromUrl = (): QuizDeckId | undefined => { + if (typeof window === "undefined") return undefined; + const param = new URLSearchParams(window.location.search).get("deck") ?? ""; + return isQuizDeckId(param) ? param : undefined; +}; + const useReducedMotion = () => { const [reduced, setReduced] = useState(false); @@ -36,41 +54,76 @@ const useReducedMotion = () => { return reduced; }; -export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDeckProps) { +export function QuizDeck({ deckIds, deckLabels, decks, sessionLimit = 10 }: QuizDeckProps) { const reducedMotion = useReducedMotion(); const inputRef = useRef(null); + const initialDeckId = readDeckFromUrl(); + const [selectedDeckId, setSelectedDeckId] = useState(initialDeckId); const [order, setOrder] = useState(() => - buildSession({ items, limit: sessionLimit }), + initialDeckId + ? buildSession({ + items: decks[initialDeckId], + limit: Math.min(sessionLimit, decks[initialDeckId].length), + }) + : [], ); const [idx, setIdx] = useState(0); - const [flipped, setFlipped] = useState(false); - const [picked, setPicked] = useState(undefined); - const [shortValue, setShortValue] = useState(""); - const [revealed, setRevealed] = useState(false); - const [score, setScore] = useState(0); - const [, setHistory] = useState([]); + const [snapshots, setSnapshots] = useState>({}); const [liveMessage, setLiveMessage] = useState(""); - const [storedProgress, setStoredProgress] = useState(() => readQuizProgress()[deckId]); + const [storedProgress, setStoredProgress] = useState( + () => readQuizProgress()[selectedDeckId ?? "ts-basics"], + ); + + const deckLabel = selectedDeckId ? deckLabels[selectedDeckId] : ""; + const snap = snapshots[idx] ?? emptySnapshot(); + const { flipped, picked, shortValue, revealed } = snap; const current = order[idx]; const total = order.length; - const finished = idx >= total; + const finished = selectedDeckId !== undefined && idx >= total; + const score = useMemo( + () => Object.values(snapshots).filter((entry) => entry.revealed && entry.correct).length, + [snapshots], + ); - const resetCard = useCallback(() => { - setFlipped(false); - setPicked(undefined); - setShortValue(""); - setRevealed(false); - }, []); + const patchSnap = useCallback( + (patch: Partial) => { + setSnapshots((prev) => ({ + ...prev, + [idx]: { ...emptySnapshot(), ...prev[idx], ...patch }, + })); + }, + [idx], + ); + + const startDeckSession = useCallback( + (deckId: QuizDeckId) => { + const items = decks[deckId]; + const nextLimit = Math.min(sessionLimit, items.length); + setOrder(buildSession({ items, limit: nextLimit })); + setIdx(0); + setSnapshots({}); + setStoredProgress(readQuizProgress()[deckId]); + setLiveMessage(`${deckLabels[deckId]} 덱을 시작합니다.`); + }, + [deckLabels, decks, sessionLimit], + ); + + const selectDeck = useCallback( + (deckId: QuizDeckId) => { + const url = new URL(window.location.href); + url.searchParams.set("deck", deckId); + history.replaceState({}, "", url); + setSelectedDeckId(deckId); + startDeckSession(deckId); + }, + [startDeckSession], + ); const restartSession = useCallback(() => { - setOrder(buildSession({ items, limit: sessionLimit })); - setIdx(0); - setScore(0); - setHistory([]); - resetCard(); - setLiveMessage("새 세션을 시작했습니다."); - }, [items, resetCard, sessionLimit]); + if (!selectedDeckId) return; + startDeckSession(selectedDeckId); + }, [selectedDeckId, startDeckSession]); const goNext = useCallback(() => { if (idx >= total - 1) { @@ -79,29 +132,17 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe return; } setIdx((value) => value + 1); - resetCard(); setLiveMessage(`다음 문제 ${idx + 2} / ${total}`); - }, [idx, resetCard, score, total]); + }, [idx, score, total]); const goPrev = useCallback(() => { if (idx <= 0) return; setIdx((value) => value - 1); - resetCard(); setLiveMessage(`이전 문제 ${idx} / ${total}`); - }, [idx, resetCard, total]); + }, [idx, total]); const evaluate = useCallback(() => { - if (!current || revealed) return; - - const correct = - current.type === "mcq" && isSessionMcq(current) - ? gradeMCQ({ - pickedIndex: picked ?? -1, - answerIndex: current.shuffledAnswerIndex, - }) - : current.type === "short" - ? gradeShort({ given: shortValue, answer: current.answer }) - : false; + if (!current || !selectedDeckId || snapshots[idx]?.revealed) return; if (current.type === "mcq" && picked === undefined) { setLiveMessage("보기를 선택한 뒤 제출하세요."); @@ -113,25 +154,41 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe return; } - setRevealed(true); - setFlipped(true); - setHistory((entries) => [...entries, { id: current.id, correct }]); - if (correct) setScore((value) => value + 1); + const correct = + current.type === "mcq" && isSessionMcq(current) + ? gradeMCQ({ + pickedIndex: picked ?? -1, + answerIndex: current.shuffledAnswerIndex, + }) + : current.type === "short" + ? gradeShort({ given: shortValue, answer: current.answer }) + : false; - const progress = recordDeckAnswer({ deckId, correct }); + setSnapshots((prev) => ({ + ...prev, + [idx]: { + flipped: true, + picked, + shortValue, + revealed: true, + correct, + }, + })); + + const progress = recordDeckAnswer({ deckId: selectedDeckId, correct }); setStoredProgress(progress); setLiveMessage(correct ? "정답입니다." : "오답입니다."); - }, [current, deckId, picked, revealed, shortValue]); + }, [current, idx, picked, selectedDeckId, shortValue, snapshots]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (finished) return; + if (!selectedDeckId || finished) return; const target = event.target as HTMLElement | null; const typing = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA"; if (!flipped && event.key === "Enter" && !typing) { event.preventDefault(); - setFlipped(true); + patchSnap({ flipped: true }); setLiveMessage("문제를 확인했습니다. 답을 입력하세요."); return; } @@ -152,7 +209,7 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe const choice = Number(event.key); if (choice >= 1 && choice <= 4) { event.preventDefault(); - setPicked(choice - 1); + patchSnap({ picked: choice - 1 }); setLiveMessage(`${choice}번 보기를 선택했습니다.`); } } @@ -174,22 +231,56 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [current, evaluate, finished, flipped, goNext, goPrev, revealed]); + }, [current, evaluate, finished, flipped, goNext, goPrev, patchSnap, revealed, selectedDeckId]); useEffect(() => { - if (flipped && current?.type === "short") { + if (flipped && current?.type === "short" && !revealed) { inputRef.current?.focus(); } - }, [current?.type, flipped]); + }, [current?.type, flipped, revealed]); const progressPercent = useMemo( () => (total === 0 ? 0 : Math.round(((idx + (revealed ? 1 : 0)) / total) * 100)), [idx, revealed, total], ); + const deckPicker = ( + + ); + + if (!selectedDeckId) { + return ( +
+ {deckPicker} +

+ 덱을 선택하면 flash card 세션이 시작됩니다. +

+
+ ); + } + if (finished) { return (
+ {deckPicker}

{deckLabel} 완료

@@ -228,7 +319,7 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe type="button" {...stylex.props(quizDeckStyles.primaryButton)} onClick={() => { - setFlipped(true); + patchSnap({ flipped: true }); setLiveMessage("문제를 확인했습니다. 답을 입력하세요."); }} > @@ -261,7 +352,7 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe showResult && selected && !isCorrect && quizDeckStyles.choiceWrong, )} onClick={() => { - setPicked(choiceIndex); + patchSnap({ picked: choiceIndex }); setLiveMessage(`${choiceIndex + 1}번 보기를 선택했습니다.`); }} > @@ -282,7 +373,7 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe disabled={revealed} aria-label="주관식 답" {...stylex.props(quizDeckStyles.shortInput)} - onInput={(event) => setShortValue((event.target as HTMLInputElement).value)} + onInput={(event) => patchSnap({ shortValue: (event.target as HTMLInputElement).value })} /> )} @@ -315,6 +406,7 @@ export function QuizDeck({ deckId, deckLabel, items, sessionLimit = 10 }: QuizDe return (
+ {deckPicker}

{deckLabel}

diff --git a/src/pages/wiki/quiz.astro b/src/pages/wiki/quiz.astro index 6a08a69..08f0102 100644 --- a/src/pages/wiki/quiz.astro +++ b/src/pages/wiki/quiz.astro @@ -7,17 +7,16 @@ import { deckLabels, getAllQuizItems, getDeckItems, - isQuizDeckId, quizDeckIds, type QuizDeckId, } from '../../lib/quiz/decks.ts'; import Page from '../../layouts/Page.astro'; -const deckParam = Astro.url.searchParams.get('deck') ?? ''; -const selectedDeck: QuizDeckId | undefined = isQuizDeckId(deckParam) ? deckParam : undefined; -const deckItems = selectedDeck ? getDeckItems(selectedDeck) : []; +const decks = Object.fromEntries( + quizDeckIds.map((deckId) => [deckId, getDeckItems(deckId)]), +) as Record>; + const allItems = getAllQuizItems(); -const sessionLimit = Math.min(10, deckItems.length); --- @@ -30,45 +29,28 @@ const sessionLimit = Math.min(10, deckItems.length); - - - { - selectedDeck && deckItems.length > 0 && ( -
- -
- ) - } +
+ +