@ihabdevteam/core 0.322.0 → 0.323.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 (196) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/dist/components.cjs.js +183 -183
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +54 -16
  8. package/dist/packages/core/src/components/Accordion.js +3 -3
  9. package/dist/packages/core/src/components/Alert.js +2 -2
  10. package/dist/packages/core/src/components/BarChart.js +120 -119
  11. package/dist/packages/core/src/components/BottomNav.js +18 -18
  12. package/dist/packages/core/src/components/BulkBar.js +25 -25
  13. package/dist/packages/core/src/components/Button.js +16 -16
  14. package/dist/packages/core/src/components/Calendar.js +17 -17
  15. package/dist/packages/core/src/components/ChartSection.js +30 -30
  16. package/dist/packages/core/src/components/CountTile.js +38 -38
  17. package/dist/packages/core/src/components/DatePicker.js +59 -59
  18. package/dist/packages/core/src/components/Dialog.js +14 -14
  19. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
  20. package/dist/packages/core/src/components/FilterPanel.js +6 -6
  21. package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
  22. package/dist/packages/core/src/components/GameIntro.js +1 -1
  23. package/dist/packages/core/src/components/Icon.js +2 -2
  24. package/dist/packages/core/src/components/ItemActions.js +16 -16
  25. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  26. package/dist/packages/core/src/components/LineChart.js +24 -24
  27. package/dist/packages/core/src/components/Loader.js +5 -5
  28. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  29. package/dist/packages/core/src/components/PageTitle.js +2 -2
  30. package/dist/packages/core/src/components/Pagination.js +4 -4
  31. package/dist/packages/core/src/components/Panel.js +2 -2
  32. package/dist/packages/core/src/components/PanelRow.js +6 -13
  33. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  34. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  35. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  36. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  37. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  38. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
  39. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  40. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
  41. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  42. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  43. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  44. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  45. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  46. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  47. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  48. package/dist/packages/core/src/components/Rating.js +20 -20
  49. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  50. package/dist/packages/core/src/components/RingChart.js +5 -5
  51. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  52. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  53. package/dist/packages/core/src/components/SideNav.js +37 -37
  54. package/dist/packages/core/src/components/Slider.js +2 -2
  55. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  56. package/dist/packages/core/src/components/Table.js +257 -98
  57. package/dist/packages/core/src/components/Tabs.js +12 -12
  58. package/dist/packages/core/src/components/TextField.js +34 -34
  59. package/dist/packages/core/src/components/Toast.js +6 -6
  60. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  61. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  62. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  63. package/dist/packages/core/src/components/TopBanner.js +28 -28
  64. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  65. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  66. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  67. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  68. package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
  69. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  70. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  71. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  72. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  73. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  74. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
  75. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  76. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
  77. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  78. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  79. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  80. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
  81. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  82. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  83. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  84. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  85. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  86. package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
  87. package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
  88. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  89. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  90. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  91. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  92. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  93. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  94. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  95. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  96. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  97. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  98. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  99. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  100. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  101. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
  102. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  103. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  104. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  105. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  106. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  107. package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
  108. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  109. package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
  110. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  111. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  112. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  113. package/dist/packages/core/src/components/WordTable.js +130 -130
  114. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  115. package/dist/packages/core/src/components/barChartData.js +49 -29
  116. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  117. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  118. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  119. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  120. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  121. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  122. package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
  123. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  124. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  125. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  126. package/dist/packages/core/src/hooks/inputModality.js +13 -13
  127. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  128. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  129. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  130. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  131. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  132. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  133. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  134. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  135. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  136. package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
  137. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  138. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  139. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  140. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  141. package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
  142. package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
  143. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  144. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  145. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  146. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  147. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
  148. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  149. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  150. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  151. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  152. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  153. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  154. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  155. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  156. package/dist/packages/core/src/utils/clipboard.js +3 -3
  157. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  158. package/dist/packages/core/src/utils/date.js +12 -12
  159. package/dist/packages/core/src/utils/devWarn.js +4 -4
  160. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  161. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  162. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  163. package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
  164. package/dist/packages/core/src/utils/format.js +4 -4
  165. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  166. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  167. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  168. package/dist/packages/core/src/utils/navScroll.js +2 -2
  169. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  170. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  171. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  172. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  173. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  174. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  175. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  176. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  177. package/dist/packages/core/src/utils/safeHref.js +4 -4
  178. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  179. package/dist/packages/core/src/utils/speak.js +12 -12
  180. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  181. package/dist/packages/core/src/utils/textSort.js +38 -48
  182. package/dist/packages/core/src/utils/theme.js +3 -3
  183. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  184. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  185. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
  186. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  187. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  188. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  189. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  190. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  191. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  192. package/dist/packages/core/src/utils/wornDate.js +14 -14
  193. package/dist/packages/core/src/version.d.ts +1 -1
  194. package/dist/packages/core/src/version.js +1 -1
  195. package/dist/utils.cjs.js +4 -4
  196. package/package.json +1 -1
@@ -5,13 +5,13 @@ import { resolveAssetUrl } from '../utils';
5
5
  /* 속성 값이 **문서 밖을 가리키는** url( 을 품었나. `url(#그라데이션)`·`url(#clip)` 처럼 같은 SVG 안의 조각만 가리키는 것은 둔다 —
6
6
  코어 로고 서른여섯 장이 clip-path·fill 로 그것을 쓴다(걷으면 인라인으로 그린 로고가 깨진다).
7
7
  CSS 이스케이프로 url 을 숨긴 꼴(역슬래시 + 괄호)도 걷는다 — 아이콘 속성에 그런 값을 쓸 까닭이 없다. */
