@ihabdevteam/core 0.321.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 (217) hide show
  1. package/CHANGELOG.md +617 -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 +108 -66
  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/HearingDeviceInfo.d.ts +5 -5
  24. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  25. package/dist/packages/core/src/components/Icon.js +2 -2
  26. package/dist/packages/core/src/components/ItemActions.js +16 -16
  27. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  28. package/dist/packages/core/src/components/LineChart.js +24 -24
  29. package/dist/packages/core/src/components/Loader.js +5 -5
  30. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  31. package/dist/packages/core/src/components/PageTitle.js +2 -2
  32. package/dist/packages/core/src/components/Pagination.js +4 -4
  33. package/dist/packages/core/src/components/Panel.js +2 -2
  34. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  35. package/dist/packages/core/src/components/PanelRow.js +8 -15
  36. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  37. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  38. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  39. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  40. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  41. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  42. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  43. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
  44. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  45. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
  46. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  47. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  48. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  49. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  50. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  51. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  52. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  53. package/dist/packages/core/src/components/Rating.js +20 -20
  54. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  55. package/dist/packages/core/src/components/RingChart.js +5 -5
  56. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  57. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  58. package/dist/packages/core/src/components/SideNav.js +37 -37
  59. package/dist/packages/core/src/components/Slider.js +2 -2
  60. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  61. package/dist/packages/core/src/components/Table.js +257 -98
  62. package/dist/packages/core/src/components/Tabs.js +12 -12
  63. package/dist/packages/core/src/components/TextField.js +34 -34
  64. package/dist/packages/core/src/components/Toast.js +6 -6
  65. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  66. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  67. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  68. package/dist/packages/core/src/components/TopBanner.js +28 -28
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  70. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  71. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  72. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  73. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  74. package/dist/packages/core/src/components/Training/TrainingBlock.js +12 -12
  75. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  76. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  77. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  78. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  79. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  80. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +83 -83
  81. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  82. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
  83. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  84. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  85. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  86. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +13 -13
  87. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  88. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  89. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  90. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  91. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  92. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  93. package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
  94. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  95. package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
  96. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  97. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  98. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  99. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  100. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  101. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  102. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  103. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  104. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  105. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  106. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  107. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  108. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  109. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +18 -18
  110. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  111. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  112. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  113. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  114. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  115. package/dist/packages/core/src/components/Word/wordScoreStats.js +17 -17
  116. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  117. package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
  118. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  119. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  120. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  121. package/dist/packages/core/src/components/WordTable.js +130 -130
  122. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  123. package/dist/packages/core/src/components/barChartData.js +49 -29
  124. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  125. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  126. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  127. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  128. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  129. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  130. package/dist/packages/core/src/components/wordTableLayout.d.ts +22 -7
  131. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  132. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  133. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  134. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  135. package/dist/packages/core/src/hooks/inputModality.js +14 -14
  136. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  137. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  138. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  139. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  140. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  141. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  142. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  143. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  144. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  145. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  146. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  147. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  148. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  149. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  150. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  151. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  152. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  153. package/dist/packages/core/src/hooks/useTablePaging.d.ts +7 -7
  154. package/dist/packages/core/src/hooks/useTablePaging.js +40 -34
  155. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  156. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  157. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  158. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  159. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  160. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
  161. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  162. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  163. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  164. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  165. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  166. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  167. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  168. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  169. package/dist/packages/core/src/utils/clipboard.js +3 -3
  170. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  171. package/dist/packages/core/src/utils/date.js +12 -12
  172. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  173. package/dist/packages/core/src/utils/devWarn.js +7 -7
  174. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  175. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  176. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  177. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  178. package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
  179. package/dist/packages/core/src/utils/format.js +4 -4
  180. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  181. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  182. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  183. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  184. package/dist/packages/core/src/utils/navScroll.js +6 -6
  185. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  186. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  187. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  188. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  189. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  190. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  191. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  192. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  193. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  194. package/dist/packages/core/src/utils/pressable.js +3 -3
  195. package/dist/packages/core/src/utils/safeHref.js +4 -4
  196. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  197. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  198. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  199. package/dist/packages/core/src/utils/speak.js +12 -12
  200. package/dist/packages/core/src/utils/textSort.d.ts +5 -5
  201. package/dist/packages/core/src/utils/textSort.js +47 -47
  202. package/dist/packages/core/src/utils/theme.js +3 -3
  203. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  204. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  205. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  206. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
  207. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  208. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  209. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  210. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  211. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  212. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  213. package/dist/packages/core/src/utils/wornDate.js +14 -14
  214. package/dist/packages/core/src/version.d.ts +1 -1
  215. package/dist/packages/core/src/version.js +1 -1
  216. package/dist/utils.cjs.js +4 -4
  217. package/package.json +1 -1
