@ihabdevteam/core 0.134.0 → 0.136.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 (82) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +19 -3
  8. package/dist/packages/core/src/components/Alert.js +1 -1
  9. package/dist/packages/core/src/components/BarChart.js +3 -1
  10. package/dist/packages/core/src/components/Button.css +5 -0
  11. package/dist/packages/core/src/components/Button.js +3 -3
  12. package/dist/packages/core/src/components/Checkbox.js +10 -2
  13. package/dist/packages/core/src/components/CodeBox.js +2 -1
  14. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +2 -0
  15. package/dist/packages/core/src/components/IconGridMenu.js +25 -22
  16. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
  17. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
  18. package/dist/packages/core/src/components/Popup/ListPopup.js +10 -3
  19. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +6 -5
  20. package/dist/packages/core/src/components/Popup/Popup.css +9 -0
  21. package/dist/packages/core/src/components/Popup/Popup.d.ts +9 -1
  22. package/dist/packages/core/src/components/Popup/Popup.js +2 -2
  23. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
  24. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
  25. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +5 -3
  26. package/dist/packages/core/src/components/ProgressBar.js +5 -2
  27. package/dist/packages/core/src/components/ProgressIndicator.js +6 -1
  28. package/dist/packages/core/src/components/Rating.js +3 -1
  29. package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
  30. package/dist/packages/core/src/components/RingChart.js +14 -3
  31. package/dist/packages/core/src/components/SideNav.js +6 -3
  32. package/dist/packages/core/src/components/SplashLogo.js +1 -2
  33. package/dist/packages/core/src/components/StatChart.js +1 -1
  34. package/dist/packages/core/src/components/Table.js +15 -5
  35. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +6 -5
  36. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +57 -0
  37. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +8 -4
  38. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
  39. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +2 -1
  40. package/dist/packages/core/src/components/Training/TrainingStepCard.js +4 -3
  41. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +36 -26
  42. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
  43. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +7 -4
  44. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -1
  45. package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
  46. package/dist/packages/core/src/components/Training/WordTablePopup.js +9 -3
  47. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +3 -1
  48. package/dist/packages/core/src/components/Training/trainingMissStats.js +9 -2
  49. package/dist/packages/core/src/components/Word/WordTableBlock.js +7 -3
  50. package/dist/packages/core/src/components/Word/WordTableButton.js +3 -0
  51. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +5 -1
  52. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -2
  53. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -2
  54. package/dist/packages/core/src/components/barChartData.d.ts +8 -0
  55. package/dist/packages/core/src/components/barChartData.js +21 -0
  56. package/dist/packages/core/src/components/buttonTypes.d.ts +6 -0
  57. package/dist/packages/core/src/components/useBarChartControl.js +5 -3
  58. package/dist/packages/core/src/components/useCalendarMonth.js +15 -17
  59. package/dist/packages/core/src/components/useToastLifecycle.js +7 -2
  60. package/dist/packages/core/src/context/I18nProvider.js +12 -3
  61. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
  62. package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
  63. package/dist/packages/core/src/hooks/useEditableList.js +20 -1
  64. package/dist/packages/core/src/hooks/useSoundCompassExam.js +13 -2
  65. package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
  66. package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
  67. package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
  68. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
  69. package/dist/packages/core/src/i18n/locales/en.json +3 -1
  70. package/dist/packages/core/src/i18n/locales/ko.json +3 -1
  71. package/dist/packages/core/src/utils/date.js +26 -5
  72. package/dist/packages/core/src/utils/getTrainingData.js +11 -4
  73. package/dist/packages/core/src/utils/imageConfig.js +6 -3
  74. package/dist/packages/core/src/utils/speak.js +23 -3
  75. package/dist/packages/core/src/utils/textSort.js +12 -1
  76. package/dist/packages/core/src/utils/ttsConfig.js +10 -8
  77. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
  78. package/dist/packages/core/src/utils/wordTrainingRuntime.js +11 -4
  79. package/dist/packages/core/src/version.d.ts +1 -1
  80. package/dist/packages/core/src/version.js +1 -1
  81. package/dist/utils.cjs.js +2 -2
  82. package/package.json +1 -1
