@ihabdevteam/core 0.146.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 (33) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/components.cjs.js +170 -170
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +47 -47
  6. package/dist/ihabdevteam-core.d.ts +22 -13
  7. package/dist/packages/core/src/components/Dropdown.js +2 -2
  8. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +3 -1
  9. package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -4
  10. package/dist/packages/core/src/components/Popup/Popup.js +1 -1
  11. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +3 -2
  12. package/dist/packages/core/src/components/Report/ReportDocument.js +5 -3
  13. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  14. package/dist/packages/core/src/components/Training/useNoiseFade.js +25 -7
  15. package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +2 -1
  16. package/dist/packages/core/src/components/Word/wordTablePrint.js +8 -1
  17. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +35 -7
  18. package/dist/packages/core/src/hooks/useTrainingSessionLog.d.ts +5 -2
  19. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +45 -8
  20. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -2
  21. package/dist/packages/core/src/utils/getTrainingData.d.ts +5 -1
  22. package/dist/packages/core/src/utils/getTrainingData.js +20 -4
  23. package/dist/packages/core/src/utils/planFeatures.d.ts +1 -1
  24. package/dist/packages/core/src/utils/planFeatures.js +7 -1
  25. package/dist/packages/core/src/utils/textSort.js +4 -2
  26. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +2 -2
  27. package/dist/packages/core/src/utils/wordTrainingGenerators.js +2 -2
  28. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +2 -1
  29. package/dist/packages/core/src/utils/wordTrainingRuntime.js +5 -2
  30. package/dist/packages/core/src/version.d.ts +1 -1
  31. package/dist/packages/core/src/version.js +1 -1
  32. package/dist/utils.cjs.js +2 -2
  33. package/package.json +1 -1