@@ -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;
@@ -56,9 +56,11 @@ export interface WordTrainingFilterCaps {
56
56
  * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
57
57
  *
58
58
  * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
59
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
60
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
59
61
  * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
60
62
  */
61
- export declare function 등급설명(t: TFunction): string[];
63
+ export declare function gradeDescriptions(t: TFunction): string[];
62
64
  export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
63
65
  resetOnMount?: boolean;
64
66
  resetKey?: string;
@@ -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 없는 순수 계층).
@@ -78,7 +78,7 @@ function readComposition(saved) {
78
78
  * @public
79
79
  */
80
80
  /** 사전이 배열을 안 주면 한국어 원문으로 물러선다 — 빈 줄을 띄우지 않는다((205)) */
81
- const 등급설명기본 = [
81
+ const GRADE_DESCRIPTIONS_KO = [
82
82
  '1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘',
83
83
  '2등급 : 초등학교 1-2학년 난이도 어휘',
84
84
  '3등급 : 초등학교 3-4학년 난이도 어휘',
@@ -89,11 +89,13 @@ const 등급설명기본 = [
89
89
  * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
90
90
  *
91
91
  * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
92
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
93
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
92
94
  * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
93
95
  */
94
- export function 등급설명(t) {
95
- const v = t('wordPairs.filter.difficultyInfo', { returnObjects: true, defaultValue: 등급설명기본 });
96
- return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string') ? v : 등급설명기본;
96
+ export function gradeDescriptions(t) {
97
+ const v = t('wordPairs.filter.difficultyInfo', { returnObjects: true, defaultValue: GRADE_DESCRIPTIONS_KO });
98
+ return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string') ? v : GRADE_DESCRIPTIONS_KO;
97
99
  }
98
100
  export function useWordTrainingFilter(groupKey, caps, opts) {
99
101
  const { t, i18n } = useTranslation();
@@ -143,12 +145,12 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
143
145
  // 음소 구조 같은 단어만(초성 ㅇ 유무·받침 유무 대립 제외) — 기본 true (reset 시 OFF)
144
146
  const [sameStructureOnly, setSameStructureOnly] = useState(opts?.resetOnMount ? false : (saved?.sameStructureOnly ?? true));
145
147
  // 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
146
- const [gradeFilter, setGradeFilter] = useState(등급코드만(saved?.gradeFilter));
148
+ const [gradeFilter, setGradeFilter] = useState(gradeCodesOnly(saved?.gradeFilter));
147
149
  // 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
148
- const 라벨언어들 = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
150
+ const labelLanguages = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
149
151
  const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
150
- const posCodesOf = (v) => toAttrCodes(v, POS_CODES, c => `wordSearch.filter.pos.${c}`, labelOf, 라벨언어들);
151
- 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);
152
154
  const [posCodes, setPosCodes] = useState(() => posCodesOf(saved?.posFilter));
153
155
  const [typeCodes, setTypeCodes] = useState(() => typeCodesOf(saved?.wordTypeFilter));
154
156
  // 훈련 타입(resetKey) 변경 시 필터를 그 타입 기본값으로 초기화 — 이전 타입의 고정값(음절수/받침/한음절 시드)이
@@ -211,7 +213,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
211
213
  setSameStructureOnly(s?.sameStructureOnly ?? true);
212
214
  // 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
213
215
  setPhonemeView(readPhonemeView(s));
214
- setGradeFilter(등급코드만(s?.gradeFilter));
216
+ setGradeFilter(gradeCodesOnly(s?.gradeFilter));
215
217
  setPosCodes(posCodesOf(s?.posFilter));
216
218
  setTypeCodes(typeCodesOf(s?.wordTypeFilter));
217
219
  }
@@ -450,7 +452,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
450
452
  사전이 배열을 안 주면(옛 사전을 덮어쓴 소비자 등) **한국어 원문으로 물러선다** — 빈 줄을
451
453
  띄우지 않는다. 재 보고 적는다: `returnObjects` 는 배열을 그대로 주고 `defaultValue` 도
452
454
  배열로 물러선다(i18next 26.4.1). */
453
- labelInfo: infoAction(등급설명(t)),
455
+ labelInfo: infoAction(gradeDescriptions(t)),
454
456
  }),
455
457
  label: LABELS.gradeLabel,
456
458
  });