@@ -43,6 +43,10 @@ export function useTablePaging(rows, resetOn, opts) {
43
43
  대부분의 경우 빈 표가 된다 */
44
44
  const setPageSize = useCallback((v) => { setPageSizeRaw(v); setPage(1); }, []);
45
45
  const total = Math.max(1, Math.ceil(rows.length / Number(pageSize)));
46
+ /* 행이 줄어 범위를 넘으면 **상태도** 끝 쪽으로 맞춘다 — 보이는 쪽만 조이고 상태를 두면, 필터로 줄었다가 다시 늘 때 예전
47
+ 5쪽으로 튀었다. 렌더 중에 맞춘다(위 resetOn 과 같은 꼴). */
48
+ if (page > total)
49
+ setPage(total);
46
50
  const 이쪽 = Math.min(page, total);
47
51
  const pageRows = useMemo(() => rows.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize)), [rows, 이쪽, pageSize]);
48
52
  return {
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { useEditableList } from './useEditableList';
2
+ import { useEditableList, isSameEditableItem } from './useEditableList';
3
3
  /**
4
4
  * @public
5
5
  */
@@ -24,7 +24,7 @@ export function useVisitHistory(initialHistory = []) {
24
24
  closeSubPopup?.();
25
25
  };
26
26
  const handleVisitEditSave = () => {
27
- commitEdit(prev => prev.map(v => v === selectedVisit ? editVisitForm : v), editVisitForm);
27
+ commitEdit(prev => prev.map(v => (selectedVisit != null && isSameEditableItem(v, selectedVisit)) ? editVisitForm : v), editVisitForm);
28
28
  };
29
29
  /** 외부(스케줄 훅 등)에서 방문이력에 항목을 추가할 때 사용 */
30
30
  const addVisit = (item) => {
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useMemo, useState } from 'react';
3
3
  import { today } from './useVisitHistory';
4
- import { useEditableList } from './useEditableList';
4
+ import { useEditableList, isSameEditableItem } from './useEditableList';
5
5
  /**
6
6
  * 차례를 정하는 열쇠 — **가까운 것이 위**.
7
7
  *
@@ -48,17 +48,18 @@ initialSchedule = []) {
48
48
  closeSubPopup?.();
49
49
  };
50
50
  const handleScheduleEditSave = () => {
51
- commitEdit(prev => sortByDate(prev.map(v => v === selectedSchedule ? editScheduleForm : v)), editScheduleForm);
51
+ commitEdit(prev => sortByDate(prev.map(v => (selectedSchedule != null && isSameEditableItem(v, selectedSchedule)) ? editScheduleForm : v)), editScheduleForm);
52
52
  };
53
53
  const handleScheduleDelete = useCallback((item) => {
54
54
  remove(item);
55
55
  }, [remove]);
56
56
  const handleScheduleToVisit = useCallback((item) => {
57
- addVisit({ year: item.year, month: item.month, day: item.day, hour: item.hour, minute: item.minute, memo: item.memo });
57
+ // id 도 넘긴다 — 빠지면 그 방문을 이어서 수정할 때 합성 키로 넘어가 서버가 행을 못 찾았다
58
+ addVisit({ year: item.year, month: item.month, day: item.day, hour: item.hour, minute: item.minute, memo: item.memo, ...(item.id != null ? { id: item.id } : {}) });
58
59
  remove(item);
59
60
  }, [addVisit, remove]);
60
61
  const handleScheduleNoVisit = useCallback((item) => {
61
- setScheduleList(prev => prev.filter(v => v !== item));
62
+ setScheduleList(prev => prev.filter(v => !isSameEditableItem(v, item)));
62
63
  setNoShowList(prev => [item, ...prev]);
63
64
  setSelectedSchedule(null);
64
65
  }, [setScheduleList, setSelectedSchedule]);
@@ -23,26 +23,35 @@ import { useState, useEffect, useMemo } from 'react';
23
23
  export const meaningKey = (word, homonum) => `${word}:${homonum ?? 1}`;
24
24
  // 세션 동안 유지되는 메모리 캐시 — 페이지를 오가도 재조회하지 않는다
25
25
  const memCache = new Map();
26
+ /* 받아 봤는데 뜻이 없던 열쇠 — 다시 묻지 않는다. 전에는 뜻 없는 낱말이 캐시에 안 남아 표를 다시 정렬하거나 쪽을
27
+ 넘길 때마다 같은 낱말을 또 조회했다. (조회가 실패한 것은 여기 넣지 않는다 — 다음에 다시 받는다.) */
28
+ const noMeaning = new Set();
26
29
  export function useWordMeanings(rows, fetchMeanings) {
27
30
  /* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
28
31
  올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
29
32
  const [fetched, setFetched] = useState(0);
30
33
  // rows 는 매 렌더 새 배열이라 의존성으로 쓸 수 없다 — 조회 키만 뽑아 문자열로 비교한다
31
- const wantedKey = rows.map(r => meaningKey(r.word, r.homonum)).join('|');
34
+ // 차례는 조회와 무관하다 — 정렬해 두어야 같은 낱말들을 다시 정렬만 해도 새 조회로 보지 않는다
35
+ const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort().join('|');
32
36
  useEffect(() => {
33
37
  const wanted = wantedKey ? wantedKey.split('|') : [];
34
- const missing = wanted.filter(k => !memCache.has(k));
38
+ const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k));
35
39
  if (missing.length === 0)
36
40
  return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
37
41
  let cancelled = false;
38
42
  const words = Array.from(new Set(missing.map(k => k.slice(0, k.lastIndexOf(':')))));
39
43
  void fetchMeanings(words).then(got => {
40
- if (cancelled || got.length === 0)
41
- return;
44
+ /* 받은 것은 **취소됐어도 캐시에 담는다** — 쪽을 빨리 넘기면 앞 조회가 취소되며 받은 결과까지 버려, 돌아오면 또
45
+ 조회했다. 화면 갱신(setFetched)만 취소된 쪽에서 건너뛴다. */
42
46
  for (const row of got) {
43
47
  if (row.Meaning)
44
48
  memCache.set(meaningKey(row.Word, row.Homonum ?? undefined), row.Meaning);
45
49
  }
50
+ for (const k of missing)
51
+ if (!memCache.has(k))
52
+ noMeaning.add(k);
53
+ if (cancelled || got.length === 0)
54
+ return;
46
55
  setFetched(v => v + 1);
47
56
  }).catch(() => {
48
57
  // 조회 함수가 약속을 어기고 거절해도(망 끊김 등) 처리되지 않은 거절로 새지 않게 삼킨다 —
@@ -376,6 +376,7 @@
376
376
  "listenButton": "Listen",
377
377
  "playSound": "Play Sound",
378
378
  "playAll": "Play All",
379
+ "selectInOrder": "Select the cards in the order you heard.",
379
380
  "stop": "Stop",
380
381
  "preview": "Preview",
381
382
  "next": "Next",
@@ -742,7 +743,8 @@
742
743
  "descriptionPlaceholder": "Enter a description for this word table.",
743
744
  "duplicateName": "This name is already in use.",
744
745
  "nameRequired": "Please enter a table name.",
745
- "autoFillHint": " (Press Tab or Space to autofill)"
746
+ "autoFillHint": " (Press Tab or Space to autofill)",
747
+ "copyName": "{{name}} (copy)"
746
748
  },
747
749
  "loadTable": {
748
750
  "title": "Load Saved Word Table",
@@ -374,6 +374,7 @@
374
374
  "listenButton": "문장 듣기",
375
375
  "playSound": "소리 듣기",
376
376
  "playAll": "모두 듣기",
377
+ "selectInOrder": "들은 순서대로 카드를 선택하세요.",
377
378
  "stop": "정지",
378
379
  "preview": "미리 듣기",
379
380
  "next": "다음",
@@ -740,7 +741,8 @@
740
741
  "descriptionPlaceholder": "이 단어표에 대한 설명을 입력하세요.",
741
742
  "duplicateName": "이미 사용 중인 이름입니다.",
742
743
  "nameRequired": "표 이름을 입력해 주세요.",
743
- "autoFillHint": " (Tab 또는 space 입력시 자동 입력)"
744
+ "autoFillHint": " (Tab 또는 space 입력시 자동 입력)",
745
+ "copyName": "{{name}} (복사)"
744
746
  },
745
747
  "loadTable": {
746
748
  "title": "저장된 단어 표 불러오기",
@@ -16,12 +16,24 @@
16
16
  * 영어로 보더라도 가리키는 날은 같아야 한다.
17
17
  */
18
18
  const KST = 'Asia/Seoul';
19
+ /* 못 읽는 값(빈 문자열·깨진 날짜)이면 null — 아래 형식 함수들이 화면에 "Invalid Date" 를 그대로 내던 것을 빈 글자로 막는다
20
+ (같은 파일의 toKstYmd·formatYmd 는 이미 막고 있었다) */
21
+ const 읽은날 = (iso) => {
22
+ const d = new Date(iso);
23
+ return Number.isNaN(d.getTime()) ? null : d;
24
+ };
19
25
  export function formatDateKo(iso) {
20
- return new Date(iso).toLocaleDateString('ko-KR', { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
26
+ const d = 읽은날(iso);
27
+ if (!d)
28
+ return '';
29
+ return d.toLocaleDateString('ko-KR', { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
21
30
  }
22
31
  /** "2024년 1월 15일 오후 03:30" (long month + time) */
23
32
  export function formatDateLongKo(iso) {
24
- return new Date(iso).toLocaleString('ko-KR', {
33
+ const d = 읽은날(iso);
34
+ if (!d)
35
+ return '';
36
+ return d.toLocaleString('ko-KR', {
25
37
  timeZone: KST,
26
38
  year: 'numeric', month: 'long', day: 'numeric',
27
39
  hour: '2-digit', minute: '2-digit',
@@ -35,12 +47,18 @@ export function formatDateLongKo(iso) {
35
47
  */
36
48
  export function formatDateLocale(iso, lang) {
37
49
  const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
38
- return new Date(iso).toLocaleDateString(locale, { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
50
+ const d = 읽은날(iso);
51
+ if (!d)
52
+ return '';
53
+ return d.toLocaleDateString(locale, { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
39
54
  }
40
55
  /** formatDateLongKo의 로케일 인식 버전 — en: "January 15, 2024, 03:30 PM" */
41
56
  export function formatDateLongLocale(iso, lang) {
42
57
  const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
43
- return new Date(iso).toLocaleString(locale, {
58
+ const d = 읽은날(iso);
59
+ if (!d)
60
+ return '';
61
+ return d.toLocaleString(locale, {
44
62
  timeZone: KST,
45
63
  year: 'numeric', month: 'long', day: 'numeric',
46
64
  hour: '2-digit', minute: '2-digit',
@@ -48,7 +66,10 @@ export function formatDateLongLocale(iso, lang) {
48
66
  }
49
67
  /** "2024. 01. 15. 오후 03:30" (2-digit month + time) */
50
68
  export function formatDatetimeKo(iso) {
51
- return new Date(iso).toLocaleString('ko-KR', {
69
+ const d = 읽은날(iso);
70
+ if (!d)
71
+ return '';
72
+ return d.toLocaleString('ko-KR', {
52
73
  timeZone: KST,
53
74
  year: 'numeric', month: '2-digit', day: '2-digit',
54
75
  hour: '2-digit', minute: '2-digit',
@@ -104,6 +104,9 @@ export async function loadTrainingData() {
104
104
  catch (e) { }
105
105
  return;
106
106
  }
107
+ /* 다 읽은 뒤에 한꺼번에 넣는다 — 도중에 형식 오류로 멈추면 이미 넣은 것이 남은 채 재시도가 또 넣어, 부를 때마다 풀에
108
+ 중복이 쌓였다(하루 선택이 중복 문항을 뽑고 기기마다 세트가 달라짐). */
109
+ const 받음 = { noise: [], short: [], long: [], order: [] };
107
110
  try {
108
111
  for (const key of Object.keys(map || {})) {
109
112
  const groups = map[key] || [];
@@ -115,16 +118,16 @@ export async function loadTrainingData() {
115
118
  // Preserve original group structure from allTrainingData.json
116
119
  const groupObj = Object.assign({}, gr, { training_type: t, category_0: cat0, category_1: cat1 });
117
120
  if (t === 'LISTEN_IN_NOISE') {
118
- NOISE_DATAS.push(groupObj);
121
+ 받음.noise.push(groupObj);
119
122
  }
120
123
  else if (t === 'SHORT_STORY') {
121
- SHORT_STORIES.push(groupObj);
124
+ 받음.short.push(groupObj);
122
125
  }
123
126
  else if (t === 'LONG_STORY') {
124
- LONG_STORIES.push(groupObj);
127
+ 받음.long.push(groupObj);
125
128
  }
126
129
  else if (t === 'ORDERING_SENTENCE') {
127
- ORDER_SETS.push(groupObj);
130
+ 받음.order.push(groupObj);
128
131
  }
129
132
  }
130
133
  }
@@ -142,6 +145,10 @@ export async function loadTrainingData() {
142
145
  catch { /* SSR 등 */ }
143
146
  return;
144
147
  }
148
+ NOISE_DATAS.push(...받음.noise);
149
+ SHORT_STORIES.push(...받음.short);
150
+ LONG_STORIES.push(...받음.long);
151
+ ORDER_SETS.push(...받음.order);
145
152
  try {
146
153
  window.dispatchEvent(new Event('speechear.data.updated'));
147
154
  }
@@ -60,13 +60,16 @@ export async function checkImageExists(word, homonum) {
60
60
  return false;
61
61
  // 타임아웃 없으면 HEAD 요청 하나가 응답 없이 걸릴 때 이 확인을 기다리는
62
62
  // 검색 버튼(이미지 있음 필터)이 영구 비활성화된다
63
+ /* **확인이 안 된 것은 캐시하지 않는다.** 망 끊김·타임아웃·5xx 까지 '이미지 없음' 으로 영구 저장해, 한 번 끊긴 단어는 망이
64
+ 돌아와도 '이미지 있음' 필터에서 계속 빠졌다(ttsConfig.checkAudioExists 와 같게). 있음(2xx)·없음(4xx)만 담는다. */
63
65
  const p = fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
64
- .then(res => res.ok)
65
- .catch(() => false)
66
+ .then(res => (res.status >= 500 ? null : res.ok), () => null)
66
67
  .then(ok => {
68
+ inflight.delete(k);
69
+ if (ok === null)
70
+ return false;
67
71
  map[k] = ok;
68
72
  scheduleSave();
69
- inflight.delete(k);
70
73
  return ok;
71
74
  });
72
75
  inflight.set(k, p);
@@ -1,3 +1,12 @@
1
+ /* 목소리 목록을 기다리는 말 — 하나만 둔다. 전에는 기다리는 리스너가 부를 때마다 줄을 서고 stopSpeaking 이 치우지 못해,
2
+ `speak('하나'); speak('둘'); stopSpeaking()` 뒤 목소리가 뜨는 순간 멈췄던 말이 모두 나왔다. */
3
+ let 기다림 = null;
4
+ /** voiceschanged 가 끝내 안 오는 환경(목소리 없음)에서도 말은 건다 — 안 그러면 onEnd 가 영영 안 불렸다 */
5
+ const 목소리기다림MS = 1500;
6
+ function 기다림치우기() {
7
+ 기다림?.치우기();
8
+ 기다림 = null;
9
+ }
1
10
  /** @public */
2
11
  export function speak(text, { onEnd, rate = 0.9 } = {}) {
3
12
  if (typeof window === 'undefined' || typeof SpeechSynthesisUtterance === 'undefined' || !('speechSynthesis' in window)) {
@@ -5,7 +14,9 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
5
14
  return;
6
15
  }
7
16
  const synth = window.speechSynthesis;
17
+ 기다림치우기();
8
18
  const doSpeak = () => {
19
+ 기다림치우기();
9
20
  synth.cancel();
10
21
  const u = new SpeechSynthesisUtterance(text);
11
22
  const voices = synth.getVoices();
@@ -23,13 +34,22 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
23
34
  : null;
24
35
  synth.speak(u);
25
36
  };
26
- if (synth.getVoices().length > 0)
37
+ if (synth.getVoices().length > 0) {
27
38
  doSpeak();
28
- else
29
- synth.addEventListener('voiceschanged', doSpeak, { once: true });
39
+ return;
40
+ }
41
+ const 시계 = setTimeout(doSpeak, 목소리기다림MS);
42
+ synth.addEventListener('voiceschanged', doSpeak, { once: true });
43
+ 기다림 = {
44
+ 치우기: () => {
45
+ clearTimeout(시계);
46
+ synth.removeEventListener('voiceschanged', doSpeak);
47
+ },
48
+ };
30
49
  }
31
50
  /** @public */
32
51
  export function stopSpeaking() {
52
+ 기다림치우기();
33
53
  if (typeof window !== 'undefined' && 'speechSynthesis' in window)
34
54
  window.speechSynthesis.cancel();
35
55
  }
@@ -33,6 +33,17 @@ export function 한글순(a, b) {
33
33
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
34
34
  */
35
35
  const 숫자글자 = /^-?\d+(\.\d+)?$/;
36
+ /* 칸의 글자 비교는 **안의 숫자도 수로** 본다 — 숫자 칸끼리는 수로, 섞인 칸은 글자로 견주면 '9'<'10'(수)·'10'<'10분'(글자)·
37
+ '10분'<'9'(글자) 로 돌고 돌아, 같은 행을 들어온 차례만 바꿔도 정렬 결과가 달랐다. 수를 수로 읽는 콜레이터는
38
+ 숫자끼리의 차례와 같은 쪽을 낸다. */
39
+ const 칸콜레이터 = (() => {
40
+ let 것 = null;
41
+ return () => {
42
+ if (!것)
43
+ 것 = new Intl.Collator('ko', { numeric: true });
44
+ return 것;
45
+ };
46
+ })();
36
47
  export function 칸순(a, b) {
37
48
  /* NaN 은 숫자가 아닌 것으로 — a - b 가 NaN 이면 정렬 비교가 어긋나 줄이 뒤섞였다. 글자로 온 숫자('10'·'9')도
38
49
  둘 다 온전한 숫자면 숫자로 견준다(전에는 '10' 이 '9' 앞에 섰다). */
@@ -40,5 +51,5 @@ export function 칸순(a, b) {
40
51
  const na = 수(a), nb = 수(b);
41
52
  if (na !== null && nb !== null)
42
53
  return na - nb;
43
- return 한글순(a == null ? '' : String(a), b == null ? '' : String(b));
54
+ return 칸콜레이터().compare(a == null ? '' : String(a), b == null ? '' : String(b));
44
55
  }
@@ -75,15 +75,17 @@ export async function checkAudioExists(word) {
75
75
  return audioExistsCache.get(url);
76
76
  // 타임아웃 없으면 HEAD 요청 하나가 응답 없이 걸릴 때 이 확인을 기다리는
77
77
  // 검색 버튼(음성 있음 필터)이 영구 비활성화된다
78
- // 서버가 답을 준 경우(200/404)만 캐시에 남긴다. 네트워크 실패·타임아웃까지 캐시하면
79
- // 한 번의 통신 끊김이 그 단어를 세션 내내 '음성 없음'으로 고정해, '음성 있음' 필터에서
80
- // 통째로 빠진다. 실패는 지워서 다음 호출이 다시 물어보게 한다.
81
- const promise = fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
82
- .then(res => res.ok)
83
- .catch(() => {
84
- audioExistsCache.delete(url);
78
+ // 서버가 답을 준 경우(2xx/4xx)만 캐시에 남긴다. 네트워크 실패·타임아웃·5xx 까지 캐시하면
79
+ // 한 번의 통신 끊김·서버 장애가 그 단어를 세션 내내 '음성 없음'으로 고정해, '음성 있음' 필터에서
80
+ // 통째로 빠진다. 실패는 지워서 다음 호출이 다시 물어보게 한다 — 단 **이 요청이 담은 칸일 때만**:
81
+ // clearAudioExistsCache() 뒤 새로 담긴 확인을 늦게 끝난 옛 요청이 지우면 같은 주소를 또 묻는다.
82
+ const 확인안됨 = () => {
83
+ if (audioExistsCache.get(url) === promise)
84
+ audioExistsCache.delete(url);
85
85
  return false;
86
- });
86
+ };
87
+ const promise = fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) })
88
+ .then(res => (res.status >= 500 ? 확인안됨() : res.ok), 확인안됨);
87
89
  audioExistsCache.set(url, promise);
88
90
  return promise;
89
91
  }
@@ -9,7 +9,7 @@ export declare function generateRuntimeItems(typeKey: WordTrainingTypeKey, opts?
9
9
  * 공통 조합을 백그라운드에서 사전 계산해 결과 캐시를 미리 채운다.
10
10
  * 이후 동일 조합으로 generateRuntimeItems 호출 시 즉시(<5ms) 반환.
11
11
  * fire-and-forget — 완료 여부에 관계없이 UI 블로킹 없음.
12
- * UI가 실제로 쓰는 difficulty(cap.difficulty→'count', 그 외 null)만 예열한다.
12
+ * UI가 실제로 쓰는 열쇠(count 5 · 난이도 'random'/'count' · 필터 기본값)로 예열한다.
13
13
  * (과거 대립종류 cho/jung/jong 예열은 UI 미사용이고 정작 count는 누락돼 첫 생성이 느렸음)
14
14
  */
15
15
  export declare function precomputeCommonCombinations(excludeNoImage: boolean): Promise<void>;
@@ -599,11 +599,14 @@ async function _generateRuntimeItems(typeKey, opts = {}) {
599
599
  * 공통 조합을 백그라운드에서 사전 계산해 결과 캐시를 미리 채운다.
600
600
  * 이후 동일 조합으로 generateRuntimeItems 호출 시 즉시(<5ms) 반환.
601
601
  * fire-and-forget — 완료 여부에 관계없이 UI 블로킹 없음.
602
- * UI가 실제로 쓰는 difficulty(cap.difficulty→'count', 그 외 null)만 예열한다.
602
+ * UI가 실제로 쓰는 열쇠(count 5 · 난이도 'random'/'count' · 필터 기본값)로 예열한다.
603
603
  * (과거 대립종류 cho/jung/jong 예열은 UI 미사용이고 정작 count는 누락돼 첫 생성이 느렸음)
604
604
  */
605
605
  export async function precomputeCommonCombinations(excludeNoImage) {
606
- const difficulties = [null, 'count'];
606
+ /* **화면이 실제로 부르는 열쇠와 똑같이** 예열한다. 전에는 count:10 · 난이도 null/'count' 로 돌았는데 화면
607
+ (useWordTrainingDataGeneration)은 count:5 · 필터 기본 난이도 'random'(음소를 고르면 'count')으로 불러 캐시 열쇠가
608
+ 달랐다 — 예열 계산을 전부 버리고 첫 생성이 다시 계산했다. */
609
+ const difficulties = ['random', 'count'];
607
610
  const types = Object.keys(CAPABILITY);
608
611
  let tick = 0;
609
612
  for (const diff of difficulties) {
@@ -611,9 +614,13 @@ export async function precomputeCommonCombinations(excludeNoImage) {
611
614
  const cap = CAPABILITY[t];
612
615
  generateRuntimeItems(t, {
613
616
  difficulty: cap.difficulty ? diff : null,
614
- choseong: null,
615
- count: 10,
617
+ contrastCount: 1,
618
+ composition: null,
619
+ count: 5,
616
620
  excludeNoImage,
621
+ sameStructureOnly: true,
622
+ phonemeView: 'char',
623
+ grade: null,
617
624
  }).catch(() => { });
618
625
  // 6 타입마다 마이크로태스크 양보 → 메인 스레드 프레임 블로킹 방지
619
626
  if (++tick % 6 === 0)
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.134.0";
49
+ export declare const CORE_VERSION = "0.136.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.134.0';
49
+ export const CORE_VERSION = '0.136.0';