@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
@@ -11,16 +11,16 @@ import { getTodayKstDate } from '../utils/date';
11
11
  import DescriptionTooltip from './Tooltip/DescriptionTooltip';
12
12
  import ChartSection, { ChartTotal, ChartLegend } from './ChartSection';
13
13
  import { devWarnOnce } from '../utils/devWarn';
14
- import { firstMondayOfMonth, KO_ORD } from './barChartData';
14
+ import { firstMondayOfMonth, weekLabel } from './barChartData';
15
15
  import { useBarChartControl } from './useBarChartControl';
16
16
  // ─── 막대 렌더링 원시 컴포넌트 ───────────────────────────────────
17
17
  /** 막대 값 — 수가 아니면(undefined·NaN·Infinity) 0 */
18
- const 수 = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
18
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
19
19
  /** 아직 오지 않은 칸인가 — 옛 표시(a === -1)와 새 표시(future)를 함께 본다.
20
20
  * 음수가 값인 차트(baseline)에서는 −1 이 진짜 값일 수 있어 `future` 를 쓴다. */
21
- function 미래칸(d, 옛표시 = true) {
21
+ function isFutureBar(d, showPast = true) {
22
22
  /* `baseline` 을 쓰는 차트는 −1 이 진짜 값이라 옛 표시를 보지 않는다 — 부르는 쪽이 옛표시=false 로 끈다 */
23
- return d.future === true || (옛표시 && d.a === -1);
23
+ return d.future === true || (showPast && d.a === -1);
24
24
  }
25
25
  function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, barGap = 1, height = 140, maxOverride, tightMax, seriesColors, seriesLabels, rateLabels, grouped = false, baseline, focusableBars = false }) {
26
26
  // colorB 미지정 시 단일 계열(A) 모드
@@ -29,24 +29,24 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
29
29
  /* 값이 비었거나(undefined) 수가 아니면 0 으로 친다. 두 계열인데 한 항목의 `b` 가 비면 Math.max 가 NaN 이 되고
30
30
  `NaN || 10` 으로 y축이 데이터와 상관없이 10 에 박혀 막대가 판 위로 넘쳤다(ihab-core-96 이 짚음). */
31
31
  /* 기준선 모드에서는 옛 미래 표시(a === -1)를 보지 않는다 — −1 이 진짜 값이다 */
32
- const 기준 = baseline;
32
+ const base = baseline;
33
33
  const maxVal = useMemo(() => stacked
34
- ? Math.max(0, ...data.filter(d => !미래칸(d, 기준 == null)).map(d => grouped
34
+ ? Math.max(0, ...data.filter(d => !isFutureBar(d, base == null)).map(d => grouped
35
35
  /* 나란히 서면 가장 높은 **낱 막대**가 위를 정한다 — 합으로 잡으면 막대가 판의 몇 분의 일로 눌린다 */
36
- ? Math.max(0, ...(d.parts ?? []).map(수))
37
- : (d.parts ?? []).reduce((s, v) => s + Math.max(0, 수(v)), 0)))
38
- : Math.max(0, ...data.filter(d => !미래칸(d, 기준 == null)).flatMap(d => single ? [수(d.a)] : [수(d.a), 수(d.b)])), [data, single, stacked, grouped]);
36
+ ? Math.max(0, ...(d.parts ?? []).map(num))
37
+ : (d.parts ?? []).reduce((s, v) => s + Math.max(0, num(v)), 0)))
38
+ : Math.max(0, ...data.filter(d => !isFutureBar(d, base == null)).flatMap(d => single ? [num(d.a)] : [num(d.a), num(d.b)])), [data, single, stacked, grouped]);
39
39
  /* 기준선 모드: 0 선에서 얼마나 떨어졌는지(|값 − 기준|)가 축을 정한다 — 위아래가 대칭이라야 같은 크기의 오름·내림이 같은 길이로 읽힌다 */
40
- const 대칭최대 = useMemo(() => {
41
- if (기준 == null)
40
+ const symmetricMax = useMemo(() => {
41
+ if (base == null)
42
42
  return 0;
43
- const 값들 = data.filter(d => !미래칸(d, 기준 == null)).flatMap(d => stacked ? (d.parts ?? []).map(수) : (single ? [수(d.a)] : [수(d.a), 수(d.b)]));
44
- return Math.max(0, ...값들.map(v => Math.abs(v - 기준)));
45
- }, [data, 기준, stacked, single]);
43
+ const values = data.filter(d => !isFutureBar(d, base == null)).flatMap(d => stacked ? (d.parts ?? []).map(num) : (single ? [num(d.a)] : [num(d.a), num(d.b)]));
44
+ return Math.max(0, ...values.map(v => Math.abs(v - base)));
45
+ }, [data, base, stacked, single]);
46
46
  const padded = useMemo(() => {
47
- const m = 기준 != null ? 대칭최대 : maxVal;
47
+ const m = base != null ? symmetricMax : maxVal;
48
48
  return maxOverride ?? (tightMax ? (m > 0 ? m : 10) : (Math.ceil(m / 10) * 10 || 10));
49
- }, [maxVal, 대칭최대, 기준, maxOverride, tightMax]);
49
+ }, [maxVal, symmetricMax, base, maxOverride, tightMax]);
50
50
  const h = height;
51
51
  // 막대 너비 — 여백과 무관하게 막대 '개수'(컬럼 × 계열)가 늘수록 점진 축소(최대 barWidth)
52
52
  const barsPerCol = stacked ? (grouped ? Math.max(1, seriesColors.length) : 1) : (single ? 1 : 2);
@@ -57,109 +57,109 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
57
57
  }, [data.length, barsPerCol, barWidth]);
58
58
  /* 굵기는 인라인 flex/maxWidth 가 아니라 CSS 변수로 박는다 — 소비자가 `!important`
59
59
  없이 CSS 로 덮을 수 있게. 읽는 규칙은 BarChart.css 의 `.manager-bar-chart-bar`·`-stack`. */
60
- const 막대굵기 = { '--bar-width': `${effBarWidth}px` };
60
+ const barWidthStyle = { '--bar-width': `${effBarWidth}px` };
61
61
  /* 나란히 선 막대 사이 간격 — 칸(.manager-bar-chart-col--group)의 gap 으로 내려보낸다 */