@@ -619,7 +621,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
619
621
  setExcludeNoImage(s.excludeNoImage);
620
622
  setSameStructureOnly(s.sameStructureOnly ?? true);
621
623
  setPhonemeView(s.phonemeView);
622
- setGradeFilter(등급코드만(s.gradeFilter));
624
+ setGradeFilter(gradeCodesOnly(s.gradeFilter));
623
625
  if (Array.isArray(s.posFilter))
624
626
  setPosCodes(posCodesOf(s.posFilter));
625
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": {
@@ -110,6 +110,16 @@
110
110
  "금",
111
111
  "토"
112
112
  ],
113
+ "weekOrdinal": [
114
+ "첫째",
115
+ "둘째",
116
+ "셋째",
117
+ "넷째",
118
+ "다섯째",
119
+ "여섯째",
120
+ "일곱째"
121
+ ],
122
+ "weekLabel": "{{ord}}주",
113
123
  "yearMonth": "{{year}}년 {{month}}월",
114
124
  "prevMonth": "이전 달",
115
125
  "addOnDate": "{{date}}에 방문 등록",
@@ -1048,7 +1058,8 @@
1048
1058
  "removeDevice": "착용 해제",
1049
1059
  "type": {
1050
1060
  "hearingAid": "보청기",
1051
- "cochlearImplant": "인공와우"
1061
+ "cochlearImplant": "인공와우",
1062
+ "other": "기타"
1052
1063
  }
1053
1064
  },
