@ihabdevteam/core 0.145.0 → 0.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/components.cjs.js +174 -174
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +24 -13
  7. package/dist/packages/core/src/components/ActionCard.js +10 -2
  8. package/dist/packages/core/src/components/Dropdown.js +2 -2
  9. package/dist/packages/core/src/components/LandingHeader.js +12 -5
  10. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +3 -1
  11. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +17 -4
  12. package/dist/packages/core/src/components/Popup/ListPopup.js +1 -0
  13. package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -4
  14. package/dist/packages/core/src/components/Popup/Popup.js +5 -1
  15. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +3 -2
  16. package/dist/packages/core/src/components/Report/ReportDocument.js +5 -3
  17. package/dist/packages/core/src/components/Section.js +2 -1
  18. package/dist/packages/core/src/components/Table.js +18 -8
  19. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +2 -0
  20. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +11 -1
  21. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  22. package/dist/packages/core/src/components/Training/useNoiseFade.js +25 -7
  23. package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +2 -1
  24. package/dist/packages/core/src/components/Word/wordTablePrint.js +8 -1
  25. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +35 -7
  26. package/dist/packages/core/src/hooks/useTablePaging.js +4 -3
  27. package/dist/packages/core/src/hooks/useTrainingSessionLog.d.ts +5 -2
  28. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +45 -8
  29. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -2
  30. package/dist/packages/core/src/utils/getTrainingData.d.ts +5 -1
  31. package/dist/packages/core/src/utils/getTrainingData.js +20 -4
  32. package/dist/packages/core/src/utils/planFeatures.d.ts +1 -1
  33. package/dist/packages/core/src/utils/planFeatures.js +7 -1
  34. package/dist/packages/core/src/utils/textSort.d.ts +4 -4
  35. package/dist/packages/core/src/utils/textSort.js +58 -28
  36. package/dist/packages/core/src/utils/trainingItemResults.js +3 -1
  37. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +4 -2
  38. package/dist/packages/core/src/utils/wordTrainingGenerators.js +7 -4
  39. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +1 -0
  40. package/dist/packages/core/src/utils/wordTrainingPool.js +20 -6
  41. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +2 -1
  42. package/dist/packages/core/src/utils/wordTrainingRuntime.js +39 -15
  43. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +4 -3
  44. package/dist/packages/core/src/version.d.ts +1 -1
  45. package/dist/packages/core/src/version.js +1 -1
  46. package/dist/utils.cjs.js +2 -2
  47. package/package.json +1 -1
@@ -45,6 +45,8 @@ export function useDailyTrainingSelection(userId) {
45
45
  }, []);