62
- const 묶음간격 = { '--bar-group-gap': `${Math.max(0, barGap)}px` };
62
+ const groupGapStyle = { '--bar-group-gap': `${Math.max(0, barGap)}px` };
63
63
  const ticks = useMemo(() => {
64
64
  const mid = Math.round(padded / 2);
65
- if (기준 != null) {
65
+ if (base != null) {
66
66
  /* 위 → 0 → 아래. 맨 아래 값은 x축 자리라 아래 렌더에서 뺀다(기존 규칙과 같다) */
67
- const 위 = [기준 + padded, ...(mid !== padded ? [기준 + mid] : []), 기준];
68
- return [...위, ...(mid !== padded ? [기준 - mid] : []), 기준 - padded];
67
+ const topTicks = [base + padded, ...(mid !== padded ? [base + mid] : []), base];
68
+ return [...topTicks, ...(mid !== padded ? [base - mid] : []), base - padded];
69
69
  }
70
70
  return mid !== padded ? [padded, mid, 0] : [padded, 0];
71
- }, [padded, 기준]);
71
+ }, [padded, base]);
72
72
  /** 값 → 판 위에서의 높이 비율(0~1) 자리 — 기준선 모드는 0 선이 한가운데 */
73
- const 눈금자리 = (t) => (기준 != null ? (1 - (t - (기준 - padded)) / (2 * padded)) : (1 - t / padded));
73
+ const tickPos = (t) => (base != null ? (1 - (t - (base - padded)) / (2 * padded)) : (1 - t / padded));
74
74
  const [tooltip, setTooltip] = useState(null);
75
75
  const handleBarLeave = useCallback(() => setTooltip(null), []);
76
76
  /* **한 칸이 말하는 것을 한 곳에서 짓는다**(말귀 요청 (52), 2026-10-02).
77
77
  전에는 이 셈이 `onMouseEnter` 손잡이 안에만 있어, **얹는 사람만** 수를 읽을 수 있었다.
78
78
  같은 글을 풍선·초점·`aria-label` 셋이 쓰도록 밖으로 뺀다 — 보는 말과 들리는 말이
79
79
  갈리지 않는다. */