1054
1065
  "manager": {
@@ -19,7 +19,16 @@ export interface WordSearchResultWord {
19
19
  export interface WordSearchResultItem {
20
20
  id: string;
21
21
  words: WordSearchResultWord[];
22
+ /**
23
+ * 갈린 자리 — **옛 한글 이름**(`'초성'`·`'모음'`·`'종성'`·`'활음'`)이다.
24
+ *
25
+ * @deprecated `diffSectionCode` 를 쓴다 — 같은 자리를 코드로 가리킨다. 2026-10-13 안쪽을
26
+ * 코드로 옮겼지만 **소비자가 이 값으로 묶음을 가른다**(말귀 `WordPairsPage` 의 `GROUP_ORDER`).
27
+ * 혼자 바꾸면 그쪽 묶음이 조용히 빈다. 소비자가 옮긴 뒤 걷는다.
28
+ */
22
29
  diffSection?: string;
30
+ /** 갈린 자리 — 코드(`'onset'`·`'vowel'`·`'coda'`·`'glide'`). 이쪽이 정본이다. */
31
+ diffSectionCode?: import('../utils/phonemeSectionCodes').PhonemeSectionCode;
23
32
  diffHasEmpty?: boolean;
24
33
  }
25
34
  /**
@@ -20,19 +20,19 @@ const _graphs = new WeakMap();
20
20
  const _desired = new WeakMap();
21
21
  const _pending = new WeakMap();
22
22
  let _unlockInstalled = false;
23
- const 자르기 = (v) => Math.max(0, Math.min(1, Number.isFinite(v) ? v : 0));
23
+ const clamp01 = (v) => Math.max(0, Math.min(1, Number.isFinite(v) ? v : 0));
24
24
  /** 첫 사용자 제스처(및 이후 제스처)마다 멈춘 컨텍스트를 깨운다 — iOS 는 제스처 안에서만 resume 이 먹는다 */
25
25
  function installGestureUnlock() {
26
26
  if (_unlockInstalled || typeof document === 'undefined')
27
27
  return;
28
28
  _unlockInstalled = true;
29
- const 깨우기 = () => { try {
29
+ const resumeCtx = () => { try {
30
30
  if (_ctx && _ctx.state !== 'running')
31
31
  void _ctx.resume();
32
32
  }
33
33
  catch { /* noop */ } };
34
34
  for (const type of ['pointerdown', 'touchend', 'keydown']) {
35
- document.addEventListener(type, 깨우기, { capture: true, passive: true });
35
+ document.addEventListener(type, resumeCtx, { capture: true, passive: true });
36
36
  }
37
37
  }
38
38
  function getContext() {
@@ -68,7 +68,7 @@ function build(ctx, el) {
68
68
  analyser.fftSize = 1024;
69
69
  analyser.smoothingTimeConstant = 0.6;
70
70
  // 태우기 직전의 요소 볼륨을 gain 으로 옮기고 요소는 1 로 — 순서가 바뀌면 한 순간 두 번 곱해진다
71
- gain.gain.value = 자르기(_desired.get(el) ?? el.volume);
71
+ gain.gain.value = clamp01(_desired.get(el) ?? el.volume);
72
72
  source.connect(gain);
73
73
  gain.connect(analyser);
74
74
  gain.connect(ctx.destination);
@@ -137,7 +137,7 @@ export function getElementAnalyser(el) {
137
137
  * 불러 램프를 만들어도 된다.
138
138
  */
139
139
  export function setElementVolume(el, volume) {
140
- const v = 자르기(volume);
140
+ const v = clamp01(volume);
141
141
  _desired.set(el, v);
142
142
  const graph = _graphs.get(el);
143
143
  if (graph) {
@@ -6,4 +6,4 @@
6
6
  * 문장의 앞 무음은 진짜 그 소리의 일부이고, 앞뒤 뜸((74))이 그 위에서 셈된다.
7
7
  * @public
8
8
  */
9
- export declare function computePeaksFromUrl(url: string, bars: number, windowSeconds?: number, 앞무음건너뛰기?: boolean): Promise<number[] | null>;
9
+ export declare function computePeaksFromUrl(url: string, bars: number, windowSeconds?: number, skipLeadingSilence?: boolean): Promise<number[] | null>;
@@ -26,10 +26,10 @@ import { getErrorMessage } from './errorMessage';
26
26
  *
27
27
  * 0.001 은 디더까지 잡고 0.02 는 진짜 작은 소리를 잘라 낸다 — 가운데를 쓴다.
28
28
  */
29
- const 무음기준 = 0.01;
29
+ const SILENCE_LEVEL = 0.01;
30
30
  /** 아무리 조용한 파일이라도 이만큼 넘게는 안 건너뛴다 — 통째로 미끄러지지 않게 */
31
- const 건너뛰기한도초 = 0.3;
32
- async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음건너뛰기 = false) {
31
+ const SKIP_LIMIT_SEC = 0.3;
32
+ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, skipLeadingSilence = false) {
33
33
  /* 컨텍스트는 **어느 길로 나가든** 닫는다 — 디코딩 실패(404·만료 주소의 HTML)·채널 없음에서 닫지 않아
34
34
  파형 하나 실패할 때마다 AudioContext 가 하나씩 남았다(브라우저 동시 한도에 닿으면 이후 파형이 모두 실패). */
35
35
  let audioCtx = null;
@@ -42,24 +42,24 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음
42
42
  return null;
43
43
  /* **앞머리 무음을 건너뛴다**(소음처럼 루프되는 바탕에서만 켠다). 소리 있는 첫 자리를 찾아
44
44
  거기서부터 창을 연다 — 그래야 그린 결이 **실제로 들리는 결**이 된다. */
45
- let 시작 = 0;
46
- if (앞무음건너뛰기) {
47
- const 한도 = Math.floor(건너뛰기한도초 * decoded.sampleRate);
48
- const 끝 = Math.min(channelData.length, 한도);
49
- while (시작 < 끝 && Math.abs(channelData[시작]) <= 무음기준)
50
- 시작++;
51
- if (시작 >= 끝)
52
- 시작 = 0; // 통째로 조용한 파일 — 건너뛰지 않는다
45
+ let offset = 0;
46
+ if (skipLeadingSilence) {
47
+ const limit = Math.floor(SKIP_LIMIT_SEC * decoded.sampleRate);
48
+ const end = Math.min(channelData.length, limit);
49
+ while (offset < end && Math.abs(channelData[offset]) <= SILENCE_LEVEL)
50
+ offset++;
51
+ if (offset >= end)
52
+ offset = 0; // 통째로 조용한 파일 — 건너뛰지 않는다
53
53
  }
54
- const 남은 = channelData.length - 시작;
54
+ const remaining = channelData.length - offset;
55
55
  const total = (windowSeconds && windowSeconds > 0)
56
- ? Math.min(남은, Math.floor(windowSeconds * decoded.sampleRate))
57
- : 남은;
56
+ ? Math.min(remaining, Math.floor(windowSeconds * decoded.sampleRate))
57
+ : remaining;
58
58
  const usableLen = Math.max(bars, total);
59
59
  const blockSize = Math.floor(usableLen / bars) || 1;
60
60
  const peaks = new Array(bars).fill(0).map((_, i) => {
61
- const start = 시작 + i * blockSize;
62
- const end = Math.min(start + blockSize, 시작 + usableLen, channelData.length);
61
+ const start = offset + i * blockSize;
62
+ const end = Math.min(start + blockSize, offset + usableLen, channelData.length);
63
63
  let max = 0;
64
64
  for (let j = start; j < end; j++) {
65
65
  const v = Math.abs(channelData[j]);
@@ -89,14 +89,14 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음
89
89
  * 문장의 앞 무음은 진짜 그 소리의 일부이고, 앞뒤 뜸((74))이 그 위에서 셈된다.
90
90
  * @public
91
91
  */
92
- export async function computePeaksFromUrl(url, bars, windowSeconds, 앞무음건너뛰기 = false) {
92
+ export async function computePeaksFromUrl(url, bars, windowSeconds, skipLeadingSilence = false) {
93
93
  try {
94
94
  const resp = await fetch(url);
95
95
  // 404·만료 주소는 소리가 아니다 — 디코딩까지 가지 않는다
96
96
  if (resp.ok === false)
97
97
  return null;
98
98
  const arrayBuffer = await resp.arrayBuffer();
99
- return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, 앞무음건너뛰기);
99
+ return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds, skipLeadingSilence);
100
100
  }
101
101
  catch (e) {
102
102
  try {
@@ -20,9 +20,9 @@ export function columnsFitting(width, cardWidth, gap) {
20
20
  export function balancedColumns(count, perRow) {
21
21
  if (!(count > 0))
22
22
  return 1;
23
- const 한줄 = Math.max(1, Math.floor(perRow));
24
- if (count <= 한줄)
23
+ const perRowInt = Math.max(1, Math.floor(perRow));
24
+ if (count <= perRowInt)
25
25
  return count;
26
- const 줄수 = Math.ceil(count / 한줄);
27
- return Math.max(1, Math.ceil(count / 줄수));
26
+ const rowCount = Math.ceil(count / perRowInt);
27
+ return Math.max(1, Math.ceil(count / rowCount));
28
28
  }
@@ -31,7 +31,7 @@
31
31
  const SKEW_KEY = 'chunk-reload-ts';
32
32
  /** 이 시간 안에 다시 실패하면 배포 skew 가 아니라 진짜 오류로 본다(무한 새로고침 방지). */
33
33
  const COOLDOWN_MS = 10_000;
34
- function 마지막시각() {
34
+ function lastAt() {
35
35
  try {
36
36
  return Number(sessionStorage.getItem(SKEW_KEY) || 0);
37
37
  }
@@ -48,7 +48,7 @@ function 마지막시각() {
48
48
  * @public
49
49
  */
50
50
  export function recoverFromSkew(now = Date.now()) {
51
- if (now - 마지막시각() <= COOLDOWN_MS)
51
+ if (now - lastAt() <= COOLDOWN_MS)
52
52
  return false;
53
53
  try {
54
54
  sessionStorage.setItem(SKEW_KEY, String(now));
@@ -65,7 +65,7 @@ export function recoverFromSkew(now = Date.now()) {
65
65
  * 무엇을 그릴지 정하는 데 쓴다 — **아무것도 안 그리는 것이 맞다.** 곧 판이 바뀐다.
66
66
  */
67
67
  export function skewReloadPending(now = Date.now()) {
68
- const last = 마지막시각();
68
+ const last = lastAt();
69
69
  return last > 0 && now - last <= COOLDOWN_MS;
70
70
  }
71
71
  /**
@@ -97,18 +97,18 @@ export function skewReloadPending(now = Date.now()) {
97
97
  export function installSkewRecovery() {
98
98
  if (typeof window === 'undefined')
99
99
  return () => { };
100
- const 청크실패 = (e) => { if (recoverFromSkew())
100
+ const onChunkError = (e) => { if (recoverFromSkew())
101
101
  e.preventDefault(); };
102
102
  /* 스타일시트만 본다 — 그림 하나가 깨졌다고 판을 새로 받으면 안 된다 */
103
- const 스타일실패 = (e) => {
103
+ const onStyleError = (e) => {
104
104
  const el = e.target;
105
105
  if (el instanceof HTMLLinkElement && el.rel === 'stylesheet')
106
106
  recoverFromSkew();
107
107
  };
108
- window.addEventListener('vite:preloadError', 청크실패);
109
- window.addEventListener('error', 스타일실패, true);
108
+ window.addEventListener('vite:preloadError', onChunkError);
109
+ window.addEventListener('error', onStyleError, true);
110
110
  return () => {
111
- window.removeEventListener('vite:preloadError', 청크실패);
112
- window.removeEventListener('error', 스타일실패, true);
111
+ window.removeEventListener('vite:preloadError', onChunkError);
112
+ window.removeEventListener('error', onStyleError, true);
113
113
  };
114
114
  }
@@ -12,7 +12,7 @@ export async function copyText(text) {
12
12
  if (typeof document !== 'undefined') {
13
13
  // 숨긴 textarea 에 포커스를 옮기므로, 끝나면 원래 자리로 돌려준다 — 안 돌리면 복사 단추를
14
14
  // 누른 뒤 키보드 포커스가 body 로 떨어져 스크린리더·Tab 이동이 처음부터 다시 시작된다.
15
- const 원래포커스 = document.activeElement;
15
+ const previousFocus = document.activeElement;
16
16
  let ta = null;
17
17
  try {
18
18
  ta = document.createElement('textarea');
@@ -38,9 +38,9 @@ export async function copyText(text) {
38
38
  // 성공·실패·던짐 어느 길이든 textarea 를 거두고 포커스를 되돌린다
39
39
  if (ta?.parentNode)
40
40
  ta.parentNode.removeChild(ta);
41
- if (원래포커스 && 원래포커스 !== document.activeElement && typeof 원래포커스.focus === 'function' && 원래포커스.isConnected) {
41
+ if (previousFocus && previousFocus !== document.activeElement && typeof previousFocus.focus === 'function' && previousFocus.isConnected) {
42
42
  try {
43
- 원래포커스.focus({ preventScroll: true });
43
+ previousFocus.focus({ preventScroll: true });
44
44
  }
45
45
  catch { /* 무시 */ }
46
46
  }
@@ -26,12 +26,12 @@ function mulberry32(seed) {
26
26
  // shuffle/sample은 공용 utils/array로 통일(시드 rng 지원 동일). 여기선 sampleArray 이름만 유지해 내부에서 사용.
27
27
  const sampleArray = sample;
28
28
  /** KST 날짜 'YYYY-MM-DD' → 날 번호(1970-01-01 = 0). 홀짝으로 이틀마다 번갈아 쓸 반쪽을 고른다. */
29
- const 날번호 = (ymd) => {
29
+ const dayNumber = (ymd) => {
30
30
  const [y, m, d] = ymd.split('-').map(Number);
31
31
  return Math.round(Date.UTC(y, (m || 1) - 1, d || 1) / 86_400_000);
32
32
  };
33
33
  /** 항목을 가리키는 열쇠 — 자료의 id(없으면 name). 자료가 늘거나 차례가 바뀌어도 그 항목의 반쪽이 안 바뀌게 */
34
- const 항목열쇠 = (it, i) => {
34
+ const itemKey = (it, i) => {
35
35
  const o = (it ?? {});
36
36
  return String(o.id ?? o.name ?? i);
37
37
  };
@@ -47,23 +47,23 @@ const 항목열쇠 = (it, i) => {
47
47
  * 정하고, 날 번호의 홀짝에 맞는 반쪽에서만 뽑는다** — 이웃한 두 날은 늘 다른 반쪽이라 겹치지 않고, 어제 보기는 어제 계산과 같다.
48
48
  * 두 반쪽이 모두 필요한 개수 이상일 때만(후보가 충분할 때) 이렇게 하고, 모자라면 전처럼 전체에서 뽑는다(빈 세트로 두지 않는다).
49
49
  */
50
- function 유형세트(pool, n, userId, ymd, type) {
50
+ function typeSet(pool, n, userId, ymd, type) {
51
51
  if (!pool.length || n <= 0)
52
52
  return [];
53
53
  const rng = mulberry32(hashSeed(`${userId}|${ymd}|${type}`));
54
- const 쪽 = 날번호(ymd) & 1;
55
- const 이쪽 = pool.filter((it, i) => ((hashSeed(`${userId}|${type}|${항목열쇠(it, i)}`) >>> 7) & 1) === 쪽);
56
- const 충분 = 이쪽.length >= n && pool.length - 이쪽.length >= n;
57
- return sampleArray((충분 ? 이쪽 : pool), n, rng);
54
+ const side = dayNumber(ymd) & 1;
55
+ const thisSide = pool.filter((it, i) => ((hashSeed(`${userId}|${type}|${itemKey(it, i)}`) >>> 7) & 1) === side);
56
+ const enough = thisSide.length >= n && pool.length - thisSide.length >= n;
57
+ return sampleArray((enough ? thisSide : pool), n, rng);
58
58
  }
59
59
  // (user, 날짜)로 결정적 추출 → 기기/세션 무관 동일 선택. 유형마다 시드가 따로다(위 유형세트).
60
60
  function createSelection(counts, userId, ymd) {
61
- const 개수 = (k) => Math.max(0, counts[k] || 0);
61
+ const countOf = (k) => Math.max(0, counts[k] || 0);
62
62
  return {
63
- LISTEN_IN_NOISE: 유형세트(NOISE_DATAS, 개수('LISTEN_IN_NOISE'), userId, ymd, 'LISTEN_IN_NOISE'),
64
- SHORT_STORY: 유형세트(SHORT_STORIES, 개수('SHORT_STORY'), userId, ymd, 'SHORT_STORY'),
65
- LONG_STORY: 유형세트(LONG_STORIES, 개수('LONG_STORY'), userId, ymd, 'LONG_STORY'),
66
- ORDERING_SENTENCE: 유형세트(ORDER_SETS, 개수('ORDERING_SENTENCE'), userId, ymd, 'ORDERING_SENTENCE'),
63
+ LISTEN_IN_NOISE: typeSet(NOISE_DATAS, countOf('LISTEN_IN_NOISE'), userId, ymd, 'LISTEN_IN_NOISE'),
64
+ SHORT_STORY: typeSet(SHORT_STORIES, countOf('SHORT_STORY'), userId, ymd, 'SHORT_STORY'),
65
+ LONG_STORY: typeSet(LONG_STORIES, countOf('LONG_STORY'), userId, ymd, 'LONG_STORY'),
66
+ ORDERING_SENTENCE: typeSet(ORDER_SETS, countOf('ORDERING_SENTENCE'), userId, ymd, 'ORDERING_SENTENCE'),
67
67
  };
68
68
  }
69
69
  export function hasItems(s) {
@@ -18,7 +18,7 @@
18
18
  const KST = 'Asia/Seoul';
19
19
  /* 시간대 없는 벽시계 시각 — 'YYYY-MM-DDTHH:mm[:ss[.fff…]]' 또는 가운데가 빈칸('YYYY-MM-DD HH:mm:ss', Postgres timestamp 꼴).
20
20
  뒤돌아보기 없이(iOS 15 Safari) 끝까지 맞춘다 — 뒤에 Z·±hh:mm 이 붙으면 걸리지 않는다. */
21
- const 벽시계시각 = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?$/;
21
+ const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?$/;
22
22
  /**
23
23
  * 날짜·시각 값을 순간(Date)으로 읽는 **이 파일의 공용 파서.** 못 읽는 값(빈 값·깨진 글자)이면 null.
24
24
  *
@@ -28,13 +28,13 @@ const 벽시계시각 = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2})(?:\.(\
28
28
  * 날짜만 있는 'YYYY-MM-DD' 는 지금 규칙(UTC 자정 = KST 같은 날 9시)을 그대로 따른다.
29
29
  * Date·숫자·오프셋 있는 글자는 그대로.
30
30
  */
31
- function 순간으로(when) {
31
+ function toInstant(when) {
32
32
  let d;
33
33
  if (when instanceof Date)
34
34
  d = when;
35
35
  else if (typeof when === 'string') {
36
36
  const s = when.trim();
37
- const m = 벽시계시각.exec(s);
37
+ const m = WALL_CLOCK.exec(s);
38
38
  /* 소수 초는 세 자리까지만 — 여섯 자리(Postgres)는 엔진마다 읽기가 갈린다 */
39
39
  d = new Date(m ? `${m[1]}T${m[2]}:${m[3] ?? '00'}${m[4] ? `.${m[4].slice(0, 3).padEnd(3, '0')}` : ''}+09:00` : s);
40
40
  }
@@ -44,16 +44,16 @@ function 순간으로(when) {
44
44
  }
45
45
  /* 못 읽는 값(빈 문자열·깨진 날짜)이면 null — 아래 형식 함수들이 화면에 "Invalid Date" 를 그대로 내던 것을 빈 글자로 막는다
46
46
  (같은 파일의 toKstYmd·formatYmd 는 이미 막고 있었다) */
47
- const 읽은날 = (iso) => 순간으로(iso);
47
+ const parsed = (iso) => toInstant(iso);
48
48
  export function formatDateKo(iso) {
49
- const d = 읽은날(iso);
49
+ const d = parsed(iso);
50
50
  if (!d)
51
51
  return '';
52
52
  return d.toLocaleDateString('ko-KR', { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
53
53
  }
54
54
  /** "2024년 1월 15일 오후 03:30" (long month + time) */
55
55
  export function formatDateLongKo(iso) {
56
- const d = 읽은날(iso);
56
+ const d = parsed(iso);
57
57
  if (!d)
58
58
  return '';
59
59
  return d.toLocaleString('ko-KR', {
@@ -70,7 +70,7 @@ export function formatDateLongKo(iso) {
70
70
  */
71
71
  export function formatDateLocale(iso, lang) {
72
72
  const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
73
- const d = 읽은날(iso);
73
+ const d = parsed(iso);
74
74
  if (!d)
75
75
  return '';
76
76
  return d.toLocaleDateString(locale, { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
@@ -78,7 +78,7 @@ export function formatDateLocale(iso, lang) {
78
78
  /** formatDateLongKo의 로케일 인식 버전 — en: "January 15, 2024, 03:30 PM" */
79
79
  export function formatDateLongLocale(iso, lang) {
80
80
  const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
81
- const d = 읽은날(iso);
81
+ const d = parsed(iso);
82
82
  if (!d)
83
83
  return '';
84
84
  return d.toLocaleString(locale, {
@@ -89,7 +89,7 @@ export function formatDateLongLocale(iso, lang) {
89
89
  }
90
90
  /** "2024. 01. 15. 오후 03:30" (2-digit month + time) */
91
91
  export function formatDatetimeKo(iso) {
92
- const d = 읽은날(iso);
92
+ const d = parsed(iso);
93
93
  if (!d)
94
94
  return '';
95
95
  return d.toLocaleString('ko-KR', {
@@ -110,7 +110,7 @@ export function formatDatetimeKo(iso) {
110
110
  export function toKstHm(when) {
111
111
  if (when === null || when === undefined || when === '')
112
112
  return '';
113
- const d = 순간으로(when);
113
+ const d = toInstant(when);
114
114
  if (!d)
115
115
  return '';
116
116
  return new Intl.DateTimeFormat('en-GB', {
@@ -177,7 +177,7 @@ export function parseLocalYmd(ymd) {
177
177
  export function toKstYmd(when) {
178
178
  if (when === null || when === undefined || when === '')
179
179
  return '';
180
- const d = 순간으로(when);
180
+ const d = toInstant(when);
181
181
  if (!d)
182
182
  return '';
183
183
  return new Intl.DateTimeFormat('fr-CA', { timeZone: 'Asia/Seoul' }).format(d);
@@ -191,7 +191,7 @@ export function toKstYmd(when) {
191
191
  export function formatRelativeTime(input, t) {
192
192
  if (!input)
193
193
  return '';
194
- const d = 순간으로(input);
194
+ const d = toInstant(input);
195
195
  if (!d)
196
196
  return '';
197
197
  const then = d.getTime();
@@ -1,5 +1,5 @@
1
1
  export declare const isDev: boolean;
2
- /** `키` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
3
- export declare function devWarnOnce(키: string, ...말: unknown[]): void;
2
+ /** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
3
+ export declare function devWarnOnce(key: string, ...args: unknown[]): void;
4
4
  /** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
5
5
  export declare function __resetDevWarnOnce(): void;
@@ -8,7 +8,7 @@
8
8
  * **왜 한 번만인가** — 컴포넌트는 다시 그려진다. 그릴 때마다 찍으면 콘솔이
9
9
  * 넘쳐서 진짜 봐야 할 것이 묻힌다.
10
10
  */
11
- const 찍은것 = new Set();
11
+ const printed = new Set();
12
12
  export const isDev = (() => {
13
13
  try {
14
14
  if (import.meta.env?.DEV)
@@ -27,14 +27,14 @@ export const isDev = (() => {
27
27
  return false;
28
28
  }
29
29
  })();
30
- /** `키` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
31
- export function devWarnOnce(키, ...말) {
32
- if (!isDev || 찍은것.has(키))
30
+ /** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
31
+ export function devWarnOnce(key, ...args) {
32
+ if (!isDev || printed.has(key))
33
33
  return;
34
- 찍은것.add(키);
35
- console.warn(...말);
34
+ printed.add(key);
35
+ console.warn(...args);
36
36
  }
37
37
  /** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
38
38
  export function __resetDevWarnOnce() {
39
- 찍은것.clear();
39
+ printed.clear();
40
40
  }