46
46
  useEffect(() => {
47
47
  let cancelled = false;
48
+ let 떠남 = false;
49
+ let 적용됨 = false;
48
50
  let timer;
49
51
  let attempts = 0;
50
52
  const MAX_ATTEMPTS = 40; // 250ms * 40 = 10초 후 폴링 중단(풀이 영영 비는 경우 무한루프 방지)
@@ -60,6 +62,9 @@ export function useDailyTrainingSelection(userId) {
60
62
  }
61
63
  catch { }
62
64
  const apply = (sel) => {
65
+ if (떠남)
66
+ return;
67
+ 적용됨 = true;
63
68
  todayRef.current = sel.today;
64
69
  yesterdayRef.current = sel.yesterday;
65
70
  setLoaded(true);
@@ -79,13 +84,16 @@ export function useDailyTrainingSelection(userId) {
79
84
  return;
80
85
  }
81
86
  }
82
- // 캐시 miss → 데이터 로드 후 새 선택
83
- try {
84
- await ensureTrainingDataLoaded(3000);
87
+ /* 캐시 miss → 데이터 로드 후 새 선택. 로드는 **2초마다 한 번만** 다시 청한다 — 250ms 마다 부르면 받기가 곧바로 실패하는
88
+ 배포(형식 오류)에서 10초에 850KB 자료를 40번 받았다. 그 사이 폴링은 선택만 다시 계산한다. */
89
+ if (attempts % 8 === 0) {
90
+ try {
91
+ await ensureTrainingDataLoaded(3000);
92
+ }
93
+ catch { }
94
+ if (cancelled)
95
+ return;
85
96
  }
86
- catch { }
87
- if (cancelled)
88
- return;
89
97
  const sel = resolveSelection(forceRefresh, undefined, userId);
90
98
  if (hasItems(sel.today)) {
91
99
  apply(sel);
@@ -101,9 +109,29 @@ export function useDailyTrainingSelection(userId) {
101
109
  stop();
102
110
  }
103
111
  };
112
+ /* 포기한 뒤(10초)라도 자료가 도착하면 다시 계산한다 — 느린 회선에서 13초 걸려 받은 회원은 새로고침 전까지 "자료를 못 찾음" 에 남았다 */
113
+ const 자료도착 = () => {
114
+ if (떠남 || 적용됨)
115
+ return;
116
+ const sel = resolveSelection(forceRefresh, undefined, userId);
117
+ if (hasItems(sel.today))
118
+ apply(sel);
119
+ };
120
+ try {
121
+ window.addEventListener('speechear.data.updated', 자료도착);
122
+ }
123
+ catch { }
104
124
  tryFill();
105
125
  timer = setInterval(tryFill, 250);
106
- return () => { cancelled = true; stop(); };
126
+ return () => {
127
+ cancelled = true;
128
+ 떠남 = true;
129
+ stop();
130
+ try {
131
+ window.removeEventListener('speechear.data.updated', 자료도착);
132
+ }
133
+ catch { }
134
+ };
107
135
  // 오늘 은 날짜가 바뀌었다는 신호 — resolveSelection 이 안에서 오늘 날짜를 읽는다
108
136
  // eslint-disable-next-line react-hooks/exhaustive-deps
109
137
  }, [userId, 오늘]);
@@ -46,9 +46,10 @@ export function useTablePaging(rows, resetOn, opts) {
46
46
  const total = Math.max(1, Math.ceil(rows.length / Number(pageSize)));
47
47
  /* 행이 줄어 범위를 넘으면 **상태도** 끝 쪽으로 맞춘다 — 보이는 쪽만 조이고 상태를 두면, 필터로 줄었다가 다시 늘 때 예전
48
48
  5쪽으로 튀었다. 렌더 중에 맞춘다(위 resetOn 과 같은 꼴). */
49
- if (page > total)
50
- setPage(total);
51
- const 이쪽 = Math.min(page, total);
49
+ // 1 보다 작은 쪽(setPage(0)·-1)도 막는다 — 0 은 빈 쪽, -1 은 음수 slice 로 엉뚱한 줄이 나왔다
50
+ if (page > total || page < 1 || !Number.isFinite(page))
51
+ setPage(Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total));
52
+ const 이쪽 = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total);
52
53
  const pageRows = useMemo(() => rows.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize)), [rows, 이쪽, pageSize]);