8
- function 바깥url있나(value) {
8
+ function hasExternalUrl(value) {
9
9
  if (value.includes('\\') && value.includes('('))
10
10
  return true;
11
11
  if (!/url\s*\(/i.test(value))
12
12
  return false;
13
- const 조각만뺀 = value.replace(/url\(\s*(['"]?)#[^)'"]*\1\s*\)/gi, '');
14
- return /url\s*\(/i.test(조각만뺀);
13
+ const withoutFragments = value.replace(/url\(\s*(['"]?)#[^)'"]*\1\s*\)/gi, '');
14
+ return /url\s*\(/i.test(withoutFragments);
15
15
  }
16
16
  function sanitizeSvg(raw) {
17
17
  if (typeof window === 'undefined')
@@ -19,11 +19,11 @@ function sanitizeSvg(raw) {
19
19
  /* 인라인 SVG 에서 **바깥 자원을 부르는 길** 을 걷는다 — `<style>`(@import·background:url) 과 바깥 url( 이 든 속성(style·fill·filter…).
20
20
  그대로 두면 아이콘 한 장이 제3자 주소로 요청을 보내거나(추적) CSS 로 화면을 덮을 수 있었다.
21
21
  훅은 DOMPurify 전역에 붙으므로 이 한 번의 sanitize 동안만 걸고 곧바로 뗀다(앱의 다른 DOMPurify 쓰임에 새지 않게). */
22
- const 훅 = (_node, data) => {
23
- if (typeof data.attrValue === 'string' && 바깥url있나(data.attrValue))
22
+ const hook = (_node, data) => {
23
+ if (typeof data.attrValue === 'string' && hasExternalUrl(data.attrValue))
24
24
  data.keepAttr = false;
25
25
  };
26
- DOMPurify.addHook('uponSanitizeAttribute', 훅);
26
+ DOMPurify.addHook('uponSanitizeAttribute', hook);
27
27
  try {
28
28
  return DOMPurify.sanitize(raw, {
29
29
  USE_PROFILES: { svg: true, svgFilters: true },
@@ -32,7 +32,7 @@ function sanitizeSvg(raw) {
32
32
  });
33
33
  }
34
34
  finally {
35
- DOMPurify.removeHook('uponSanitizeAttribute', 훅);
35
+ DOMPurify.removeHook('uponSanitizeAttribute', hook);
36
36
  }
37
37
  }
38
38
  /** @public */
@@ -42,8 +42,8 @@ export function useListControls(opts = {}) {
42
42
  const [page, setPage] = useState(1);
43
43
  /* 못 읽히는 쪽크기('' · 'all' · '0')는 기본값으로 — 그 셈이 화면마다 한 벌씩 있었다.
44
44
  기본값 자체가 못 읽히면 25 다. 목록이 통째로 사라지는 값(0·NaN)을 여기서 닫는다. */
45
- const 기본수 = Number(defaultPageSize) || 25;
46
- const perPage = Number(pageSize) || 기본수;
45
+ const defaultSize = Number(defaultPageSize) || 25;
46
+ const perPage = Number(pageSize) || defaultSize;
47
47
  /* **검색어와 쪽크기를 여기서 넣는다** — 부르는 쪽이 적으면 빠뜨릴 수 있고, 빠뜨려도 안 빨개진다.
48
48
  이어 붙이는 것은 `usePageResetOnChange` 가 글 하나를 받기 때문이고, 값이 글·수·참거짓뿐이라
49
49
  `[object Object]` 로 뭉개질 일이 없다(타입이 막는다). */
@@ -22,9 +22,9 @@ import { useState } from 'react';
22
22
  * @public
23
23
  */
24
24
  export function usePageResetOnChange(key, setPage) {
25
- const [본열쇠, set본열쇠] = useState(key);
26
- if (본열쇠 !== key) {
27
- set본열쇠(key);
25
+ const [seenKey, setSeenKey] = useState(key);
26
+ if (seenKey !== key) {
27
+ setSeenKey(key);
28
28
  setPage(1);
29
29
  }
30
30
  }
@@ -17,7 +17,7 @@ export function usePasswordChange(onChangePassword) {
17
17
  const clearTimerRef = useRef(null);
18
18
  /* 처리 중에는 다시 받지 않는다 — 두 번 누르면 변경이 두 번 나가, 먼저 온 성공 뒤에 늦게 온 "현재 비밀번호 틀림" 이
19
19
  남아 바뀌었는데 오류 문구가 떴다(말귀 계정 화면 단추는 로딩 중에도 눌린다) */
20
- const 처리중Ref = useRef(false);
20
+ const busyRef = useRef(false);
21
21
  useEffect(() => {
22
22
  return () => {
23
23
  if (clearTimerRef.current !== null)
@@ -29,7 +29,7 @@ export function usePasswordChange(onChangePassword) {
29
29
  setPwMsg(null);
30
30
  }, []);
31
31
  const handleChangePw = useCallback(async () => {
32
- if (처리중Ref.current)
32
+ if (busyRef.current)
33
33
  return;
34
34
  if (!pw.current) {
35
35
  setPwMsg({ type: 'error', text: t('account.security.password.currentRequired', '현재 비밀번호를 입력하세요.') });
@@ -46,7 +46,7 @@ export function usePasswordChange(onChangePassword) {
46
46
  return;
47
47
  }
48
48
  setPwLoading(true);
49
- 처리중Ref.current = true;
49
+ busyRef.current = true;
50
50
  // 콜백이 문구 대신 **거절**하면(망 끊김 등) 로딩이 true 로 남아 단추가 영영 잠겼고,
51
51
  // reset() 도 로딩은 안 건드려 풀 길이 없었다. 거절도 오류 문구로 보이고 로딩은 늘 내린다.
52
52
  let err;
@@ -57,7 +57,7 @@ export function usePasswordChange(onChangePassword) {
57
57
  err = getErrorMessage(e);
58
58
  }
59
59
  finally {
60
- 처리중Ref.current = false;
60
+ busyRef.current = false;
61
61
  setPwLoading(false);
62
62
  }
63
63
  if (err) {
@@ -7,19 +7,19 @@ import { useCallback, useEffect, useRef, useState } from 'react';
7
7
  */
8
8
  export function usePendingPress(fn) {
9
9
  const [pending, setPending] = useState(false);
10
- const 진행중 = useRef(false);
11
- const 붙어있음 = useRef(true);
12
- useEffect(() => { 붙어있음.current = true; return () => { 붙어있음.current = false; }; }, []);
10
+ const running = useRef(false);
11
+ const mounted = useRef(true);
12
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
13
13
  const press = useCallback(() => {
14
- if (!fn || 진행중.current)
14
+ if (!fn || running.current)
15
15
  return;
16
16
  const r = fn();
17
17
  if (r && typeof r.then === 'function') {
18
- 진행중.current = true;
18
+ running.current = true;
19
19
  setPending(true);
20
- const 풀기 = () => { 진행중.current = false; if (붙어있음.current)
20
+ const settle = () => { running.current = false; if (mounted.current)
21
21
  setPending(false); };
22
- r.then(풀기, 풀기);
22
+ r.then(settle, settle);
23
23
  }
24
24
  }, [fn]);
25
25
  return { pending, press };
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { getTodayKst, toKstYmd } from '../utils/date';
5
5
  /** 'YYYY-MM-DD' 두 날짜 사이의 달력날 수 */
6
- function 날수(from, to) {
6
+ function dayDiff(from, to) {
7
7
  const [fy, fm, fd] = from.split('-').map(Number);
8
8
  const [ty, tm, td] = to.split('-').map(Number);
9
9
  return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86400000);
@@ -23,10 +23,10 @@ export function useRelativeDay() {
23
23
  return useCallback((iso) => {
24
24
  if (!iso)
25
25
  return '';
26
- const 그날 = toKstYmd(iso);
27
- if (!그날)
26
+ const ymd = toKstYmd(iso);
27
+ if (!ymd)
28
28
  return '';
29
- const days = 날수(그날, getTodayKst());
29
+ const days = dayDiff(ymd, getTodayKst());
30
30
  if (days <= 0)
31
31
  return t('myHomework.relativeDay.today', '오늘');
32
32
  if (days === 1)
@@ -26,7 +26,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
26
26
  useEffect(() => () => clearTimers(), []);
27
27
  const answerId = queue[index]?.id ?? null;
28
28
  /** 이미 고른 문항 번호 — 새 문항이 대기에 들어가면 풀린다(playQuestion) */
29
- const 고름잠금 = useRef(null);
29
+ const pickLockRef = useRef(null);
30
30
  // ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
31
31
  const runPlayback = useCallback(() => {
32
32
  clearTimers();
@@ -49,7 +49,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
49
49
  onDoneRef.current?.({ score: scoreRef.current, total: q.length });
50
50
  return;
51
51
  }
52
- 고름잠금.current = null;
52
+ pickLockRef.current = null;
53
53
  setIndex(i);
54
54
  setSelectedId(null);
55
55
  setPhase('playing');
@@ -87,7 +87,7 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
87
87
  /* eslint-disable react-hooks/set-state-in-effect */
88
88
  /* 배치가 바뀌었는지는 **스피커 id 들**로 본다 — 개수만 보면 수가 같은 다른 배치(HORIZON_7 ↔ PRESET_27 가로)로 바꿔도
89
89
  초기화되지 않아 옛 문제 줄이 남고 정답 id 가 새 배치에 없어 무엇을 골라도 오답이었다. */
90
- const 배치열쇠 = speakers.map((sp) => sp.id).join('|');
90
+ const layoutKey = speakers.map((sp) => sp.id).join('|');
91
91
  useEffect(() => {
92
92
  if (autoStart)
93
93
  start();
@@ -96,14 +96,14 @@ export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = f
96
96
  // 배치·모드가 바뀌거나 떠날 때 앞 판의 예약을 걷는다
97
97
  return () => clearTimers();
98
98
  // eslint-disable-next-line react-hooks/exhaustive-deps
99
- }, [mode, 배치열쇠, autoStart]);
99
+ }, [mode, layoutKey, autoStart]);
100
100
  /* eslint-enable react-hooks/set-state-in-effect */
101
101
  const onSelectSpeaker = useCallback((id) => {
102
102
  /* 같은 대기에서 **한 번만** 받는다 — 다시 그려지기 전에 같은 핸들러가 두 번 불리면(렌더 시점의 phase 만 보면) 점수가 +2,
103
103
  다음 문제가 두 번 예약됐다. 고르는 순간 ref 로 닫는다. */
104
- if (phase !== 'waiting' || mode === 'playback' || 고름잠금.current === index)
104
+ if (phase !== 'waiting' || mode === 'playback' || pickLockRef.current === index)
105
105
  return;
106
- 고름잠금.current = index;
106
+ pickLockRef.current = index;
107
107
  setSelectedId(id);
108
108
  const correct = id === queue[index]?.id;
109
109
  if (correct)
@@ -78,15 +78,4 @@ export declare function useTableSort<K extends string>(opts?: TableSortOptions<K
78
78
  onClick: () => void;
79
79
  };
80
80
  sortRows: <R>(rows: readonly R[], getValue: (row: R, key: K) => unknown) => readonly R[];
81
- /** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
82
- 정렬: {
83
- key: K;
84
- asc: boolean;
85
- } | null;
86
- /** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
87
- 칸: (label: string, key: K) => {
88
- label: string;
89
- sortDir: "desc" | "asc" | null;
90
- onClick: () => void;
91
- };
92
81
  };
@@ -26,17 +26,17 @@ import { compareCellBlankLast } from '../utils/textSort';
26
26
  export function useTablePaging(rows, resetOn, opts) {
27
27
  const { t } = useTranslation();
28
28
  const [pageSize, setPageSizeRaw] = useState(() => {
29
- const 처음 = opts?.initialPageSize;
30
- return 처음 && VAP_PAGE_SIZE_OPTIONS.some(o => o.value === 처음) ? 처음 : '25';
29
+ const initial = opts?.initialPageSize;
30
+ return initial && VAP_PAGE_SIZE_OPTIONS.some(o => o.value === initial) ? initial : '25';
31
31
  });
32
32
  const [page, setPage] = useState(1);
33
33
  /* 늘어놓는 차례가 바뀌면 첫 쪽으로 — 3쪽을 보던 중에 정렬하면 무엇이 위로 왔는지
34
34
  못 본다. 렌더 중에 되돌린다(저장소의 다른 곳들과 같은 꼴: 값이 바뀐 그 렌더에서
35
35
  바로 맞춰야 한 박자 늦은 화면이 안 뜬다). */
36
- const [본것, set본것] = useState(resetOn);
36
+ const [seen, setSeen] = useState(resetOn);
37
37
  // Object.is — NaN 이면 `!==` 가 늘 참이라 렌더가 끝나지 않았다. 객체는 렌더마다 새로 만들면 매번 바뀐 것이다(원시값을 넘길 것)
38
- if (!Object.is(본것, resetOn)) {
39
- set본것(resetOn);
38
+ if (!Object.is(seen, resetOn)) {
39
+ setSeen(resetOn);
40
40
  if (page !== 1)
41
41
  setPage(1);
42
42
  }
@@ -50,14 +50,14 @@ export function useTablePaging(rows, resetOn, opts) {
50
50
  // 1 보다 작은 쪽(setPage(0)·-1)도 막는다 — 0 은 빈 쪽, -1 은 음수 slice 로 엉뚱한 줄이 나왔다
51
51
  if (page > total || page < 1 || !Number.isFinite(page))
52
52
  setPage(Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total));
53
- const 이쪽 = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total);
54
- const pageRows = useMemo(() => rows.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize)), [rows, 이쪽, pageSize]);
53
+ const clampedPage = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total);
54
+ const pageRows = useMemo(() => rows.slice((clampedPage - 1) * Number(pageSize), clampedPage * Number(pageSize)), [rows, clampedPage, pageSize]);
55
55
  return {
56
56
  pageRows,
57
57
  /** Toolbar 의 pageSize 자리에 그대로 넣는다 */
58
58
  pageSizeProps: { options: pageSizeOptions, value: pageSize, onChange: setPageSize },
59
59
  /** Toolbar 의 pagination 자리에 그대로 넣는다 */
60
- paginationProps: { page: 이쪽, total, onChange: setPage },
60
+ paginationProps: { page: clampedPage, total, onChange: setPage },
61
61
  setPage,
62
62
  };
63
63
  }
@@ -79,42 +79,37 @@ export function useTablePaging(rows, resetOn, opts) {
79
79
  * @public
80
80
  */
81
81
  export function useTableSort(opts) {
82
- const [정렬, 정렬바꾸기] = useState(null);
82
+ const [sortState, setSortState] = useState(null);
83
83
  /* 다음 정렬은 **ref 에서** 계산해 알린다 — 갱신 함수 안에서 부르면 StrictMode 가 두 번 부른다 */
84
- const 지금정렬 = useRef(정렬);
85
- const 알림 = useRef(opts?.onSortChange);
86
- 알림.current = opts?.onSortChange;
87
- const 누름 = useCallback((key) => {
88
- const 앞 = 지금정렬.current;
89
- const 다음 = 앞?.key === key ? { key, asc: !앞.asc } : { key, asc: true };
90
- 지금정렬.current = 다음;
91
- 정렬바꾸기(다음);
92
- 알림.current?.(다음);
84
+ const sortRef = useRef(sortState);
85
+ const onChangeRef = useRef(opts?.onSortChange);
86
+ onChangeRef.current = opts?.onSortChange;
87
+ const onHeadClick = useCallback((key) => {
88
+ const prev = sortRef.current;
89
+ const next = prev?.key === key ? { key, asc: !prev.asc } : { key, asc: true };
90
+ sortRef.current = next;
91
+ setSortState(next);
92
+ onChangeRef.current?.(next);
93
93
  }, []);
94
94
  /** Table 의 field 하나에 붙일 것 — 라벨과 함께 쓴다 */
95
- const 칸 = useCallback((label, key) => ({
95
+ const fieldHead = useCallback((label, key) => ({
96
96
  label,
97
- sortDir: 정렬?.key === key ? (정렬.asc ? 'asc' : 'desc') : null,
98
- onClick: () => 누름(key),
99
- }), [정렬, 누름]);
97
+ sortDir: sortState?.key === key ? (sortState.asc ? 'asc' : 'desc') : null,
98
+ onClick: () => onHeadClick(key),
99
+ }), [sortState, onHeadClick]);
100
100
  /** 지금 정렬로 세운 새 배열(정렬 없으면 받은 배열 그대로). 빈 값은 오름·내림 모두 맨 뒤. */
101
101
  const sortRows = useCallback((rows, getValue) => {
102
- if (!정렬)
102
+ if (!sortState)
103
103
  return rows;
104
- const { key, asc } = 정렬;
104
+ const { key, asc } = sortState;
105
105
  return [...rows].sort((a, b) => compareCellBlankLast(getValue(a, key), getValue(b, key), asc));
106
- }, [정렬]);
106
+ }, [sortState]);
107
107
  /* 쪽넘김에 그대로 건넨다 — 차례가 바뀌면 첫 쪽으로 돌아가게.
108
- **영어 이름이 정본이다**(`sort`·`field`) — 공개 API 는 영어라야 한다(사용자 지시 2026-10-13).
109
- 한글 이름(`정렬`·`칸`)은 **같은 것을 가리키는 옛 이름**으로 남긴다: 말귀가 77 줄에서 그 이름으로
110
- 풀어 쓰고 있어, 한 번에 걷으면 그 저장소가 통째로 빨개진다. 그쪽이 옮긴 뒤 걷는다. */
108
+ **옛 한글 이름(`정렬`·`칸`)은 2026-10-13 걷었다** — 소비자가 다 옮긴 것을 세어 보고 걷었다
109
+ (말귀의 코어 `useTableSort` 자리 열, 전부 `{ sort, field }`. kscan·소리방향성·fe 는 0). */
111
110
  return {
112
- sort: 정렬,
113
- field: 칸,
111
+ sort: sortState,
112
+ field: fieldHead,
114
113
  sortRows,
115
- /** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
116
- 정렬,
117
- /** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
118
- 칸,
119
114
  };
120
115
  }
@@ -54,72 +54,72 @@ import { logTrainingTime } from '../utils/trainingLogger';
54
54
  * @public
55
55
  */
56
56
  /** 지금 그 갈래의 시간을 적고 있는 주인 — 갈래마다 하나(위 머리말 참고) */
57
- const 적는주인 = new Set();
57
+ const writers = new Set();
58
58
  export default function useTrainingSessionLog(trainingType) {
59
59
  useEffect(() => {
60
60
  if (!trainingType)
61
61
  return; // 타입 미상이면 기록하지 않음
62
62
  /* **갈래마다 주인은 하나다.** 이미 누가 적고 있으면 조용히 물러난다 — 안 그러면
63
63
  두 창이 제 시간을 따로 적어 겹친 만큼이 두 번 세어진다. */
64
- if (적는주인.has(trainingType))
64
+ if (writers.has(trainingType))
65
65
  return;
66
- 적는주인.add(trainingType);
67
- const 보임 = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
68
- let 처음보임 = null;
69
- let 구간시작 = null;
70
- let 모은ms = 0;
71
- let 보낸ms = 0; // 지금까지 줄로 내보낸 누계 — 다음 줄은 여기서부터 이어 붙인다
72
- const 구간열기 = () => { const t = Date.now(); if (처음보임 == null)
73
- 처음보임 = t; if (구간시작 == null)
74
- 구간시작 = t; };
75
- const 구간닫기 = () => { if (구간시작 != null) {
76
- 모은ms += Date.now() - 구간시작;
77
- 구간시작 = null;
66
+ writers.add(trainingType);
67
+ const isVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
68
+ let firstVisibleAt = null;
69
+ let spanStartedAt = null;
70
+ let accumulatedMs = 0;
71
+ let sentMs = 0; // 지금까지 줄로 내보낸 누계 — 다음 줄은 여기서부터 이어 붙인다
72
+ const openSpan = () => { const t = Date.now(); if (firstVisibleAt == null)
73
+ firstVisibleAt = t; if (spanStartedAt == null)
74
+ spanStartedAt = t; };
75
+ const closeSpan = () => { if (spanStartedAt != null) {
76
+ accumulatedMs += Date.now() - spanStartedAt;
77
+ spanStartedAt = null;
78
78
  } };
79
- if (보임())
80
- 구간열기();
79
+ if (isVisible())
80
+ openSpan();
81
81
  /* 아직 안 보낸 만큼을 한 줄로. 끝 시각은 처음 보인 때 + 모은 시간, 시작 시각은 앞 줄이
82
82
  끝난 자리 — 줄끼리 겹치지 않으니 합산해도 이중으로 세지 않는다.
83
83
  전달은 등록된 로거(logTrainingTime)로 간다. 코어는 보낼 주소를 모르므로
84
84
  navigator.sendBeacon 을 여기서 직접 쓸 수 없다 — 앱이 등록하는 로거의 몫이다. */
85
- const 보내기 = () => {
86
- 구간닫기();
87
- if (처음보임 == null)
85
+ const send = () => {
86
+ closeSpan();
87
+ if (firstVisibleAt == null)
88
88
  return;
89
- const 남은ms = 모은ms - 보낸ms;
89
+ const pendingMs = accumulatedMs - sentMs;
90
90
  /* 1초가 안 되는 조각은 보내지 않는다 — 서버(log_training_category_time)가 1초 미만 줄을 버리므로
91
91
  부르기 제한(10분 60번)만 축낸다(말귀 확인 2026-09-16). */
92
- if (남은ms < 1000)
92
+ if (pendingMs < 1000)
93
93
  return;
94
94
  // 첫 줄에만 5초 문턱을 건다(잘못 열었다 닫은 판). 한 번 보낸 판은 이미 실재하는
95
95
  // 훈련이라, 돌아와 더 한 시간은 짧아도 버리지 않는다.
96
- if (보낸ms === 0 && 모은ms < 5000)
96
+ if (sentMs === 0 && accumulatedMs < 5000)
97
97
  return;
98
- const 시작ms = 처음보임 + 보낸ms;
99
- 보낸ms = 모은ms;
98
+ const startMs = firstVisibleAt + sentMs;
99
+ sentMs = accumulatedMs;
100
100
  logTrainingTime({
101
101
  trainingType,
102
- startedAt: new Date(시작ms).toISOString(),
103
- endedAt: new Date(처음보임 + 모은ms).toISOString(),
102
+ startedAt: new Date(startMs).toISOString(),
103
+ endedAt: new Date(firstVisibleAt + accumulatedMs).toISOString(),
104
104
  });
105
105
  };
106
106
  // 숨으면 보낸다(앱이 그대로 죽을 수 있다). 다시 보이면 이어서 모은다.
107
- const 보임바뀜 = () => { if (보임())
108
- 구간열기();
107
+ const onVisibilityChange = () => { if (isVisible())
108
+ openSpan();
109
109
  else
110
- 보내기(); };
110
+ send(); };
111
111
  if (typeof document !== 'undefined')
112
- document.addEventListener('visibilitychange', 보임바뀜);
112
+ document.addEventListener('visibilitychange', onVisibilityChange);
113
113
  // 페이지가 내려가면(탭·앱 닫기) React 정리가 안 돈다 — 그때 보낸다
114
114
  if (typeof window !== 'undefined')
115
- window.addEventListener('pagehide', 보내기);
115
+ window.addEventListener('pagehide', send);
116
116
  return () => {
117
117
  if (typeof document !== 'undefined')
118
- document.removeEventListener('visibilitychange', 보임바뀜);
118
+ document.removeEventListener('visibilitychange', onVisibilityChange);
119
119
  if (typeof window !== 'undefined')
120
- window.removeEventListener('pagehide', 보내기);
121
- 보내기();
122
- 적는주인.delete(trainingType);
120
+ window.removeEventListener('pagehide', send);
121
+ send();
122
+ writers.delete(trainingType);
123
123
  };
124
124
  }, [trainingType]);
125
125
  }
@@ -13,10 +13,10 @@ import { useEditableList, isSameEditableItem } from './useEditableList';
13
13
  * 없었다** — V8 의 TimSort 는 같은 값 구간에서 비교를 아예 안 불러, 뒤섞인 자료로
14
14
  * 재 봐도 결과가 같았다(실측). 그래서 이 한 가지는 시험으로 못 박는다.
15
15
  */
16
- const 정렬키 = (v) => `${v.year.padStart(4, '0')}-${v.month.padStart(2, '0')}-${v.day.padStart(2, '0')}` +
16
+ const sortKeyOf = (v) => `${v.year.padStart(4, '0')}-${v.month.padStart(2, '0')}-${v.day.padStart(2, '0')}` +
17
17
  ` ${v.hour.padStart(2, '0')}:${v.minute.padStart(2, '0')}`;
18
18
  const sortByDate = (list) => [...list].sort((a, b) => {
19
- const x = 정렬키(a), y = 정렬키(b);
19
+ const x = sortKeyOf(a), y = sortKeyOf(b);
20
20
  return x < y ? -1 : x > y ? 1 : 0;
21
21
  });
22
22
  const emptyScheduleItem = { year: '', month: '', day: '', hour: '09', minute: '00', memo: '' };
@@ -34,8 +34,8 @@ initialSchedule = []) {
34
34
  useMemo 로 묶는 것은 성능 때문이 아니라 **되돌이를 막기 위해서다** —
35
35
  useEditableList 는 초기 목록을 원소 참조로 견주는데, 렌더마다 새 배열을 만들면
36
36
  원소는 같아도 매번 setList 가 돌아 무한 렌더가 된다. */
37
- const 정렬된초기목록 = useMemo(() => sortByDate(initialSchedule), [initialSchedule]);
38
- const { list: scheduleList, setList: setScheduleList, selected: selectedSchedule, setSelected: setSelectedSchedule, isEditing: isEditingSchedule, setIsEditing: setIsEditingSchedule, editForm: editScheduleForm, setEditForm: setEditScheduleForm, openDetail: openScheduleDetail, commitEdit, remove, } = useEditableList(emptyScheduleItem, 정렬된초기목록);
37
+ const sortedInitial = useMemo(() => sortByDate(initialSchedule), [initialSchedule]);
38
+ const { list: scheduleList, setList: setScheduleList, selected: selectedSchedule, setSelected: setSelectedSchedule, isEditing: isEditingSchedule, setIsEditing: setIsEditingSchedule, editForm: editScheduleForm, setEditForm: setEditScheduleForm, openDetail: openScheduleDetail, commitEdit, remove, } = useEditableList(emptyScheduleItem, sortedInitial);
39
39
  /** 미방문 처리된 예약 — 삭제하지 않고 보존해 통합 방문 목록에서 뱃지로 표시 */
40
40
  const [noShowList, setNoShowList] = useState([]);
41
41
  const { year: ty, month: tm, day: td } = today();
@@ -119,22 +119,22 @@ export function buildPositionSection(t, opts) {
119
119
  * 저장할 때는 `toWordAttributeFilterCodes` 로 코드 모양을 함께 담는다.
120
120
  */
121
121
  export function toggleAttributeFilter(prev, value, sectionLabel) {
122
- const 섹션 = wordAttributeSectionOf(sectionLabel);
123
- const 값코드 = 섹션 && value !== FILTER_ALL_VALUE ? wordAttributeValueCodeOf(섹션, value) : null;
124
- if (섹션 && (value === FILTER_ALL_VALUE || 값코드)) {
125
- const 남김 = {};
126
- const 같은섹션 = {};
122
+ const section = wordAttributeSectionOf(sectionLabel);
123
+ const valueCode = section && value !== FILTER_ALL_VALUE ? wordAttributeValueCodeOf(section, value) : null;
124
+ if (section && (value === FILTER_ALL_VALUE || valueCode)) {
125
+ const kept = {};
126
+ const sameSection = {};
127
127
  for (const [k, v] of Object.entries(prev))
128
- (wordAttributeSectionOf(k) === 섹션 ? 같은섹션 : 남김)[k] = v;
128
+ (wordAttributeSectionOf(k) === section ? sameSection : kept)[k] = v;
129
129
  if (value === FILTER_ALL_VALUE)
130
- return 남김;
131
- const 지금 = toWordAttributeFilterCodes(같은섹션)[섹션] ?? [];
132
- const 다음 = 지금.includes(값코드) ? 지금.filter(c => c !== 값코드) : [...지금, 값코드];
133
- if (다음.length === 0)
134
- return 남김;
135
- const 라벨 = wordAttributeFilterFromCodes({ [섹션]: 다음 }, wordAttributeLabelLanguageOf(sectionLabel));
136
- const 값들 = Object.values(라벨)[0] ?? [];
137
- return { ...남김, [sectionLabel]: 값들 };
130
+ return kept;
131
+ const current = toWordAttributeFilterCodes(sameSection)[section] ?? [];
132
+ const nextCodes = current.includes(valueCode) ? current.filter(c => c !== valueCode) : [...current, valueCode];
133
+ if (nextCodes.length === 0)
134
+ return kept;
135
+ const labels = wordAttributeFilterFromCodes({ [section]: nextCodes }, wordAttributeLabelLanguageOf(sectionLabel));
136
+ const values = Object.values(labels)[0] ?? [];
137
+ return { ...kept, [sectionLabel]: values };
138
138
  }
139
139
  if (value === FILTER_ALL_VALUE) {
140
140
  const { [sectionLabel]: _omit, ...rest } = prev;
@@ -28,8 +28,8 @@ const memCache = new Map();
28
28
  const noMeaning = new Set();
29
29
  /* 빈 응답(한 줄도 못 받음)으로 '뜻 없음' 이 된 열쇠와 그 때 — 조회 함수는 실패도 빈 배열로 돌려주므로 영영 굳히지 않고, 사용자 추가
30
30
  낱말처럼 정말 뜻이 없는 쪽을 오갈 때마다 다시 묻지도 않게 **잠시만**(1분) 묻지 않는다. */
31
- const 빈응답때 = new Map();
32
- const 빈응답유예ms = 60_000;
31
+ const emptyAt = new Map();
32
+ const EMPTY_GRACE_MS = 60_000;
33
33
  export function useWordMeanings(rows, fetchMeanings) {
34
34
  /* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
35
35
  올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
@@ -39,8 +39,8 @@ export function useWordMeanings(rows, fetchMeanings) {
39
39
  const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort().join('|');
40
40
  useEffect(() => {
41
41
  const wanted = wantedKey ? wantedKey.split('|') : [];
42
- const 지금 = Date.now();
43
- const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k) && !(지금 - (빈응답때.get(k) ?? -Infinity) < 빈응답유예ms));
42
+ const now = Date.now();
43
+ const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k) && !(now - (emptyAt.get(k) ?? -Infinity) < EMPTY_GRACE_MS));
44
44
  if (missing.length === 0)
45
45
  return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
46
46
  let cancelled = false;
@@ -62,7 +62,7 @@ export function useWordMeanings(rows, fetchMeanings) {
62
62
  else {
63
63
  const t = Date.now();
64
64
  for (const k of missing)
65
- 빈응답때.set(k, t);
65
+ emptyAt.set(k, t);
66
66
  }
67
67
  if (cancelled || got.length === 0)
68
68
  return;
@@ -8,7 +8,7 @@ import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection
8
8
  import { matchesFilters } from '../utils/phonemePairSearch';
9
9
  import { wordAttributeValueCodeOf, wordAttributeValueLabelOf, isGradeCode } from '../utils/wordAttributeFilterCodes';
10
10
  /** 저장된 등급을 되읽을 때 코드 아닌 것은 버린다 — 0.320.0 작업본이 잠깐 `['']` 를 저장했다 */
11
- const 등급코드만 = (v) => (Array.isArray(v) ? v.filter(isGradeCode) : []);
11
+ const gradeCodesOnly = (v) => (Array.isArray(v) ? v.filter(isGradeCode) : []);
12
12
  import { hasSel } from '../utils/wordContrast';
13
13
  import { phoneticLabel } from '../utils/phonemeGroups';
14
14
  /* 글자↔발음 변환과 음절 상수는 wordTrainingFilterModel.ts 로 옮겼다(React 없는 순수 계층).
@@ -145,12 +145,12 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
145
145
  // 음소 구조 같은 단어만(초성 ㅇ 유무·받침 유무 대립 제외) — 기본 true (reset 시 OFF)
146
146
  const [sameStructureOnly, setSameStructureOnly] = useState(opts?.resetOnMount ? false : (saved?.sameStructureOnly ?? true));
147
147
  // 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
148
- const [gradeFilter, setGradeFilter] = useState(등급코드만(saved?.gradeFilter));
148
+ const [gradeFilter, setGradeFilter] = useState(gradeCodesOnly(saved?.gradeFilter));
149
149
  // 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
150
- const 라벨언어들 = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
150
+ const labelLanguages = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
151
151
  const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
152
- const posCodesOf = (v) => toAttrCodes(v, POS_CODES, c => `wordSearch.filter.pos.${c}`, labelOf, 라벨언어들);
153
- const typeCodesOf = (v) => toAttrCodes(v, TYPE_CODES, c => `wordSearch.filter.type.${c}`, labelOf, 라벨언어들);
152
+ const posCodesOf = (v) => toAttrCodes(v, POS_CODES, c => `wordSearch.filter.pos.${c}`, labelOf, labelLanguages);
153
+ const typeCodesOf = (v) => toAttrCodes(v, TYPE_CODES, c => `wordSearch.filter.type.${c}`, labelOf, labelLanguages);
154
154
  const [posCodes, setPosCodes] = useState(() => posCodesOf(saved?.posFilter));
155
155
  const [typeCodes, setTypeCodes] = useState(() => typeCodesOf(saved?.wordTypeFilter));
156
156
  // 훈련 타입(resetKey) 변경 시 필터를 그 타입 기본값으로 초기화 — 이전 타입의 고정값(음절수/받침/한음절 시드)이
@@ -213,7 +213,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
213
213
  setSameStructureOnly(s?.sameStructureOnly ?? true);
214
214
  // 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
215
215
  setPhonemeView(readPhonemeView(s));
216
- setGradeFilter(등급코드만(s?.gradeFilter));
216
+ setGradeFilter(gradeCodesOnly(s?.gradeFilter));
217
217
  setPosCodes(posCodesOf(s?.posFilter));
218
218
  setTypeCodes(typeCodesOf(s?.wordTypeFilter));
219
219
  }
@@ -621,7 +621,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
621
621
  setExcludeNoImage(s.excludeNoImage);
622
622
  setSameStructureOnly(s.sameStructureOnly ?? true);
623
623
  setPhonemeView(s.phonemeView);
624
- setGradeFilter(등급코드만(s.gradeFilter));
624
+ setGradeFilter(gradeCodesOnly(s.gradeFilter));
625
625
  if (Array.isArray(s.posFilter))
626
626
  setPosCodes(posCodesOf(s.posFilter));
627
627
  if (Array.isArray(s.wordTypeFilter))
@@ -114,6 +114,7 @@
114
114
  "Fri",
115
115
  "Sat"
116
116
  ],
117
+ "weekLabel": "Week {{ord}}",
117
118
  "yearMonth": "{{month}}/{{year}}",
118
119
  "prevMonth": "Previous month",
119
120
  "addOnDate": "Add a visit on {{date}}",
@@ -128,7 +129,16 @@
128
129
  "periodSelect": "Period unit",
129
130
  "visitCount_one": "{{count}} visit",
130
131
  "visitCount_other": "{{count}} visits",
131
- "statusCount": "{{count}} {{label}}"
132
+ "statusCount": "{{count}} {{label}}",
133
+ "weekOrdinal": [
134
+ "1",
135
+ "2",
136
+ "3",
137
+ "4",
138
+ "5",
139
+ "6",
140
+ "7"
141
+ ]
132
142
  },
133
143
  "passCard": {
134
144
  "planSuffix": "Plan",
@@ -1059,7 +1069,8 @@
1059
1069
  "removeDevice": "Remove",
1060
1070
  "type": {
1061
1071
  "hearingAid": "Hearing Aid",
1062
- "cochlearImplant": "Cochlear Implant"
1072
+ "cochlearImplant": "Cochlear Implant",
1073
+ "other": "Other"
1063
1074
  }
1064
1075
  },
1065
1076
  "manager": {