@@ -526,7 +526,7 @@ export declare function buildChangeWholeSyllableProblem(word: string, compFrom:
526
526
  * @public 합성어 부분 탈락 → [문제단어(합성어), 남는 실단어(정답), 그 단어의 대립쌍 실단어 오답 2].
527
527
  * 합성어 근사: 앞/뒤로 나눈 두 조각이 '둘 다 실단어'(wordSet)인 첫 조합을 채택(예: 손수건=손+수건).
528
528
  * 정답·오답 전부 실단어라 발음이 그대로. comp(훈련음소) 지정 시 남는 단어의 그 자리를 대립, 없으면 전 음절.
529
- * dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 편집기·런타임 공용.
529
+ * dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 결과의 `dropped` 에 뺀 조각을 담는다. 편집기·런타임 공용.
530
530
  */
531
531
  export declare function buildDropCompoundProblem(word: string, comp: CompositionFilter | null, pronMap: Map<string, string>, phonemeView: 'char' | 'pron', wordSet: Set<string>, dropPos?: number | null): TrainingProblem | null;
532
532
 
@@ -1254,7 +1254,9 @@ export declare interface ConfirmPopupProps {
1254
1254
  };
1255
1255
  /**
1256
1256
  * 확인 클릭 시 실행. `close`로 직접 닫을 수 있음(async 지원).
1257
- * 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm).
1257
+ * 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm). 받은 `close()` 를 부르면 그것도 닫힘으로 센다(닫히는 동안 잠금 유지).
1258
+ * 던지거나 거절되면 창이 남고 잠금이 풀린다 — 거절은 console.error 로 받는다.
1259
+ * 안에서 부모가 창을 치우면 그 뒤 onClose 는 불리지 않는다.
1258
1260
  */
1259
1261
  onConfirm: (close: () => void) => void | Promise<void>;
1260
1262
  /** false면 자동으로 닫지 않음 — onConfirm이 전달받은 close로 닫힘 시점을 제어 (성공 시에만 닫기 등). 기본 true */
@@ -1386,7 +1388,7 @@ export declare function copyText(text: string): Promise<boolean>;
1386
1388
  *
1387
1389
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1388
1390
  */
1389
- export declare const CORE_VERSION = "0.146.0";
1391
+ export declare const CORE_VERSION = "0.147.0";
1390
1392
 
1391
1393
  /** @public */
1392
1394
  declare const coreDefault: Record<string, unknown>;
@@ -1929,7 +1931,11 @@ export declare interface EmptyStateProps {
1929
1931
  className?: string;
1930
1932
  }
1931
1933
 
1932
- export declare function ensureTrainingDataLoaded(timeout?: number): Promise<unknown>;
1934
+ /**
1935
+ * 자료를 받게 하고 **받았는지** 를 돌려준다(시간 초과·실패는 false). 전에는 성공·3초 초과·실패가 모두 undefined 로 끝나
1936
+ * 부르는 쪽이 빈 풀로 계획을 만들고도 알 길이 없었다. 늦게 도착하면 'speechear.data.updated' 가 온다.
1937
+ */
1938
+ export declare function ensureTrainingDataLoaded(timeout?: number): Promise<boolean>;
1933
1939
 
1934
1940
  export declare function ErrorState({ message, hint, icon, onRetry, retryLabel, className }: ErrorStateProps): JSX.Element;
1935
1941
 
@@ -2364,7 +2370,7 @@ export declare function getPairIndex(): PairIndex | null;
2364
2370
  export declare function getPayloadTimestamp(payload: unknown): number;
2365
2371
 
2366
2372
  /** product code → 요금제 티어 */
2367
- export declare function getPlanTier(productCode: string): PlanTier;
2373
+ export declare function getPlanTier(productCode: string | null | undefined): PlanTier;
2368
2374
 
2369
2375
  /** 레지스트리에서 theme 조회 */
2370
2376
  export declare function getPromoBannerBgTheme(el: Element | null): PromoBannerBgTheme | undefined;
@@ -4179,8 +4185,8 @@ export declare interface PopupProps {
4179
4185
  *
4180
4186
  * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
4181
4187
  * onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
4182
- * 거기서 닫기를 취소하면 창이 한 번 사라졌다가 되살아난다(animateThen 은 콜백 뒤에도 창이
4183
- * 남아 있으면 되살린다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
4188
+ * 거기서 닫기를 취소하면(열림 상태를 끄지 않으면) 창이 닫힌 모양으로 남는다(animateThen 은 약속을 돌려준
4189
+ * 콜백만 되살린다 — onClose 같은 동기 콜백 뒤에는 되살리지 않는다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
4184
4190
  *
4185
4191
  * 미지정이면 묻지 않고 닫는다.
4186
4192
  */
@@ -4195,8 +4201,9 @@ export declare interface PopupProps {
4195
4201
  closeOnBackdrop?: boolean;
4196
4202
  className?: string;
4197
4203
  /**
4198
- * 다이얼로그 접근명 — `title`이 없는 팝업(useTitle=false 등)에서 쓴다.
4199
- * title이 있으면 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
4204
+ * 다이얼로그 접근명 — 제목을 그리지 않는 팝업(`title` 이 없거나 useTitle=false)에서 쓴다.
4205
+ * 제목을 그리면(useTitle 이고 title 이 있으면) 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
4206
+ * useTitle=false 인데 이 값이 없으면 글자 `title` 을 이름으로 쓴다.
4200
4207
  * (없으면 스크린리더가 "대화상자"라고만 읽어 무슨 창인지 알 수 없다.)
4201
4208
  */
4202
4209
  ariaLabel?: string;
@@ -4790,7 +4797,7 @@ export declare interface ReportBlock {
4790
4797
  /**
4791
4798
  * 쪽을 나눌 때 한 덩어리로 다룰 묶음. 표 머리와 그 행들처럼 갈라지면 읽을 수
4792
4799
  * 없는 것들에 같은 값을 준다 — 남은 자리가 모자라면 통째로 다음 장으로 간다.
4793
- * 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다.
4800
+ * 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다. `section` 이 다르면 같은 값이어도 한 묶음이 아니다.
4794
4801
  */
4795
4802
  group?: string;
4796
4803
  }
@@ -4803,7 +4810,8 @@ export declare interface ReportBlock {
4803
4810
  * 바닥(로고·저작권 + 쪽 번호)이 반복된다.
4804
4811
  *
4805
4812
  * 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
4806
- * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이 바뀔 때와 처음 그릴 때뿐이다 —
4813
+ * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이나 장 규격(`pageWidth`·`pageHeight`·`margin`)이 바뀔 때와
4814
+ * 처음 그릴 때(숨겨져 있다 보이게 될 때 포함)뿐이다 —
4807
4815
  * 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
4808
4816
  * 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
4809
4817
  * `key` 를 바꿔 새로 붙인다.
@@ -6113,7 +6121,7 @@ export declare function syllablePron(ch: string): string;
6113
6121
  /** composition.syllable 선택값을 실제 매칭용 음절 수 배열로 변환 (SYLLABLE_MIN 이상은 범위로 확장) */
6114
6122
  export declare function syllableToFilter(syllable: number): number[];
6115
6123
 
6116
- /** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
6124
+ /** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). `jongNone` 이면 받침 없는 음절만 맞고 `jong` 은 보지 않는다(풀 필터와 같게). */
6117
6125
  export declare function syllMatchesComp(s: Syllable, comp: CompositionFilter): boolean;
6118
6126
 
6119
6127
  /** @public */
@@ -8426,7 +8434,8 @@ export declare interface UseWordTablePrintOptions {
8426
8434
 
8427
8435
  /** @public */
8428
8436
  export declare interface UseWordTablePrintResult {
8429
- /** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관). */
8437
+ /** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관). 인쇄가 진행 중이면(10초 한도) 다시 불러도 받지 않는다.
8438
+ * 세 동작 모두 문서는 늘 light 테마(종이가 흰색). */
8430
8439
  print: () => void;
8431
8440
  /** 새 탭에 단어표를 열어 사용자가 직접 인쇄/저장하게 한다. */
8432
8441
  openNewWindow: () => void;
@@ -125,8 +125,8 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
125
125
  e.preventDefault();
126
126
  if (options[activeIdx])
127
127
  select(options[activeIdx]);
128
- else if (e.key === ' ')
129
- setOpen(false);
128
+ else
129
+ setOpen(false); // Enter 도 — 가리킨 것 없이 기본 동작만 막으면 목록이 열린 채 아무 일도 없었다
130
130
  }
131
131
  }, [disabled, open, options, activeIdx, select, setOpen]);
132
132
  const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", "data-tooltip-ignore": true, role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave,
@@ -50,7 +50,9 @@ export interface ConfirmPopupProps {
50
50
  };
51
51
  /**
52
52
  * 확인 클릭 시 실행. `close`로 직접 닫을 수 있음(async 지원).
53
- * 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm).
53
+ * 기본은 onConfirm 완료 후 자동으로 닫힘(closeOnConfirm). 받은 `close()` 를 부르면 그것도 닫힘으로 센다(닫히는 동안 잠금 유지).
54
+ * 던지거나 거절되면 창이 남고 잠금이 풀린다 — 거절은 console.error 로 받는다.
55
+ * 안에서 부모가 창을 치우면 그 뒤 onClose 는 불리지 않는다.
54
56
  */
55
57
  onConfirm: (close: () => void) => void | Promise<void>;
56
58
  /** false면 자동으로 닫지 않음 — onConfirm이 전달받은 close로 닫힘 시점을 제어 (성공 시에만 닫기 등). 기본 true */
@@ -40,8 +40,8 @@ export interface PopupProps {
40
40
  *
41
41
  * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
42
42
  * onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
43
- * 거기서 닫기를 취소하면 창이 한 번 사라졌다가 되살아난다(animateThen 은 콜백 뒤에도 창이
44
- * 남아 있으면 되살린다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
43
+ * 거기서 닫기를 취소하면(열림 상태를 끄지 않으면) 창이 닫힌 모양으로 남는다(animateThen 은 약속을 돌려준
44
+ * 콜백만 되살린다 — onClose 같은 동기 콜백 뒤에는 되살리지 않는다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
45
45
  *
46
46
  * 미지정이면 묻지 않고 닫는다.
47
47
  */
@@ -56,8 +56,9 @@ export interface PopupProps {
56
56
  closeOnBackdrop?: boolean;
57
57
  className?: string;
58
58
  /**
59
- * 다이얼로그 접근명 — `title`이 없는 팝업(useTitle=false 등)에서 쓴다.
60
- * title이 있으면 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
59
+ * 다이얼로그 접근명 — 제목을 그리지 않는 팝업(`title` 이 없거나 useTitle=false)에서 쓴다.
60
+ * 제목을 그리면(useTitle 이고 title 이 있으면) 그쪽이 `aria-labelledby`로 연결되므로 이 값은 무시된다.
61
+ * useTitle=false 인데 이 값이 없으면 글자 `title` 을 이름으로 쓴다.
61
62
  * (없으면 스크린리더가 "대화상자"라고만 읽어 무슨 창인지 알 수 없다.)
62
63
  */
63
64
  ariaLabel?: string;
@@ -109,7 +109,7 @@ export default function Popup(props) {
109
109
  /* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
110
110
  inline 으로 쓸 때는 다이얼로그가 아니라 걸지 않는다(예전과 같다). */
111
111
  useFocusTrap(panelRef, !inline);
112
- /* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 콜백 뒤에도 이 창이 그대로 있으면 되살린다** —
112
+ /* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 약속을 돌려준 콜백이 끝난 뒤에도 이 창이 그대로 있으면 되살린다** —
113
113
  전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
114
114
  없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
115
115
  const 살아있음Ref = useRef(true);
@@ -15,7 +15,7 @@ export interface ReportBlock {
15
15
  /**
16
16
  * 쪽을 나눌 때 한 덩어리로 다룰 묶음. 표 머리와 그 행들처럼 갈라지면 읽을 수
17
17
  * 없는 것들에 같은 값을 준다 — 남은 자리가 모자라면 통째로 다음 장으로 간다.
18
- * 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다.
18
+ * 묶음 하나가 한 장보다 크면 어쩔 수 없이 블록 단위로 쪼갠다. `section` 이 다르면 같은 값이어도 한 묶음이 아니다.
19
19
  */
20
20
  group?: string;
21
21
  }
@@ -54,7 +54,8 @@ export interface ReportDocumentProps {
54
54
  * 바닥(로고·저작권 + 쪽 번호)이 반복된다.
55
55
  *
56
56
  * 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
57
- * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이 바뀔 때와 처음 그릴 때뿐이다 —
57
+ * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이나 장 규격(`pageWidth`·`pageHeight`·`margin`)이 바뀔 때와
58
+ * 처음 그릴 때(숨겨져 있다 보이게 될 때 포함)뿐이다 —
58
59
  * 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
59
60
  * 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
60
61
  * `key` 를 바꿔 새로 붙인다.
@@ -15,7 +15,8 @@ const DEFAULT_PAGE_LABEL = (page, total) => `${page} / ${total}`;
15
15
  * 바닥(로고·저작권 + 쪽 번호)이 반복된다.
16
16
  *
17
17
  * 재는 방식이라 글꼴이 늦게 오거나 이미지가 나중에 뜨면 쪽이 밀릴 수 있다.
18
- * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이 바뀔 때와 처음 그릴 때뿐이다 —
18
+ * 다시 재는 때는 블록들의 `id`·`section`·`group` 구성이나 장 규격(`pageWidth`·`pageHeight`·`margin`)이 바뀔 때와
19
+ * 처음 그릴 때(숨겨져 있다 보이게 될 때 포함)뿐이다 —
19
20
  * 같은 구성으로 다시 그리거나 `content` 만 바뀌어서는 다시 재지 않는다(렌더마다 재면
20
21
  * 재기 → 쪽 나눔 → 렌더가 끝없이 돈다). 글꼴·이미지가 다 온 뒤 다시 재야 하면
21
22
  * `key` 를 바꿔 새로 붙인다.
@@ -134,12 +135,13 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
134
135
  if (cur.length && blocks[cur[0]].section !== blocks[grp[0]].section)
135
136
  flush();
136
137
  // 덩어리 전체가 남은 자리에 안 들어가면 통째로 다음 장으로.
137
- if (cur.length && used + groupH > budget)
138
+ // 소수 높이는 배율(transform: scale) 안에서 요소마다 1/64px 안팎 오차가 붙는다 — 딱 찬 결과지가 두 장이 되지 않게 조금 봐준다
139
+ if (cur.length && used + groupH > budget + 0.01)
138
140
  flush();
139
141
  if (groupH > budget) {
140
142
  // 덩어리 하나가 한 장보다 크면 쪼갤 수밖에 없다.
141
143
  grp.forEach((i) => {
142
- if (cur.length && used + heights[i] > budget)
144
+ if (cur.length && used + heights[i] > budget + 0.01)
143
145
  flush();
144
146
  cur.push(i);
145
147
  used += heights[i];
@@ -51,13 +51,13 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
51
51
  새 함수 → 알림 이 끝없이 돈다. 부모가 어떻게 짜든 안 돌게 한다. */
52
52
  const notifyRef = useRef(onScoreChange);
53
53
  useEffect(() => { notifyRef.current = onScoreChange; });
54
- // 처음 열 때는 알리지 않는다 — 아무것도 안 매겼는데 onScoreChange({}) 가 나갔다
55
- const 첫알림Ref = useRef(true);
54
+ /* 매긴 것이 **바뀌었을 때만** 알린다 — 처음 열 때 onScoreChange({}) 가 나갔다. '첫 실행 건너뛰기' 는 StrictMode 의 두 번째 실행에서
55
+ 새어 이전 값과 견준다. */
56
+ const 알린marksRef = useRef(marks);
56
57
  useEffect(() => {
57
- if (첫알림Ref.current) {
58
- 첫알림Ref.current = false;
58
+ if (알린marksRef.current === marks)
59
59
  return;
60
- }
60
+ 알린marksRef.current = marks;
61
61
  notifyRef.current?.(marksForSave(marks));
62
62
  }, [marks]);
63
63
  /* 표에는 지운 칸(null)이 빠진 것만 넘긴다 — 표는 "매겨진 것"만 알면 된다. */
@@ -240,6 +240,9 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
240
240
  ? { enabled: noisePlayer.settings.enabled, volume: noiseVolume, type: noisePlayer.settings.type }
241
241
  : null;
242
242
  }, [noisePlayer, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type]);
243
+ /* 새 소음 파일의 canplay 를 기다리는 핸들러 — 다음 종류 변경·떠날 때 뗀다(옛 파일 기다림이 새 설정 위에서 울리지 않게) */
244
+ const canplay떼기Ref = useRef(null);
245
+ useEffect(() => () => { canplay떼기Ref.current?.(); canplay떼기Ref.current = null; }, []);
243
246
  // 소음 설정 변경 → 오디오 반영
244
247
  useEffect(() => {
245
248
  const ns = noisePlayer?.settings;
@@ -267,14 +270,23 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
267
270
  noiseEl.load();
268
271
  const mainAudio = audioRef.current;
269
272
  if (mainAudio && !mainAudio.paused) {
270
- noiseEl.addEventListener('canplay', function h() {
273
+ canplay떼기Ref.current?.();
274
+ const h = () => {
271
275
  noiseEl.removeEventListener('canplay', h);
276
+ if (canplay떼기Ref.current === 떼기)
277
+ canplay떼기Ref.current = null;
272
278
  // 파일 로드가 끝난 "이 시점"에 메인이 여전히 재생 중인지 재확인 —
273
279
  // 로드가 느리면 예약 시점과 실행 시점 사이에 메인이 끝났을 수 있다.
280
+ /* 볼륨·켜짐은 **지금** 설정으로 — 효과 때 붙잡은 값으로 켜면, 불러오는 동안 치료사가 비율을 0.3 으로 내렸거나 소음을
281
+ 껐어도 canplay 에서 1 로 다시 켜져 끝까지 났다(난이도가 뜻보다 어려워지고 기록과 들린 소리가 어긋남). */
274
282
  const cur = audioRef.current;
275
- if (cur && !cur.paused && !cur.ended)
276
- fadeInNoise(volume);
277
- });
283
+ const 지금 = noiseSettingsRef.current;
284
+ if (cur && !cur.paused && !cur.ended && 지금?.enabled && 지금.volume > 0)
285
+ fadeInNoise(지금.volume);
286
+ };
287
+ const 떼기 = () => noiseEl.removeEventListener('canplay', h);
288
+ canplay떼기Ref.current = 떼기;
289
+ noiseEl.addEventListener('canplay', h);
278
290
  }
279
291
  });
280
292
  }
@@ -286,8 +298,14 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
286
298
  // — 그때 noiseEl이 아직(레이스 등으로) paused가 아니면 볼륨만 올려버려 소음이 계속
287
299
  // 나오는 버그가 있었다. 메인이 재생 중이 아니면 무조건 정지시킨다.
288
300
  const mainAudio = audioRef.current;
289
- const mainPlaying = !!mainAudio && !mainAudio.paused && !mainAudio.ended;
290
- if (mainPlaying && noiseEl.paused && noiseEl.src) {
301
+ // speechSynthesis TTS 경로는 메인 <audio> 없이 재생된다 — 감시 타이머처럼 playbackState 도 본다(안 보면 비율만 바꿔도 소음이 멈췄다)
302
+ const mainPlaying = (!!mainAudio && !mainAudio.paused && !mainAudio.ended) || playbackStateRef.current === 'playing';
303
+ if (mainPlaying && pendingNoiseUrlRef.current) {
304
+ /* 종류를 바꾼 뒤 150ms 페이드아웃 안에 다른 설정을 바꾸면, 아래 볼륨만 옮기는 갈래가 파일을 바꿀 페이드를 지워 화면의 종류와
305
+ 들리는 종류가 문장 끝까지 달랐다 — 기다리는 파일이 있으면 fadeInNoise 가 그것을 끼운다. */
306
+ fadeInNoise(volume);
307
+ }
308
+ else if (mainPlaying && noiseEl.paused && noiseEl.src) {
291
309
  fadeInNoise(volume);
292
310
  }
293
311
  else if (!noiseEl.paused) {
@@ -315,7 +333,7 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
315
333
  }
316
334
  }
317
335
  }
318
- }, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef]);
336
+ }, [noisePlayer?.settings, noisePlayer?.settings.enabled, noiseVolume, noisePlayer?.settings.type, cancelNoiseFade, fadeInNoise, fadeOutNoise, audioRef, playbackStateRef, noiseSettingsRef]);
319
337
  // 소음 재생 속도(playbackRate) 반영 — 0.75~1.25. 엘리먼트 속성이라 src 변경에도 유지됨
320
338
  useEffect(() => {
321
339
  const el = noiseAudioRef.current;
@@ -15,7 +15,8 @@ export interface UseWordTablePrintOptions {
15
15
  }
16
16
  /** @public */
17
17
  export interface UseWordTablePrintResult {
18
- /** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관). */
18
+ /** 화면 밖 iframe에서 격리 인쇄(전역 print CSS·팝업 차단 무관). 인쇄가 진행 중이면(10초 한도) 다시 불러도 받지 않는다.
19
+ * 세 동작 모두 문서는 늘 light 테마(종이가 흰색). */
19
20
  print: () => void;
20
21
  /** 새 탭에 단어표를 열어 사용자가 직접 인쇄/저장하게 한다. */
21
22
  openNewWindow: () => void;
@@ -105,7 +105,14 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
105
105
  아무도 opacity 를 되돌리지 않아 **빈칸으로 인쇄**되고, 새 창·내려받은 HTML 에서는 스피너가 영영 돈다(말귀 내 단어표
106
106
  '인쇄' 는 열자마자 100ms 뒤 인쇄해 처음 보는 표는 거의 늘 이랬다). */
107
107
  clone.querySelectorAll('.word-table-cell__spinner').forEach(s => s.remove());
108
- clone.querySelectorAll('img').forEach(i => i.style.removeProperty('opacity'));
108
+ /* 불러오는 중이던 그림은 opacity 를 **그대로 두고** 받아지면 보이고 실패하면 숨게 한다 — 지우기만 하면 없는 그림(404, 화면에서는
109
+ onError 로 칸이 사라짐)이 인쇄 문서에서 깨진 그림 아이콘과 alt 글자로 찍혔다. autoPrint 는 그림 로드를 기다린 뒤 인쇄한다. */
110
+ clone.querySelectorAll('img').forEach(i => {
111
+ if (i.style.opacity !== '0')
112
+ return;
113
+ i.setAttribute('onload', "this.style.opacity=''");
114
+ i.setAttribute('onerror', "this.style.visibility='hidden'");
115
+ });
109
116
  return `<div class="paper-wrap">${headHtml}${clone.outerHTML}</div>`;
110
117
  }).join('\n');
111
118
  /* 이 꾸러미의 CSS 는 전부 `.ihabdevteam-core-scope` **아래**에 산다(styles.css 스코핑).
@@ -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, 오늘]);
@@ -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'))