53
54
  return {
54
55
  pageRows,
@@ -1,6 +1,9 @@
1
1
  /**
2
- * 훈련 팝업 마운트~언마운트 시간을 기록합니다.
3
- * 5초 미만 세션(잘못 열었다 닫은 경우)은 무시합니다.
2
+ * 훈련 팝업을 **보고 있던** 시간을 기록합니다.
3
+ * 보인 시간이 5초 미만(잘못 열었다 닫은 경우)이면 무시합니다.
4
+ *
5
+ * 한 팝업에 **한 줄** 만 보냅니다 — 떠날 때, 또는 페이지가 내려갈 때(pagehide: 팝업을 연 채 탭·앱을 닫음). 화면이 가려진
6
+ * 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간 잠가 둔 시간이 훈련 시간으로 적혔다.
4
7
  *
5
8
  * @public
6
9
  */
@@ -2,8 +2,11 @@
2
2
  import { useEffect } from 'react';
3
3
  import { logTrainingTime } from '../utils/trainingLogger';
4
4
  /**
5
- * 훈련 팝업 마운트~언마운트 시간을 기록합니다.
6
- * 5초 미만 세션(잘못 열었다 닫은 경우)은 무시합니다.
5
+ * 훈련 팝업을 **보고 있던** 시간을 기록합니다.
6
+ * 보인 시간이 5초 미만(잘못 열었다 닫은 경우)이면 무시합니다.
7
+ *
8
+ * 한 팝업에 **한 줄** 만 보냅니다 — 떠날 때, 또는 페이지가 내려갈 때(pagehide: 팝업을 연 채 탭·앱을 닫음). 화면이 가려진
9
+ * 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간 잠가 둔 시간이 훈련 시간으로 적혔다.
7
10
  *
8
11
  * @public
9
12
  */
@@ -11,16 +14,50 @@ export default function useTrainingSessionLog(trainingType) {
11
14
  useEffect(() => {
12
15
  if (!trainingType)
13
16
  return; // 타입 미상이면 기록하지 않음
14
- const startedAt = new Date();
15
- return () => {
16
- const endedAt = new Date();
17
- if (endedAt.getTime() - startedAt.getTime() < 5000)
17
+ /* **한 팝업 = 한 줄.** 보이는 구간마다 따로 보내면 서버가 줄 수를 세션 수로 세어(v_user_training_time_stats) 탭을 오갈 때마다
18
+ 문장 훈련 횟수가 늘었다. 보이던 시간만 모아 두었다가 떠날 때(또는 페이지가 내려갈 때) 한 번 보낸다 — 끝 시각은 처음 보인 때 + 보인 시간. */
19
+ const 보임 = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
20
+ let 처음보임 = null;
21
+ let 구간시작 = null;
22
+ let 모은ms = 0;
23
+ let 보냄 = false;
24
+ const 구간열기 = () => { const t = Date.now(); if (처음보임 == null)
25
+ 처음보임 = t; if (구간시작 == null)
26
+ 구간시작 = t; };
27
+ const 구간닫기 = () => { if (구간시작 != null) {
28
+ 모은ms += Date.now() - 구간시작;
29
+ 구간시작 = null;
30
+ } };
31
+ if (보임())
32
+ 구간열기();
33
+ const 보내기 = () => {
34
+ if (보냄)
35
+ return;
36
+ 구간닫기();
37
+ if (처음보임 == null || 모은ms < 5000)
18
38
  return;
39
+ 보냄 = true;
19
40
  logTrainingTime({
20
41
  trainingType,
21
- startedAt: startedAt.toISOString(),
22
- endedAt: endedAt.toISOString(),
42
+ startedAt: new Date(처음보임).toISOString(),
43
+ endedAt: new Date(처음보임 + 모은ms).toISOString(),
23
44
  });
24
45
  };
46
+ const 보임바뀜 = () => { if (보임())
47
+ 구간열기();
48
+ else
49
+ 구간닫기(); };
50
+ if (typeof document !== 'undefined')
51
+ document.addEventListener('visibilitychange', 보임바뀜);
52
+ // 페이지가 내려가면(탭·앱 닫기) React 정리가 안 돈다 — 그때 보낸다
53
+ if (typeof window !== 'undefined')
54
+ window.addEventListener('pagehide', 보내기);
55
+ return () => {
56
+ if (typeof document !== 'undefined')
57
+ document.removeEventListener('visibilitychange', 보임바뀜);
58
+ if (typeof window !== 'undefined')
59
+ window.removeEventListener('pagehide', 보내기);
60
+ 보내기();
61
+ };
25
62
  }, [trainingType]);
26
63
  }
@@ -26,6 +26,10 @@ const memCache = new Map();
26
26
  /* 받아 봤는데 뜻이 없던 열쇠 — 다시 묻지 않는다. 전에는 뜻 없는 낱말이 캐시에 안 남아 표를 다시 정렬하거나 쪽을
27
27
  넘길 때마다 같은 낱말을 또 조회했다. (조회가 실패한 것은 여기 넣지 않는다 — 다음에 다시 받는다.) */
28
28
  const noMeaning = new Set();
29
+ /* 빈 응답(한 줄도 못 받음)으로 '뜻 없음' 이 된 열쇠와 그 때 — 조회 함수는 실패도 빈 배열로 돌려주므로 영영 굳히지 않고, 사용자 추가
30
+ 낱말처럼 정말 뜻이 없는 쪽을 오갈 때마다 다시 묻지도 않게 **잠시만**(1분) 묻지 않는다. */
31
+ const 빈응답때 = new Map();
32
+ const 빈응답유예ms = 60_000;
29
33
  export function useWordMeanings(rows, fetchMeanings) {
30
34
  /* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
31
35
  올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
@@ -35,7 +39,8 @@ export function useWordMeanings(rows, fetchMeanings) {
35
39
  const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort().join('|');
36
40
  useEffect(() => {
37
41
  const wanted = wantedKey ? wantedKey.split('|') : [];
38
- const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k));
42
+ const 지금 = Date.now();
43
+ const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k) && !(지금 - (빈응답때.get(k) ?? -Infinity) < 빈응답유예ms));
39
44
  if (missing.length === 0)
40
45
  return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
41
46
  let cancelled = false;
@@ -50,9 +55,15 @@ export function useWordMeanings(rows, fetchMeanings) {
50
55
  /* 한 줄도 못 받았으면 '뜻 없음' 으로 굳히지 않는다 — 조회 함수는 실패를 빈 배열로 돌려주는 약속이라, 잠깐 끊긴 망이
51
56
  그 쪽 낱말들을 새로고침 전까지 '뜻 없음' 으로 묶었다. 받은 게 있으면 그 응답에 빠진 낱말만 없는 것으로 본다. */
52
57
  if (got.length > 0)
53
- for (const k of missing)
58
+ for (const k of missing) {
54
59
  if (!memCache.has(k))
55
60
  noMeaning.add(k);
61
+ }
62
+ else {
63
+ const t = Date.now();
64
+ for (const k of missing)
65
+ 빈응답때.set(k, t);
66
+ }
56
67
  if (cancelled || got.length === 0)
57
68
  return;
58
69
  setFetched(v => v + 1);
@@ -56,7 +56,11 @@ export declare function getTrainingPools(): Record<string, unknown[]>;
56
56
  export declare function loadTrainingData(): Promise<void>;
57
57
  /** @public */
58
58
  export declare function isTrainingDataLoaded(): boolean;
59
- export declare function ensureTrainingDataLoaded(timeout?: number): Promise<unknown>;
59
+ /**
60
+ * 자료를 받게 하고 **받았는지** 를 돌려준다(시간 초과·실패는 false). 전에는 성공·3초 초과·실패가 모두 undefined 로 끝나
61
+ * 부르는 쪽이 빈 풀로 계획을 만들고도 알 길이 없었다. 늦게 도착하면 'speechear.data.updated' 가 온다.
62
+ */
63
+ export declare function ensureTrainingDataLoaded(timeout?: number): Promise<boolean>;
60
64
  declare const _default: {
61
65
  NOISE_DATAS: unknown[];
62
66
  SHORT_STORIES: unknown[];
@@ -111,6 +111,9 @@ export async function loadTrainingData() {
111
111
  for (const key of Object.keys(map || {})) {
112
112
  const groups = map[key] || [];
113
113
  for (const g of groups) {
114
+ // 한 행이 null·글자면 건너뛴다 — 그 한 행의 TypeError 로 자료 전체를 버리고 매번 같은 실패를 되풀이했다
115
+ if (!g || typeof g !== 'object')
116
+ continue;
114
117
  const gr = g;
115
118
  const t = String(gr.training_type || key);
116
119
  const cat0 = String(gr.category_main_code || gr.category_main || gr.category_0 || '');
@@ -149,22 +152,35 @@ export async function loadTrainingData() {
149
152
  SHORT_STORIES.push(...받음.short);
150
153
  LONG_STORIES.push(...받음.long);
151
154
  ORDER_SETS.push(...받음.order);
155
+ // 다 채운 뒤 _loaded 를 먼저 세운다 — 'updated' 를 받은 쪽이 isTrainingDataLoaded() 로 확인하면 아직 false 였다
156
+ _loaded = true;
152
157
  try {
153
- window.dispatchEvent(new Event('speechear.data.updated'));
158
+ computeDailySelection();
154
159
  }
155
160
  catch (e) { }
156
161
  try {
157
- computeDailySelection();
162
+ window.dispatchEvent(new Event('speechear.data.updated'));
158
163
  }
159
164
  catch (e) { }
160
- _loaded = true;
161
165
  })();
162
166
  return _loading;
163
167
  }
164
168
  /** @public */
165
169
  export function isTrainingDataLoaded() { return _loaded; }
170
+ /**
171
+ * 자료를 받게 하고 **받았는지** 를 돌려준다(시간 초과·실패는 false). 전에는 성공·3초 초과·실패가 모두 undefined 로 끝나
172
+ * 부르는 쪽이 빈 풀로 계획을 만들고도 알 길이 없었다. 늦게 도착하면 'speechear.data.updated' 가 온다.
173
+ */
166
174
  export async function ensureTrainingDataLoaded(timeout = 3000) {
167
175
  const p = loadTrainingData();
168
- return Promise.race([p, new Promise((res) => setTimeout(res, timeout))]);
176
+ let t;
177
+ try {
178
+ await Promise.race([p, new Promise((res) => { t = setTimeout(res, timeout); })]);
179
+ }
180
+ finally {
181
+ if (t !== undefined)
182
+ clearTimeout(t);
183
+ }
184
+ return _loaded;
169
185
  }
170
186
  export default { NOISE_DATAS, SHORT_STORIES, LONG_STORIES, ORDER_SETS, loadTrainingData, ensureTrainingDataLoaded };
@@ -58,4 +58,4 @@ export interface PlanLimits {
58
58
  /** @public 요금제 티어별 제한 (표시되지 않는 레거시 티어는 생략) */
59
59
  export declare const PLAN_LIMITS: Partial<Record<PlanTier, PlanLimits>>;
60
60
  /** product code → 요금제 티어 */
61
- export declare function getPlanTier(productCode: string): PlanTier;
61
+ export declare function getPlanTier(productCode: string | null | undefined): PlanTier;
@@ -145,7 +145,13 @@ export const PLAN_LIMITS = {
145
145
  };
146
146
  /** product code → 요금제 티어 */
147
147
  export function getPlanTier(productCode) {
148
- const c = productCode.toLowerCase();
148
+ const c = (productCode ?? '').toLowerCase();
149
+ /* 무료 코드는 접두사 규칙보다 먼저 — 'speechear_free' 가 아래 speechear_ 규칙에 걸려 유료 개인 스탠다드로 올라갔고,
150
+ 'manager_free' 는 규칙이 없어 none 이었다(어드민 요금제 PLANS 의 plan_id). null 코드는 던지지 않고 none. */
151
+ if (c === 'speechear_free' || c === 'free')
152
+ return 'free';
153
+ if (c.startsWith('manager_free') || c.startsWith('mgr_free'))
154
+ return 'mgr_free';
149
155
  if (c.startsWith('manager_enterprise'))
150
156
  return 'enterprise';
151
157
  if (c.startsWith('manager_pro'))
@@ -1,13 +1,13 @@
1
1
  /** 글자 둘을 한국어 차례로 견준다 */
2
2
  export declare function 한글순(a: string, b: string): number;
3
3
  /**
4
- * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
4
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
5
5
  *
6
6
  * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
7
- * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
8
- * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
7
+ * (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
8
+ * `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
9
9
  *
10
- * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
10
+ * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
11
11
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
12
12
  */
13
13
  export declare function 칸순(a: unknown, b: unknown): number;
@@ -22,39 +22,69 @@ const 콜레이터 = (() => {
22
22
  export function 한글순(a, b) {
23
23
  return 콜레이터().compare(a, b);
24
24
  }
25
- const 숫자글자 = /^-?\d+(\.\d+)?$/;
26
- /* 칸의 글자 비교는 **안의 숫자도 수로** 본다 — 숫자 칸끼리는 수로, 섞인 칸은 글자로 견주면 '9'<'10'(수)·'10'<'10분'(글자)·
27
- '10분'<'9'(글자) 로 돌고 돌아, 같은 행을 들어온 차례만 바꿔도 정렬 결과가 달랐다. 수를 수로 읽는 콜레이터는
28
- 숫자끼리의 차례와 같은 쪽을 낸다. */
29
- const 칸콜레이터 = (() => {
30
- let 것 = null;
31
- return () => {
32
- if (!것)
33
- 것 = new Intl.Collator('ko', { numeric: true });
34
- return 것;
35
- };
36
- })();
25
+ const 조각열쇠 = new Map();
26
+ function 조각내기(s) {
27
+ const 있음 = 조각열쇠.get(s);
28
+ if (있음)
29
+ return 있음;
30
+ const out = [];
31
+ // 뒤돌아보기(?<=) 는 iOS 15 Safari 가 못 읽어 모듈째 깨지므로 쓰지 않는다 — 빼기표 앞 글자를 손으로 본다
32
+ const re = /-?\d+(?:\.\d+)?/g;
33
+ let 끝 = 0;
34
+ for (let m = re.exec(s); m; m = re.exec(s)) {
35
+ let 시작 = m.index;
36
+ let 수 = m[0];
37
+ if (수[0] === '-' && 시작 > 0 && !/\s/.test(s[시작 - 1])) {
38
+ 시작 += 1;
39
+ 수 = 수.slice(1);
40
+ }
41
+ if (시작 > 끝)
42
+ out.push(s.slice(끝, 시작));
43
+ out.push(Number(수));
44
+ 끝 = m.index + m[0].length;
45
+ }
46
+ if (끝 < s.length)
47
+ out.push(s.slice(끝));
48
+ if (조각열쇠.size > 5000)
49
+ 조각열쇠.clear();
50
+ 조각열쇠.set(s, out);
51
+ return out;
52
+ }
37
53
  /**
38
- * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
54
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
39
55
  *
40
56
  * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
41
- * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
42
- * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
57
+ * (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
58
+ * `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
43
59
  *
44
- * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
60
+ * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
45
61
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
46
62
  */
47
63
  export function 칸순(a, b) {
48
- /* NaN 은 숫자가 아닌 것으로 — a - b 가 NaN 이면 정렬 비교가 어긋나 줄이 뒤섞였다. 글자로 온 숫자('10'·'9')도
49
- 둘 다 온전한 숫자면 숫자로 견준다(전에는 '10' 이 '9' 앞에 섰다). */
50
- const 수 = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : typeof v === 'string' && 숫자글자.test(v.trim()) ? Number(v) : null);
51
- const na = 수(a), nb = 수(b);
52
- if (na !== null && nb !== null)
53
- return na - nb;
54
- const sa = a == null ? '' : String(a), sb = b == null ? '' : String(b);
55
- /* 소수점·음수가 든 글자('12.5%'·'-10 dB')는 수로 읽는 콜레이터가 틀린다 — 점 뒤를 따로 떼어 '12.5' 가 '12.25' 뒤에,
56
- 빼기표를 버려 '-5' 가 '-10' 뒤에 섰다. 그런 칸은 한국어 글자 차례로 둔다. 선행 0 도 수로 읽으면 'A007'='A7' 이 되어 글자로. */
57
- if (/\d\.\d|-\d|(^|\D)0\d/.test(sa) || /\d\.\d|-\d|(^|\D)0\d/.test(sb))
58
- return 한글순(sa, sb);
59
- return 칸콜레이터().compare(sa, sb);
64
+ const 글 = (v) => (v == null || (typeof v === 'number' && Number.isNaN(v)) ? '' : String(v).trim());
65
+ const sa = 글(a), sb = 글(b);
66
+ if (sa === sb)
67
+ return 0;
68
+ /* 수(number)는 글로 바꿔 쪼개지 않고 **수 조각 하나** 로 — '1e+21'·'1e-7' 로 쪼개면 1e21 이 5 앞에, -Infinity 가 끝에 섰다 */
69
+ const 열쇠 = (v, s) => (typeof v === 'number' && !Number.isNaN(v) ? [v] : 조각내기(s));
70
+ const ka = 열쇠(a, sa), kb = 열쇠(b, sb);
71
+ const n = Math.min(ka.length, kb.length);
72
+ for (let i = 0; i < n; i++) {
73
+ const x = ka[i], y = kb[i];
74
+ if (typeof x === 'number' && typeof y === 'number') {
75
+ if (x !== y)
76
+ return x - y;
77
+ continue;
78
+ } // 같지 않은 ±Infinity 끼리도 부호가 맞다
79
+ if (typeof x === 'number')
80
+ return -1;
81
+ if (typeof y === 'number')
82
+ return 1;
83
+ const c = 한글순(x, y);
84
+ if (c !== 0)
85
+ return c;
86
+ }
87
+ if (ka.length !== kb.length)
88
+ return ka.length - kb.length;
89
+ return 한글순(sa, sb);
60
90
  }
@@ -55,7 +55,9 @@ export function toItemResults(raw) {
55
55
  /* 물음이 담긴 칸 이름이 유형마다 다르다 — 문장 훈련 넷(소음 하 듣기·문장 순서화·
56
56
  짧은/긴 이야기)은 `sentence` 에, 낱말 훈련은 `word` 에 담는다. `sentence` 를
57
57
  빠뜨렸더니 대면 세션 기록의 item_id 가 전부 null 이었다(2026-08-27 실측). */
58
- question: text(r.question) ?? text(r.sentence) ?? text(r.word) ?? text(r.targetSyllable),
58
+ /* 단어 조작(합성·대치·탈락)·확인형은 물음을 `text` 에 담는다 — 빠져 기록의 question 이 null 이라 오답 패널에서 통째로
59
+ 빠졌는데, 결과 창(resolveProblem)은 text 를 읽어 보여 두 화면이 어긋났다. 결과 창과 같은 칸들을 본다. */
60
+ question: text(r.question) ?? text(r.q) ?? text(r.sentence) ?? text(r.text) ?? text(r.prompt) ?? text(r.word) ?? text(r.targetSyllable),
59
61
  answer: text(r.answer),
60
62
  selected: text(r.selectedText) ?? text(r.selected),
61
63
  correct: itemCorrectness(r),
@@ -59,7 +59,7 @@ export declare function syllablePron(ch: string): string;
59
59
  export declare function soundOf(syll: string, pronMap: Map<string, string>): string;
60
60
  /** @public comp에서 실제로 선택된 파트 1개(초성 우선)를 반환. 없으면 null. */
61
61
  export declare function primarySelPart(comp: CompositionFilter | null): 'cho' | 'jung' | 'jong' | null;
62
- /** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
62
+ /** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). `jongNone` 이면 받침 없는 음절만 맞고 `jong` 은 보지 않는다(풀 필터와 같게). */
63
63
  export declare function syllMatchesComp(s: Syllable, comp: CompositionFilter): boolean;
64
64
  /**
65
65
  * @public 발음 모드에서 comp 매칭에 쓸 음절 배열. 표준발음(pronMap)의 음절수가 철자와 같으면 발음 음절로,
@@ -75,6 +75,8 @@ export declare function syllableContrasts(cs: Syllable, part: 'cho' | 'jung' | '
75
75
  export interface TrainingProblem {
76
76
  question: string;
77
77
  distractors: string[];
78
+ /** 탈락 문제에서 빠진 조각(합성어 부분 탈락이 채운다 — 앞뒤로 같은 조각이 있으면 remain 만으로는 어느 쪽인지 모른다) */
79
+ dropped?: string;
78
80
  }
79
81
  /**
80
82
  * @public 2음절 단어 → [문제단어, 남는 1음절(정답), 대립쌍 오답 2]. 정답·오답 모두 '실단어'(wordSet)만 인정.
@@ -92,7 +94,7 @@ export declare function buildDropContrast3Problem(word: string, comp: Compositio
92
94
  * @public 합성어 부분 탈락 → [문제단어(합성어), 남는 실단어(정답), 그 단어의 대립쌍 실단어 오답 2].
93
95
  * 합성어 근사: 앞/뒤로 나눈 두 조각이 '둘 다 실단어'(wordSet)인 첫 조합을 채택(예: 손수건=손+수건).
94
96
  * 정답·오답 전부 실단어라 발음이 그대로. comp(훈련음소) 지정 시 남는 단어의 그 자리를 대립, 없으면 전 음절.
95
- * dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 편집기·런타임 공용.
97
+ * dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 결과의 `dropped` 에 뺀 조각을 담는다. 편집기·런타임 공용.
96
98
  */
97
99
  export declare function buildDropCompoundProblem(word: string, comp: CompositionFilter | null, pronMap: Map<string, string>, phonemeView: 'char' | 'pron', wordSet: Set<string>, dropPos?: number | null): TrainingProblem | null;
98
100
  /**
@@ -207,10 +207,13 @@ export function primarySelPart(comp) {
207
207
  return 'jong';
208
208
  return null;
209
209
  }
210
- /** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
210
+ /** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). `jongNone` 이면 받침 없는 음절만 맞고 `jong` 은 보지 않는다(풀 필터와 같게). */
211
211
  export function syllMatchesComp(s, comp) {
212
212
  const ok = (arr, val) => !hasSel(arr) || asArr(arr).includes(val);
213
- return ok(comp.cho, CHOSEONG[s.cho]) && ok(comp.jung, JUNGSEONG[s.jung]) && ok(comp.jong, JONGSEONG[s.jong]);
213
+ /* '받침 없음'(jongNone)도 본다 — 풀 필터(wordTrainingPool)는 jongNone 이면 받침 없는 음절만 맞다고 보는데 여기만 무시해, 초성 ㅎ +
214
+ 받침 없음으로 골라도 음절 바꾸기가 '해협' 의 '협' 을 바꿀 음절로 골랐다. jongNone 이면 jong 선택은 보지 않는다(풀과 같게). */
215
+ const jongOk = comp.jongNone ? s.jong === 0 : ok(comp.jong, JONGSEONG[s.jong]);
216
+ return ok(comp.cho, CHOSEONG[s.cho]) && ok(comp.jung, JUNGSEONG[s.jung]) && jongOk;
214
217
  }
215
218
  /**
216
219
  * @public 발음 모드에서 comp 매칭에 쓸 음절 배열. 표준발음(pronMap)의 음절수가 철자와 같으면 발음 음절로,
@@ -364,7 +367,7 @@ export function buildDropContrast3Problem(word, comp, pronMap, phonemeView, word
364
367
  * @public 합성어 부분 탈락 → [문제단어(합성어), 남는 실단어(정답), 그 단어의 대립쌍 실단어 오답 2].
365
368
  * 합성어 근사: 앞/뒤로 나눈 두 조각이 '둘 다 실단어'(wordSet)인 첫 조합을 채택(예: 손수건=손+수건).
366
369
  * 정답·오답 전부 실단어라 발음이 그대로. comp(훈련음소) 지정 시 남는 단어의 그 자리를 대립, 없으면 전 음절.
367
- * dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 편집기·런타임 공용.
370
+ * dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 결과의 `dropped` 에 뺀 조각을 담는다. 편집기·런타임 공용.
368
371
  */
369
372
  export function buildDropCompoundProblem(word, comp, pronMap, phonemeView, wordSet, dropPos = null) {
370
373
  const chars = [...word];
@@ -407,7 +410,7 @@ export function buildDropCompoundProblem(word, comp, pronMap, phonemeView, wordS
407
410
  break;
408
411
  }
409
412
  if (wrongs.length >= 2)
410
- return { question: word, distractors: [remain, ...wrongs] };
413
+ return { question: word, distractors: [remain, ...wrongs], dropped };
411
414
  }
412
415
  }
413
416
  return null;
@@ -25,6 +25,7 @@ export declare function pickOne<T>(arr: T[]): T | undefined;
25
25
  /** 부분 Fisher-Yates: n개만 선택 — arr.length 전체를 교환하지 않고 n번만 교환(대용량 풀 성능용, 공용 sample과 별개). */
26
26
  export declare function sample<T>(arr: T[], n: number): T[];
27
27
  export declare function getPronMap(): Map<string, string>;
28
+ export declare function pronOfWord(word: string): string;
28
29
  /**
29
30
  * 후보군에서 n개를 뽑되, 이미 선택된 것들(또는 usedPron으로 미리 배제한 것들)과
30
31
  * 발음이 겹치지 않도록 그리디하게 샘플링한다.
@@ -103,7 +103,7 @@ export function getPronMap() {
103
103
  _cachedPronMapPool = pool;
104
104
  return m;
105
105
  }
106
- function pronOfWord(word) {
106
+ export function pronOfWord(word) {
107
107
  return getPronMap().get(word) || word;
108
108
  }
109
109
  /**
@@ -433,7 +433,8 @@ async function _buildUsablePool(opts) {
433
433
  usable = bucket.filter(other => {
434
434
  if (other === w.w || pronOfWord(other) === wPron)
435
435
  return false;
436
- const d = countPhonemeDiff(w.w, other);
436
+ // 대립 비교는 두 모드 모두 **발음** — 철자로 세면 '팥빵[팓빵]/팥밥[팓빱]' 이 2개, '틀다/끊다[끈타]' 가 2개로 셌다
437
+ const d = countPhonemeDiff(wPron, pronOfWord(other));
437
438
  return contrastCount >= 5 ? d >= 5 : d === contrastCount;
438
439
  });
439
440
  }
@@ -442,11 +443,24 @@ async function _buildUsablePool(opts) {
442
443
  // 'random' 또는 null(difficulty:false 타입): 음운론적 제약은 없지만,
443
444
  // 정답과 오답의 음절수는 같아야 한다(단어 길이만으로 정답을 짐작하지 못하도록) — 본 단어와 다른 단어 + 음절수(글자수) 동일
444
445
  const 쓸수있음 = (other) => other !== w.w && other.length === w.w.length && pronOfWord(other) !== wPron;
445
- /* 셈(fullCount — 가능 단어 수 안내)은 '오답이 하나라도 있나' 만 본다. 2.2만 후보마다 600개를 끝까지 걸러 칩 한 번에
446
- 300ms 넘게 멈췄다. 셈은 결과 개수만 쓰고(countUsableWords → pool.length) 오답 목록은 쓰지 않는다. */
446
+ /* 셈(fullCount — 가능 단어 수 안내)은 **서로 소리가 다른 오답이 보기 수 − 1 개** 있는지까지만 본다(countUsableWords 가 그
447
+ 기준으로 센다). 2.2만 후보마다 600개를 끝까지 걸러 칩 한 번에 300ms 넘게 멈췄다. */
447
448
  if (opts.fullCount) {
448
- const one = distractorPool.find(쓸수있음);
449
- usable = one === undefined ? [] : [one];
449
+ const need = Math.max(1, (opts.choicesPerItem ?? 3) - 1);
450
+ const 고름 = [];
451
+ const 소리 = new Set();
452
+ for (const other of distractorPool) {
453
+ if (!쓸수있음(other))
454
+ continue;
455
+ const p = pronOfWord(other);
456
+ if (소리.has(p))
457
+ continue;
458
+ 소리.add(p);
459
+ 고름.push(other);
460
+ if (고름.length >= need)
461
+ break;
462
+ }
463
+ usable = 고름;
450
464
  }
451
465
  else
452
466
  usable = distractorPool.filter(쓸수있음);
@@ -19,7 +19,8 @@ export declare function getViableChoseongs(opts?: {
19
19
  excludeNoImage?: boolean;
20
20
  }): Promise<Set<string>>;
21
21
  /** @public 현재 필터(GenOptions) 조건으로 만들 수 있는 정답 단어 수.
22
- * fullCount로 샘플링 캡을 우회해 실제 가능 단어 수를 그대로 센다(무작위 왜곡 방지). */
22
+ * fullCount로 샘플링 캡을 우회해 실제 가능 단어 수를 그대로 센다(무작위 왜곡 방지).
23
+ * 보기형 생성과 같게 **서로 소리가 다른 오답이 보기 수(choicesPerItem, 기본 3) − 1 개** 있는 낱말만 센다. */
23
24
  export declare function countUsableWords(opts: GenOptions): Promise<number>;
24
25
  /** @public 위치별(초성/중성/종성) 선택 가능한 자모 set + 음절 수 set — 실제 단어에 존재하는 값만.
25
26
  *