80
- const 풍선내용 = useCallback((d, parts) => {
80
+ const tooltipContent = useCallback((d, parts) => {
81
81
  const title = [d.label, d.sublabel].filter(Boolean).join(' ');
82
82
  let items;
83
83
  if (stacked && grouped && d.rawPartsB) {
84
84
  /* 계열별 비율 — 잰 것이 없는 계열은 0% 가 아니라 '-' 다(0% 는 "다 틀렸다" 로 읽힌다) */
85
85
  items = (seriesLabels ?? []).map((lb, k) => {
86
- const 분모 = d.rawPartsB[k] ?? 0;
87
- return { text: 분모 > 0 ? `${lb}: ${parts[k] ?? 0}% (${d.rawParts?.[k] ?? 0}/${분모})` : `${lb}: -`, color: (seriesColors ?? [])[k] };
86
+ const denominator = d.rawPartsB[k] ?? 0;
87
+ return { text: denominator > 0 ? `${lb}: ${parts[k] ?? 0}% (${d.rawParts?.[k] ?? 0}/${denominator})` : `${lb}: -`, color: (seriesColors ?? [])[k] };
88
88
  });
89
89
  }
90
90
  else if (stacked && grouped) {
91
91
  /* 자리가 고정이라 0 도 적는다 — 빈 자리가 무엇인지 알 수 있게 */
92
- items = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${부호붙이기(parts[k] ?? 0)}`, color: (seriesColors ?? [])[k] }));
92
+ items = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${withSign(parts[k] ?? 0)}`, color: (seriesColors ?? [])[k] }));
93
93
  }
94
94
  else if (stacked) {
95
- const 전부 = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${parts[k] ?? 0}`, color: (seriesColors ?? [])[k] }));
96
- const 있는 = 전부.filter((_, k) => (parts[k] ?? 0) > 0);
97
- items = 있는.length ? 있는 : 전부;
95
+ const allItems = (seriesLabels ?? []).map((lb, k) => ({ text: `${lb}: ${parts[k] ?? 0}`, color: (seriesColors ?? [])[k] }));
96
+ const presentItems = allItems.filter((_, k) => (parts[k] ?? 0) > 0);
97
+ items = presentItems.length ? presentItems : allItems;
98
98
  }
99
99
  else if (rateLabels && !single) {
100
- items = [{ text: `${labelA} ${수(d.a)}%`, color: colorA }, { text: `${labelB} ${수(d.b)}%`, color: colorB }];
100
+ items = [{ text: `${labelA} ${num(d.a)}%`, color: colorA }, { text: `${labelB} ${num(d.b)}%`, color: colorB }];
101
101
  }
102
102
  else if (rateLabels && d.rawB !== undefined) {
103
103
  items = [{ text: `${rateLabels.total} ${d.rawB}` }, { text: `${rateLabels.done} ${d.rawA ?? 0}`, color: colorA }];
104
104
  }
105
105
  else if (single) {
106
- items = [{ text: `${labelA}: ${수(d.a)}`, color: colorA }];
106
+ items = [{ text: `${labelA}: ${num(d.a)}`, color: colorA }];
107
107
  }
108
108
  else {
109
- items = [{ text: `${labelA}: ${수(d.a)}`, color: colorA }, { text: `${labelB}: ${수(d.b)}`, color: colorB }];
109
+ items = [{ text: `${labelA}: ${num(d.a)}`, color: colorA }, { text: `${labelB}: ${num(d.b)}`, color: colorB }];
110
110
  }
111
111
  return { title, items };
112
112
  // eslint-disable-next-line react-hooks/exhaustive-deps
113
- }, [stacked, grouped, seriesLabels, seriesColors, rateLabels, single, labelA, labelB, colorA, colorB, 기준]);
114
- const 풍선열기 = useCallback((el, d, parts) => {
113
+ }, [stacked, grouped, seriesLabels, seriesColors, rateLabels, single, labelA, labelB, colorA, colorB, base]);
114
+ const openTooltip = useCallback((el, d, parts) => {
115
115
  const r = el.getBoundingClientRect();
116
- const { title, items } = 풍선내용(d, parts);
116
+ const { title, items } = tooltipContent(d, parts);
117
117
  setTooltip({ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }, title, items });
118
- }, [풍선내용]);
118
+ }, [tooltipContent]);
119
119
  /* 낭독기가 읽을 한 칸의 이름 — 풍선과 같은 글을 한 줄로 잇는다 */
120
- const 막대이름 = useCallback((d, parts) => {
121
- const { title, items } = 풍선내용(d, parts);
120
+ const barLabel = useCallback((d, parts) => {
121
+ const { title, items } = tooltipContent(d, parts);
122
122
  return [title, ...items.map(i => i.text)].filter(Boolean).join(', ');
123
- }, [풍선내용]);
123
+ }, [tooltipContent]);
124
124
  /* 기준선 모드에서는 오름·내림이 뜻이라 양수에도 + 를 붙인다(부호가 없으면 툴팁만 보고 방향을 알 수 없다) */
125
- const 부호붙이기 = (v) => (기준 != null && v > 기준 ? `+${v}` : `${v}`);
125
+ const withSign = (v) => (base != null && v > base ? `+${v}` : `${v}`);
126
126
  /* 떠 있는 동안 **스크롤하거나 막대 밖을 누르면** 닫는다 — 터치에서는 톡이 호환 mouseenter 로 띄우고 mouseleave 는 다른 곳을
127
127
  눌러야 와서, 스크롤해도 제자리에 떠 있었고 누를 수 없는 곳을 톡해도 안 닫혔다(말귀 훈련 현황·매니저 화면, 태블릿). */
128
- const 떠있음 = tooltip != null;
128
+ const isOpen = tooltip != null;
129
129
  useEffect(() => {
130
- if (!떠있음)
130
+ if (!isOpen)
131
131
  return;
132
132
  /* **안-모음: 닫기만 한다**(코어 자체 조사 2026-10-10에서 세어 보고 적음).
133
133
  굴림에 하는 일이 `setTooltip(null)` 하나뿐이라 레이아웃을 안 읽는다. 그리고 **미루면 안 된다** —
134
134
  풍선은 막대에 붙어 떠 있어서, 한 틀 미루면 막대에서 떨어진 풍선이 그만큼 더 허공에 남는다
135
135
  (Tooltip·useAnchoredMenu 의 `close` 길과 같은 가름: *자리 재기는 미뤄도 되고 닫기는 안 된다*). */
136
- const 닫기 = () => setTooltip(null);
137
- const 바깥누름 = (e) => {
136
+ const close = () => setTooltip(null);
137
+ const onOutsidePress = (e) => {
138
138
  const t = e.target;
139
139
  if (t && typeof t.closest === 'function' && t.closest('.manager-bar-chart-col'))
140
140
  return;
141
141
  setTooltip(null);
142
142
  };
143
- window.addEventListener('scroll', 닫기, true);
144
- document.addEventListener('pointerdown', 바깥누름, true);
143
+ window.addEventListener('scroll', close, true);
144
+ document.addEventListener('pointerdown', onOutsidePress, true);
145
145
  return () => {
146
- window.removeEventListener('scroll', 닫기, true);
147
- document.removeEventListener('pointerdown', 바깥누름, true);
146
+ window.removeEventListener('scroll', close, true);
147
+ document.removeEventListener('pointerdown', onOutsidePress, true);
148
148
  };
149
- }, [떠있음]);
150
- return (_jsxs("div", { className: "manager-bar-chart-wrap", style: 묶음간격, children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (
149
+ }, [isOpen]);
150
+ return (_jsxs("div", { className: "manager-bar-chart-wrap", style: groupGapStyle, children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (
151
151
  /* 눈금선은 **적힌 값의 높이**에 둔다 — 가운데 값을 반올림(최대 3 → '2')하면서 자리는 절반(1.5)에 두어 선과 숫자가 어긋났다 */
152
- _jsx("div", { className: `manager-bar-chart-gridline${기준 != null && t === 기준 ? ' manager-bar-chart-gridline--baseline' : ''}`, style: { top: 눈금자리(t) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
153
- const isFuture = 미래칸(d, 기준 == null);
152
+ _jsx("div", { className: `manager-bar-chart-gridline${base != null && t === base ? ' manager-bar-chart-gridline--baseline' : ''}`, style: { top: tickPos(t) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
153
+ const isFuture = isFutureBar(d, base == null);
154
154
  /* 판 높이를 넘지 않는다 — maxOverride(진행률 100)보다 큰 값이 와도 막대가 눈금 위로 삐져나가지 않게 */
155
155
  /* 기준선 모드는 반쪽(h/2)을 쓴다 — 0 선에서 위나 아래로 자란다 */
156
- const 반 = h / 2;
157
- const 높이 = (v) => (기준 != null
158
- ? Math.min(반, (Math.abs(수(v) - 기준) / padded) * 반)
159
- : Math.min(h, Math.max(0, (수(v) / padded) * h)));
160
- const ha = isFuture ? 0 : 높이(d.a);
161
- const hb = isFuture ? 0 : 높이(d.b);
162
- const parts = (d.parts ?? []).map(수);
156
+ const half = h / 2;
157
+ const heightOf = (v) => (base != null
158
+ ? Math.min(half, (Math.abs(num(v) - base) / padded) * half)
159
+ : Math.min(h, Math.max(0, (num(v) / padded) * h)));
160
+ const ha = isFuture ? 0 : heightOf(d.a);
161
+ const hb = isFuture ? 0 : heightOf(d.b);
162
+ const parts = (d.parts ?? []).map(num);
163
163
  return (_jsx("div", { className: `manager-bar-chart-col${grouped && stacked ? ' manager-bar-chart-col--group' : ''}`,
164
164
  /* **손가락은 그대로 둔다 — 걷으면 아이폰에서 풍선이 아예 안 뜬다.**
165
165
  말귀가 "`onClick` 이 없는데 손가락이 뜬다"(요청 (52))고 짚은 것이 맞다.
@@ -169,7 +169,7 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
169
169
  걷으면 데스크톱의 거짓 손가락 하나를 없애는 대신 **손가락으로 쓰는
170
170
  기기에서 수를 읽을 길이 통째로 사라진다.** 얹으면 값이 뜨니 아주 거짓도
171
171
  아니다. 자판 쪽은 아래 `focusableBars` 가 따로 연다. */
172
- style: { cursor: isFuture ? 'default' : 'pointer' }, tabIndex: focusableBars && !isFuture ? 0 : undefined, role: focusableBars && !isFuture ? 'img' : undefined, "aria-label": focusableBars && !isFuture ? 막대이름(d, parts) : undefined, onMouseEnter: isFuture ? undefined : (e) => 풍선열기(e.currentTarget, d, parts), onFocus: isFuture || !focusableBars ? undefined : (e) => 풍선열기(e.currentTarget, d, parts), onBlur: focusableBars ? handleBarLeave : undefined,
172
+ style: { cursor: isFuture ? 'default' : 'pointer' }, tabIndex: focusableBars && !isFuture ? 0 : undefined, role: focusableBars && !isFuture ? 'img' : undefined, "aria-label": focusableBars && !isFuture ? barLabel(d, parts) : undefined, onMouseEnter: isFuture ? undefined : (e) => openTooltip(e.currentTarget, d, parts), onFocus: isFuture || !focusableBars ? undefined : (e) => openTooltip(e.currentTarget, d, parts), onBlur: focusableBars ? handleBarLeave : undefined,
173
173
  /* **수를 자판으로도 읽는다**(말귀 요청 (52), 2026-10-02). 전에는 값이
174
174
  얹어야 뜨는 풍선에만 있어, 자판·낭독기로는 막대 하나하나의 수를 읽을
175
175
  길이 **아예 없었다**. 이름은 풍선과 같은 글로 짓는다 — 보는 말과
@@ -180,26 +180,26 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
180
180
  /* 나란히 — 계열 k 는 늘 k 번째 자리. 0 이어도 자리를 두어 칸마다 같은 색이 같은 곳에 선다 */
181
181
  (seriesColors ?? []).map((c, k) => {
182
182
  const v = parts[k] ?? 0;
183
- if (기준 == null)
184
- return _jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: 높이(v), background: c } }, k);
183
+ if (base == null)
184
+ return _jsx("div", { className: "manager-bar-chart-bar", style: { ...barWidthStyle, height: heightOf(v), background: c } }, k);
185
185
  /* 0 선 위·아래 — 반쪽 둘을 쌓아 둔 자리(slot)에 그려, 나란히 선 칸의 폭 나눔은 그대로 둔다 */
186
- const 아래 = 수(v) < 기준;
187
- return (_jsxs("div", { className: "manager-bar-chart-slot", style: 막대굵기, children: [_jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--up", children: !아래 && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full", style: { height: 높이(v), background: c } }) }), _jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--down", children: 아래 && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full manager-bar-chart-bar--down", style: { height: 높이(v), background: c } }) })] }, k));
186
+ const below = num(v) < base;
187
+ return (_jsxs("div", { className: "manager-bar-chart-slot", style: barWidthStyle, children: [_jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--up", children: !below && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full", style: { height: heightOf(v), background: c } }) }), _jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--down", children: below && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full manager-bar-chart-bar--down", style: { height: heightOf(v), background: c } }) })] }, k));
188
188
  })) : stacked ? (
189
189
  // 계열별 stacked — 아래부터 위로 쌓고, 맨 위 segment만 상단 라운드
190
- _jsx("div", { className: "manager-bar-chart-stack", style: 막대굵기, children: (() => {
190
+ _jsx("div", { className: "manager-bar-chart-stack", style: barWidthStyle, children: (() => {
191
191
  const topK = parts.reduce((acc, v, k) => (v > 0 ? k : acc), -1); // 맨 위(최상단) 세그먼트
192
- let 남은 = h; // 쌓은 합도 판 높이를 넘지 않게 위 조각부터 깎는다
192
+ let remaining = h; // 쌓은 합도 판 높이를 넘지 않게 위 조각부터 깎는다
193
193
  return parts.map((v, k) => {
194
194
  if (v <= 0)
195
195
  return null;
196
- const 조각 = Math.min(남은, (v / padded) * h);
197
- 남은 -= 조각;
198
- return _jsx("div", { className: "manager-bar-chart-stack-segment", style: { height: 조각, background: (seriesColors ?? [])[k], borderRadius: k === topK ? '6px 6px 0 0' : undefined } }, k);
196
+ const piece = Math.min(remaining, (v / padded) * h);
197
+ remaining -= piece;
198
+ return _jsx("div", { className: "manager-bar-chart-stack-segment", style: { height: piece, background: (seriesColors ?? [])[k], borderRadius: k === topK ? '6px 6px 0 0' : undefined } }, k);
199
199
  });
200
- })() })) : (기준 != null ? (_jsx(_Fragment, { children: [{ v: 수(d.a), c: colorA }, ...(single ? [] : [{ v: 수(d.b), c: colorB }])].map(({ v, c }, k) => (_jsxs("div", { className: "manager-bar-chart-slot", style: 막대굵기, children: [_jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--up", children: v >= 기준 && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full", style: { height: 높이(v), background: c } }) }), _jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--down", children: v < 기준 && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full manager-bar-chart-bar--down", style: { height: 높이(v), background: c } }) })] }, k))) })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: ha, background: colorA } }), !single && _jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: hb, background: colorB } })] })))) }, i));
200
+ })() })) : (base != null ? (_jsx(_Fragment, { children: [{ v: num(d.a), c: colorA }, ...(single ? [] : [{ v: num(d.b), c: colorB }])].map(({ v, c }, k) => (_jsxs("div", { className: "manager-bar-chart-slot", style: barWidthStyle, children: [_jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--up", children: v >= base && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full", style: { height: heightOf(v), background: c } }) }), _jsx("div", { className: "manager-bar-chart-slot__half manager-bar-chart-slot__half--down", children: v < base && _jsx("div", { className: "manager-bar-chart-bar manager-bar-chart-bar--full manager-bar-chart-bar--down", style: { height: heightOf(v), background: c } }) })] }, k))) })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "manager-bar-chart-bar", style: { ...barWidthStyle, height: ha, background: colorA } }), !single && _jsx("div", { className: "manager-bar-chart-bar", style: { ...barWidthStyle, height: hb, background: colorB } })] })))) }, i));
201
201
  })] }), _jsx("div", { className: "manager-bar-chart-x-axis", children: data.map((d, i) => {
202
- const isFuture = 미래칸(d, 기준 == null); // 아직 도래하지 않은 날 — 글씨 흐리게
202
+ const isFuture = isFutureBar(d, base == null); // 아직 도래하지 않은 날 — 글씨 흐리게
203
203
  return (_jsxs("span", { className: `manager-bar-chart-x-axis__label${isFuture ? ' manager-bar-chart-x-axis__label--future' : ''}`, children: [_jsxs("span", { className: "caption-1 manager-bar-chart-x-axis__main", children: [d.isToday && _jsx("span", { className: "manager-bar-chart-x-axis__today-dot", style: { background: colorA } }), d.label] }), d.sublabel && _jsx("span", { className: "caption-1", children: d.sublabel })] }, i));
204
204
  }) })] }) }) }), tooltip && (_jsx(DescriptionTooltip, { anchorRect: tooltip.rect, title: tooltip.title, items: tooltip.items, showIndex: false, closeOnOutsideClick: false }))] }));
205
205
  }
@@ -220,20 +220,21 @@ export function BarChartControls({ control }) {
220
220
  * 주 보기의 막대·합계와 월 보기 완료율 주 막대가 **이 한 벌**을 쓴다 — 같은 주가 두 보기에서
221
221
  * 다른 수로 서지 않게(2026-09-12 말귀 검수: 주 보기 25% · 월 보기 38%).
222
222
  */
223
- function 주의날들(data, monday, now, 옛표시 = true) {
224
- const 오늘 = new Date(now.getFullYear(), now.getMonth(), now.getDate());
223
+ function daysOfWeek(data, monday, now, showPast = true) {
224
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
225
225
  return Array.from({ length: 7 }, (_, i) => {
226
226
  const dt = new Date(monday);
227
227
  dt.setDate(monday.getDate() + i);
228
228
  const entry = (data[`${dt.getFullYear()}년 ${dt.getMonth() + 1}월`]?.daily ?? [])[dt.getDate() - 1];
229
- const isFuture = (entry != null && 미래칸(entry, 옛표시)) || dt > 오늘; // 미래 날 — 평균/막대에서 제외
229
+ const isFuture = (entry != null && isFutureBar(entry, showPast)) || dt > today; // 미래 날 — 평균/막대에서 제외
230
230
  return { dt, entry, isFuture };
231
231
  });
232
232
  }
233
233
  /** control(기간)에 따라 data를 막대 배열로 변환 */
234
- function barsFromControl(data, control, t, lang, 옛표시 = true) {
234
+ /* **언어를 인자로 받지 않는다.** 주 라벨을 사전(`weekLabel`)이 쥐게 되자 `lang` 이 할 일이
235
+ 없어졌다 — 가름이 한 자리로 모이면 그 자리로 흘러들던 인자도 함께 사라진다(2026-10-13). */
236
+ function barsFromControl(data, control, t, showPast = true) {
235
237
  const { period, monthRange, weekRange, weekRanges, DOW } = control;
236
- const isKo = lang?.startsWith('ko');
237
238
  const now = getTodayKstDate(); // 칸은 KST 달력 — 오늘·이번 주 표시도 KST 로
238
239
  const nowY = now.getFullYear(), nowM = now.getMonth() + 1, nowD = now.getDate();
239
240
  if (period === 'month') {
@@ -255,19 +256,19 @@ function barsFromControl(data, control, t, lang, 옛표시 = true) {
255
256
  label = t('manager.chart.weekRange', { start: startDay, end: endTxt });
256
257
  // 오늘이 이 주 범위(시작일~+6)에 들어가면 표시 — 마지막 주는 다음 달로 넘어가므로
257
258
  // 날짜로 견준다(달이 같은지로 보면 9/3 이 8월 마지막 주 8/31~9/6 에서 빠진다)
258
- const 오늘 = new Date(nowY, nowM - 1, nowD).getTime();
259
- isToday = 오늘 >= new Date(year, month - 1, startDay).getTime() && 오늘 <= end.getTime();
259
+ const today = new Date(nowY, nowM - 1, nowD).getTime();
260
+ isToday = today >= new Date(year, month - 1, startDay).getTime() && today <= end.getTime();
260
261
  // 시작일이 오늘 이후(또는 미래 달)면 아직 도래하지 않은 주 → 흐리게/막대 미표시
261
262
  isFutureWeek = year > nowY || (year === nowY && (month > nowM || (month === nowM && startDay > nowD)));
262
263
  }
263
- return { ...d, label, sublabel: isKo ? `${KO_ORD[n - 1] ?? n}주` : `Week ${n}`, isToday, future: (d.future === true || isFutureWeek) || undefined, a: isFutureWeek ? -1 : d.a, b: isFutureWeek ? 0 : d.b };
264
+ return { ...d, label, sublabel: weekLabel(t, n - 1), isToday, future: (d.future === true || isFutureWeek) || undefined, a: isFutureWeek ? -1 : d.a, b: isFutureWeek ? 0 : d.b };
264
265
  });
265
266
  }
266
267
  const opt = weekRanges.find(o => o.value === weekRange);
267
268
  if (!opt)
268
269
  return [];
269
270
  const monday = new Date(opt.year, opt.month - 1, firstMondayOfMonth(opt.year, opt.month - 1) + opt.week * 7);
270
- return 주의날들(data, monday, now, 옛표시).map(({ dt, entry, isFuture }) => {
271
+ return daysOfWeek(data, monday, now, showPast).map(({ dt, entry, isFuture }) => {
271
272
  const dM = dt.getMonth() + 1, dD = dt.getDate(), dY = dt.getFullYear();
272
273
  const sameMonth = dM === opt.month && dY === opt.year;
273
274
  return {
@@ -295,44 +296,44 @@ const BAR_SIZE_PRESET = {
295
296
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
296
297
  */
297
298
  export default function BarChart({ focusableBars, size = 'medium', barWidth, titleKey, titleLevel, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, rateAgg = 'dayMean', tightMax = false, avgMode = false, totalFormat, series, seriesMode = 'stack', loading = false, onDetail, headExtra, headSegment, icon, disc, onClick, cardAriaLabel, cardTooltip, detailSize = 32, barGap, baseline, className, inGrid = false, }) {
298
- const { t, i18n } = useTranslation();
299
+ const { t } = useTranslation();
299
300
  const sizePreset = BAR_SIZE_PRESET[size];
300
301
  // 외부 control 미지정 시 내부 컨트롤 사용 (단독 차트 하위 호환)
301
302
  const internalControl = useBarChartControl(data);
302
303
  const control = controlProp ?? internalControl;
303
- const rawBars = useMemo(() => barsFromControl(data, control, t, i18n.language, baseline == null), [data, control, t, i18n.language, baseline]);
304
+ const rawBars = useMemo(() => barsFromControl(data, control, t, baseline == null), [data, control, t, baseline]);
304
305
  // 완료율 모드: a = 진행률(a/b×100). labelBKey 지정 시 b = 정답률(parts[0]/parts[1]×100) 2계열.
305
306
  // 월 보기의 주 막대는 **그 주 경과한 날의 일별 진행률 평균(미기록일 0%)** — 아래 total 과
306
307
  // 주 보기가 쓰는 같은 셈. 주 합 a/b 로 두면 기록 없는 날(b=0)이 빠져 같은 주가 두 보기에서
307
308
  // 다른 수로 선다(주 보기 25% · 월 보기 38%). 다음 달로 넘친 날도 그 주에 든다(주의날들).
308
309
  // 툴팁의 전체/완료(rawA·rawB)는 합 그대로 — 둘 다 사실이다.
309
- const 나란히 = seriesMode === 'group' && !!series && series.length > 0;
310
+ const grouped = seriesMode === 'group' && !!series && series.length > 0;
310
311
  /* barGap 은 나란히 선 막대 **사이** 값이라 그 밖에서는 그릴 자리가 없다 — 조용히 먹으면 "간격을 줬는데 안 변한다" 가 된다 */
311
- if (barGap != null && !나란히) {
312
+ if (barGap != null && !grouped) {
312
313
  devWarnOnce('bar-chart-bargap-without-group', "[BarChart] barGap 은 seriesMode='group' + series 에서만 쓰인다 — 그 밖에서는 아무 일도 하지 않는다.");
313
314
  }
314
315
  const bars = useMemo(() => {
315
316
  if (!rate)
316
317
  return rawBars;
317
- const 월 = control.period === 'month' ? control.monthRange.match(/^(\d+)년 (\d+)월$/) : null;
318
+ const monthMatch = control.period === 'month' ? control.monthRange.match(/^(\d+)년 (\d+)월$/) : null;
318
319
  const now = getTodayKstDate();
319
- const 계열수 = series?.length ?? 0;
320
+ const seriesCount = series?.length ?? 0;
320
321
  /** 계열 k 의 비율과 원래 수 — rateAgg 를 계열마다 따로. 날들은 막대 하나가 품는 경과한 날(주 보기는 그 하루) */
321
- const 계열비율 = (날들) => {
322
+ const seriesRatio = (days) => {
322
323
  const pct = [], ra = [], rb = [];
323
- for (let k = 0; k < 계열수; k++) {
324
- let sa = 0, sb = 0, 비율합 = 0;
325
- for (const 날 of 날들) {
326
- const x = 수(날.a[k]), y = 수(날.b[k]);
324
+ for (let k = 0; k < seriesCount; k++) {
325
+ let sa = 0, sb = 0, ratioSum = 0;
326
+ for (const day of days) {
327
+ const x = num(day.a[k]), y = num(day.b[k]);
327
328
  if (y <= 0)
328
329
  continue; // 그 계열을 안 잰 날 — 분자가 남아 있어도 뺀다(단일 계열 weighted 와 같은 규칙)
329
330
  sa += x;
330
331
  sb += y;
331
- 비율합 += (x / y) * 100;
332
+ ratioSum += (x / y) * 100;
332
333
  }
333
334
  pct.push(rateAgg === 'weighted'
334
335
  ? (sb > 0 ? Math.round((sa / sb) * 100) : 0) // 잰 날만 — Σa/Σb
335
- : (날들.length ? Math.round(비율합 / 날들.length) : 0)); // 안 잰 날은 0% 로 평균
336
+ : (days.length ? Math.round(ratioSum / days.length) : 0)); // 안 잰 날은 0% 로 평균
336
337
  ra.push(sa);
337
338
  rb.push(sb);
338
339
  }
@@ -340,13 +341,13 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
340
341
  };
341
342
  return rawBars.map((d, i) => {
342
343
  let a = d.a === -1 ? -1 : (d.b > 0 ? Math.round((d.a / d.b) * 100) : 0);
343
- if (월 && !미래칸(d)) {
344
- const y = Number(월[1]), m0 = Number(월[2]) - 1;
345
- const 지난날 = 주의날들(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now).filter(x => !x.isFuture);
344
+ if (monthMatch && !isFutureBar(d)) {
345
+ const y = Number(monthMatch[1]), m0 = Number(monthMatch[2]) - 1;
346
+ const pastDays = daysOfWeek(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now).filter(x => !x.isFuture);
346
347
  if (rateAgg === 'weighted') {
347
348
  /* 잰 것이 있는 날만 — 안 한 날을 0% 로 섞으면 "맞힌 비율" 이 내려간다(정답률) */
348
349
  let sa = 0, sb = 0;
349
- for (const { entry: e } of 지난날)
350
+ for (const { entry: e } of pastDays)
350
351
  if (e && e.b > 0) {
351
352
  sa += e.a;
352
353
  sb += e.b;
@@ -354,35 +355,35 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
354
355
  a = sb > 0 ? Math.round((sa / sb) * 100) : 0;
355
356
  }
356
357
  else {
357
- a = 지난날.length
358
- ? Math.round(지난날.reduce((s, { entry: e }) => s + (e && e.b > 0 ? (e.a / e.b) * 100 : 0), 0) / 지난날.length)
358
+ a = pastDays.length
359
+ ? Math.round(pastDays.reduce((s, { entry: e }) => s + (e && e.b > 0 ? (e.a / e.b) * 100 : 0), 0) / pastDays.length)
359
360
  : 0;
360
361
  }
361
362
  }
362
- let 계열 = null;
363
- if (나란히 && !미래칸(d)) {
364
- let 날들;
365
- if (월) {
366
- const y = Number(월[1]), m0 = Number(월[2]) - 1;
367
- 날들 = 주의날들(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now)
363
+ let seriesParts = null;
364
+ if (grouped && !isFutureBar(d)) {
365
+ let days;
366
+ if (monthMatch) {
367
+ const y = Number(monthMatch[1]), m0 = Number(monthMatch[2]) - 1;
368
+ days = daysOfWeek(data, new Date(y, m0, firstMondayOfMonth(y, m0) + i * 7), now)
368
369
  .filter(x => !x.isFuture)
369
370
  .map(({ entry: e }) => ({ a: e?.parts ?? [], b: e?.partsB ?? [] }));
370
371
  }
371
372
  else {
372
- 날들 = [{ a: d.parts ?? [], b: d.partsB ?? [] }];
373
+ days = [{ a: d.parts ?? [], b: d.partsB ?? [] }];
373
374
  }
374
- const r = 계열비율(날들);
375
- 계열 = { parts: r.pct, rawParts: r.ra, rawPartsB: r.rb };
375
+ const r = seriesRatio(days);
376
+ seriesParts = { parts: r.pct, rawParts: r.ra, rawPartsB: r.rb };
376
377
  }
377
378
  return {
378
379
  ...d,
379
380
  a,
380
381
  b: d.a === -1 ? 0 : (labelBKey && d.parts && d.parts[1] > 0 ? Math.round((d.parts[0] / d.parts[1]) * 100) : 0),
381
382
  rawA: d.a === -1 ? -1 : d.a, rawB: d.b,
382
- ...(계열 ?? {}),
383
+ ...(seriesParts ?? {}),
383
384
  };
384
385
  });
385
- }, [rawBars, rate, rateAgg, labelBKey, control.period, control.monthRange, data, 나란히, series?.length]);
386
+ }, [rawBars, rate, rateAgg, labelBKey, control.period, control.monthRange, data, grouped, series?.length]);
386
387
  // 합계 — 완료율 모드는 주/월 모두 "경과한 모든 날의 일별 진행률 평균"(미기록일=0% 포함)으로 통일.
387
388
  // 주: 선택 주의 일별, 월: 선택 월의 일별(주별 집계가 아니라 일 단위라 주↔월 일관성 유지). 미래(a=-1)만 제외.
388
389
  const total = useMemo(() => {
@@ -410,13 +411,13 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
410
411
  /* 월 보기 합계는 **그 달 날짜만** — 주 막대는 월요일 시작 7일이라 달 경계를 넘는다(마지막 주는 다음 달 날, 첫 월요일 앞 날은
411
412
  앞 달 막대). 막대 합으로 내면 8월 합계에 9/1~9/6 이 들고 8/1~8/2 가 빠져, 상세 목록(달력 달)의 건수와 어긋났다.
412
413
  주 보기는 그 주 막대의 합 그대로. */
413
- const 합칠것 = control.period === 'month' ? (data[control.monthRange]?.daily ?? []) : bars;
414
+ const toMerge = control.period === 'month' ? (data[control.monthRange]?.daily ?? []) : bars;
414
415
  /* 기준선 모드는 부호를 살려 더한다 — 오르내림의 **순변화** 가 합계다(양수만 더하면 내린 주가 사라진다).
415
416
  미래 표시(a === -1)는 여기서도 뺀다 — 기준선 모드에서도 -1 은 '아직 오지 않은 칸' 이다(실제 -1 이 나올 수 있으면 그 칸을 자료에서 뺀다). */
416
417
  if (baseline != null) {
417
- return 합칠것.reduce((s, d) => s + (미래칸(d, false) ? 0 : 수(d.a) + (labelBKey ? 수(d.b) : 0)), 0);
418
+ return toMerge.reduce((s, d) => s + (isFutureBar(d, false) ? 0 : num(d.a) + (labelBKey ? num(d.b) : 0)), 0);
418
419
  }
419
- return 합칠것.reduce((s, d) => s + (d.a > 0 ? d.a : 0) + (labelBKey && d.b > 0 ? d.b : 0), 0);
420
+ return toMerge.reduce((s, d) => s + (d.a > 0 ? d.a : 0) + (labelBKey && d.b > 0 ? d.b : 0), 0);
420
421
  }, [bars, rawBars, rate, rateAgg, avgMode, baseline, labelBKey, control.period, control.monthRange, data]);
421
422
  const legendItems = series && series.length > 0
422
423
  ? series.map(s => ({ color: s.color, label: s.label }))
@@ -441,13 +442,13 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
441
442
  * 이름은 바깥에서 더 받지 않는다 — **이미 `titleKey` 를 받고 있다.** 제목이 없을 때만 예전
442
443
  * 글자로 떨어뜨려 쓰던 자리가 안 깨지게 한다.
443
444
  */
444
- const 카드이름 = titleKey ? t(titleKey) : '';
445
- const 상세이름 = 카드이름
446
- ? t('trainingStatus.detailFor', { name: 카드이름, defaultValue: '{{name}} 상세보기' })
445
+ const cardTitle = titleKey ? t(titleKey) : '';
446
+ const detailTitle = cardTitle
447
+ ? t('trainingStatus.detailFor', { name: cardTitle, defaultValue: '{{name}} 상세보기' })
447
448
  : t('trainingStatus.detail', '상세보기');
448
449
  return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, icon: icon, disc: disc, onClick: onClick, ariaLabel: cardAriaLabel, tooltip: cardTooltip, headSegment: headSegment,
449
450
  /* 좁아 고르개가 '⋯' 로 접히면 상세보기도 그 메뉴로 들어간다 — 머리에 단추 둘이 남지 않게 */
450
- headMenuItems: onDetail ? [{ label: 상세이름, icon: 'list', onClick: onDetail, closeOnSelect: true }] : undefined, title: t(titleKey), titleLevel: titleLevel, titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : ({ collapsed }) => _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && !collapsed && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: detailSize, type: "dot", icon: "list", ariaLabel: 상세이름, showTooltipInfo: 상세이름, onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
451
+ headMenuItems: onDetail ? [{ label: detailTitle, icon: 'list', onClick: onDetail, closeOnSelect: true }] : undefined, title: t(titleKey), titleLevel: titleLevel, titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? (headExtra ?? undefined) : ({ collapsed }) => _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), headExtra, onDetail && !collapsed && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: detailSize, type: "dot", icon: "list", ariaLabel: detailTitle, showTooltipInfo: detailTitle, onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
451
452
  // 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
452
453
  _jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
453
454
  // 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
@@ -460,5 +461,5 @@ export default function BarChart({ focusableBars, size = 'medium', barWidth, tit
460
461
  → 막대 156px 로 카드마다 30px, 섹션 372 → 342px.
461
462
  그래서 **그려질 것과 같은 뼈대**(칸 자리 + 축 한 줄)를 세우고 그 안에 스피너를 둔다.
462
463
  키는 막대 자리(`sizePreset.height`)에서 오므로 크기 보기(size)가 달라도 따라온다. */
463
- _jsx("div", { className: "manager-bar-chart-wrap manager-bar-chart-loading", "aria-busy": "true", children: _jsx("div", { className: "manager-bar-chart-inner", children: _jsxs("div", { className: "manager-bar-chart-area manager-bar-chart-area--loading", children: [_jsx("div", { className: "manager-bar-chart-plot manager-bar-chart-plot--loading", style: { height: sizePreset.height }, children: _jsx(Loader, { size: 32 }) }), _jsx("div", { className: "manager-bar-chart-x-axis", "aria-hidden": "true", children: _jsxs("span", { className: "manager-bar-chart-x-axis__label", children: [_jsx("span", { className: "caption-1 manager-bar-chart-x-axis__main", children: "\u00A0" }), bars.some(b => !!b.sublabel) && _jsx("span", { className: "caption-1", children: "\u00A0" })] }) })] }) }) })) : (_jsx(BarChartCanvas, { focusableBars: focusableBars, data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, barGap: barGap, baseline: baseline, height: sizePreset.height, maxOverride: rate ? 100 : undefined, tightMax: tightMax, seriesColors: series?.map(s => s.color), seriesLabels: series?.map(s => s.label), rateLabels: rate ? { total: t('trainingStatus.today.tooltipTotal', '전체'), done: t('trainingStatus.today.tooltipDone', '완료') } : undefined, grouped: 나란히 })) }));
464
+ _jsx("div", { className: "manager-bar-chart-wrap manager-bar-chart-loading", "aria-busy": "true", children: _jsx("div", { className: "manager-bar-chart-inner", children: _jsxs("div", { className: "manager-bar-chart-area manager-bar-chart-area--loading", children: [_jsx("div", { className: "manager-bar-chart-plot manager-bar-chart-plot--loading", style: { height: sizePreset.height }, children: _jsx(Loader, { size: 32 }) }), _jsx("div", { className: "manager-bar-chart-x-axis", "aria-hidden": "true", children: _jsxs("span", { className: "manager-bar-chart-x-axis__label", children: [_jsx("span", { className: "caption-1 manager-bar-chart-x-axis__main", children: "\u00A0" }), bars.some(b => !!b.sublabel) && _jsx("span", { className: "caption-1", children: "\u00A0" })] }) })] }) }) })) : (_jsx(BarChartCanvas, { focusableBars: focusableBars, data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, barGap: barGap, baseline: baseline, height: sizePreset.height, maxOverride: rate ? 100 : undefined, tightMax: tightMax, seriesColors: series?.map(s => s.color), seriesLabels: series?.map(s => s.label), rateLabels: rate ? { total: t('trainingStatus.today.tooltipTotal', '전체'), done: t('trainingStatus.today.tooltipDone', '완료') } : undefined, grouped: grouped })) }));
464
465
  }
@@ -38,23 +38,23 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
38
38
  const root = rootRef.current;
39
39
  if (!root)
40
40
  return;
41
- const 재기 = () => {
41
+ const measure = () => {
42
42
  const cs = typeof getComputedStyle === 'function' ? getComputedStyle(root) : null;
43
- const 안쪽 = root.clientWidth - (parseFloat(cs?.paddingLeft ?? '') || 0) - (parseFloat(cs?.paddingRight ?? '') || 0);
43
+ const innerWidth = root.clientWidth - (parseFloat(cs?.paddingLeft ?? '') || 0) - (parseFloat(cs?.paddingRight ?? '') || 0);
44
44
  // 왼쪽 묶음은 항목 수와 무관하게 늘 같은 폭이다(모드 전환을 안 쓰면 아예 없다)
45
- const 왼 = root.querySelector('.bottom-nav__left');
46
- const 왼구분선 = 왼?.nextElementSibling;
47
- const 왼폭 = (왼?.offsetWidth ?? 0)
48
- + (왼 && 왼구분선?.classList.contains('bottom-nav__divider') ? 왼구분선.offsetWidth : 0);
49
- setAvail(Math.max(0, 안쪽 - 왼폭));
45
+ const leftEl = root.querySelector('.bottom-nav__left');
46
+ const leftDivider = leftEl?.nextElementSibling;
47
+ const leftWidth = (leftEl?.offsetWidth ?? 0)
48
+ + (leftEl && leftDivider?.classList.contains('bottom-nav__divider') ? leftDivider.offsetWidth : 0);
49
+ setAvail(Math.max(0, innerWidth - leftWidth));
50
50
  };
51
- 재기();
51
+ measure();
52
52
  // ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
53
53
  // 그대로 던져 렌더가 통째로 깨진다. 위에서 초기값은 이미 쟀으므로 추적만 포기한다
54
54
  // (Popup·CrosswordGame·Tooltip 과 같은 처리).
55
55
  if (typeof ResizeObserver === 'undefined')
56
56
  return;
57
- const ro = new ResizeObserver(재기);
57
+ const ro = new ResizeObserver(measure);
58
58
  ro.observe(root);
59
59
  /* **왼쪽 묶음도 본다**(말귀 요청 (71), 2026-10-02).
60
60
  `avail` 은 `바 안쪽 - 왼쪽 묶음` 이라 **왼쪽이 바뀌면 값이 바뀐다.** 그런데 보개는
@@ -65,21 +65,21 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
65
65
  없고, `/manager` 에서는 지금 보고 있는 화면의 아이콘이 반쪽이었다).
66
66
  놀이터에서 바 폭은 그대로 두고 왼쪽만 52 → 160px 로 넓혀 봤더니 **칸 수가 그대로**였다
67
67
  — 보개가 그 변화를 안 본다는 뜻이다. */
68
- const 왼 = root.querySelector('.bottom-nav__left');
69
- if (왼)
70
- ro.observe(왼);
68
+ const leftEl = root.querySelector('.bottom-nav__left');
69
+ if (leftEl)
70
+ ro.observe(leftEl);
71
71
  return () => ro.disconnect();
72
72
  /* 왼쪽 묶음은 `onAppModeChange` 가 있을 때만 선다 — 그 유무가 바뀌면 **볼 대상이 바뀌므로**
73
73
  보개를 다시 건다. 항목 수는 `avail` 에 안 들어가므로(그게 이 셈의 요점이다) 안 넣는다. */
74
74
  }, [onAppModeChange]);
75
75
  const MORE_W = BTN_SIZE + 1;
76
76
  /** 폭 w 에 들어가는 칸 수 — n칸은 n*52 + (n-1)*10 을 쓰므로 n <= (w + 10) / 62 */
77
- const 칸수 = (w) => Math.max(0, Math.floor((w + BTN_GAP) / (BTN_SIZE + BTN_GAP)));
78
- const 다들어감 = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= avail;
77
+ const fitCount = (w) => Math.max(0, Math.floor((w + BTN_GAP) / (BTN_SIZE + BTN_GAP)));
78
+ const allFit = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= avail;
79
79
  const n = avail <= 0 ? itemsLen
80
- : (moreItems.length === 0 && 다들어감) ? itemsLen
80
+ : (moreItems.length === 0 && allFit) ? itemsLen
81
81
  // "…" 가 설 자리를 뺀 폭으로 센다 — 유틸이 있으면 "…" 는 늘 선다
82
- : Math.min(itemsLen, 칸수(avail - MORE_W));
82
+ : Math.min(itemsLen, fitCount(avail - MORE_W));
83
83
  const overflow = avail > 0 && n < itemsLen;
84
84
  const maxVisible = overflow ? n : itemsLen;
85
85
  const visibleItems = items.slice(0, maxVisible);
@@ -106,7 +106,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
106
106
  넘친 항목·유틸 항목의 배지를 **합한** 것이라 그 단추의 수가 아니다 — `더보기, 3개` 는
107
107
  "더보기에 3개가 들어 있다" 로 읽히는데, 안에 든 항목 수와도 다르다. `activeMore` 가
108
108
  있을 때는 더 어긋났다: `설정, 3개` 의 3 은 알림의 것이었다. */
109
- const 안에든수 = useCallback((label, count) => count > 0 ? t('nav.badgeInsideLabel', { label, count, defaultValue: '{{label}}, 안에 알림 {{count}}개' }) : label, [t]);
109
+ const visibleCount = useCallback((label, count) => count > 0 ? t('nav.badgeInsideLabel', { label, count, defaultValue: '{{label}}, 안에 알림 {{count}}개' }) : label, [t]);
110
110
  const handleModeChange = useCallback(() => onAppModeChange?.(nextMode), [onAppModeChange, nextMode]);
111
111
  // 넘친 nav 항목 또는 유틸 항목이 현재 활성 상태면 … 버튼에 그 아이콘/선택표시를 반영
112
112
  const activeMore = useMemo(() => hiddenItems.find(it => !!it.key && it.key === activeKey) ?? moreItems.find(it => it.selected || (it.key && it.key === activeKey)), [hiddenItems, activeKey, moreItems]);
@@ -154,5 +154,5 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
154
154
  onChange?.(it.key);
155
155
  scrollNavTargetToTop(scrollContainer, 'BottomNav');
156
156
  } } }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key ?? `__slot-${i}`));
157
- }) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: 안에든수(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
157
+ }) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: visibleCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
158
158
  }