@ihabdevteam/core 0.133.0 → 0.135.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 (69) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +8 -2
  8. package/dist/packages/core/src/components/BarChart.js +3 -1
  9. package/dist/packages/core/src/components/Breadcrumb.js +5 -1
  10. package/dist/packages/core/src/components/Dialog.js +32 -9
  11. package/dist/packages/core/src/components/Header.css +2 -0
  12. package/dist/packages/core/src/components/Header.js +4 -1
  13. package/dist/packages/core/src/components/LandingHeader.js +18 -1
  14. package/dist/packages/core/src/components/Pagination.js +5 -2
  15. package/dist/packages/core/src/components/Panel.d.ts +1 -1
  16. package/dist/packages/core/src/components/Panel.js +4 -1
  17. package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
  18. package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
  19. package/dist/packages/core/src/components/PaymentPlan.js +5 -3
  20. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
  21. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
  22. package/dist/packages/core/src/components/Popup/ListPopup.js +7 -2
  23. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
  24. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
  25. package/dist/packages/core/src/components/PromoBanner.js +1 -1
  26. package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
  27. package/dist/packages/core/src/components/RingChart.js +14 -3
  28. package/dist/packages/core/src/components/SideNav.js +1 -1
  29. package/dist/packages/core/src/components/SoundCompass.js +10 -1
  30. package/dist/packages/core/src/components/Table.js +15 -5
  31. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +7 -2
  32. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  33. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
  34. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
  35. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  36. package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
  37. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
  38. package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
  39. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -2
  40. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -1
  41. package/dist/packages/core/src/components/barChartData.d.ts +8 -0
  42. package/dist/packages/core/src/components/barChartData.js +21 -0
  43. package/dist/packages/core/src/components/useBarChartControl.js +5 -3
  44. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
  45. package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
  46. package/dist/packages/core/src/hooks/useEditableList.js +20 -1
  47. package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
  48. package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
  49. package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
  50. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
  51. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
  52. package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
  53. package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
  54. package/dist/packages/core/src/i18n/locales/en.json +13 -1
  55. package/dist/packages/core/src/i18n/locales/ko.json +13 -1
  56. package/dist/packages/core/src/utils/format.js +17 -1
  57. package/dist/packages/core/src/utils/getTrainingData.js +11 -4
  58. package/dist/packages/core/src/utils/imageConfig.js +6 -3
  59. package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
  60. package/dist/packages/core/src/utils/pressable.js +4 -0
  61. package/dist/packages/core/src/utils/savedWordTables.js +2 -1
  62. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
  63. package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
  64. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
  65. package/dist/packages/core/src/utils/wordTrainingRuntime.js +38 -17
  66. package/dist/packages/core/src/version.d.ts +1 -1
  67. package/dist/packages/core/src/version.js +1 -1
  68. package/dist/utils.cjs.js +2 -2
  69. package/package.json +1 -1
@@ -11,6 +11,15 @@ const RING_SIZE_PRESET = {
11
11
  large: { diameter: 160, strokeWidth: 22 },
12
12
  };
13
13
  /** 원시 링 렌더링 — 도넛 링 하나만 그린다(헤더/legend 없음) */
14
+ /** 비율 → 퍼센트 정수. **다 채우기 전에는 100 을 내지 않는다** — 반올림하면 199/200 이 '100%' 인데 링 끝은 비고 범례는
15
+ * '미완료 1건' 이었다(말귀 숙제 완료율). 같은 까닭으로 0 초과면 0 을 내지 않는다. */
16
+ function 퍼센트(ratio) {
17
+ if (!Number.isFinite(ratio) || ratio <= 0)
18
+ return 0;
19
+ if (ratio >= 1)
20
+ return 100;
21
+ return Math.min(99, Math.max(1, Math.round(ratio * 100)));
22
+ }
14
23
  function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', size = 'medium', strokeWidth, label = '%', hideCenter = false, hideLabel = false }) {
15
24
  const dim = typeof size === 'number' ? size : RING_SIZE_PRESET[size].diameter;
16
25
  const stroke = strokeWidth ?? (typeof size === 'number' ? 12 : RING_SIZE_PRESET[size].strokeWidth);
@@ -23,7 +32,9 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
23
32
  const acc = out.length > 0 ? out[out.length - 1].acc : 0;
24
33
  // 누적 비율이 1을 넘지 않도록 clamp — 호출자가 넘긴 ratio 합이 100%를 넘으면 이후
25
34
  // 구간이 원을 한 바퀴 넘어 겹쳐 그려지는 대신 조용히 잘리도록(오류 대신 방어적 저하)
26
- const ratio = Math.max(0, Math.min(s.ratio, 1 - acc));
35
+ // NaN(잘못된 날짜로 남은 날 수 등)은 0 으로 — 그대로 두면 누적값이 NaN 으로 번져 뒤 구간이 전부 사라졌다
36
+ const r = Number.isFinite(s.ratio) ? s.ratio : 0;
37
+ const ratio = Math.max(0, Math.min(r, 1 - acc));
27
38
  out.push({ color: s.color, rotate: -90 + acc * 360, dash: circumference * ratio, acc: acc + ratio });
28
39
  return out;
29
40
  }, []);
@@ -32,7 +43,7 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
32
43
  const pct = (total ?? 0) > 0 ? Math.min((done ?? 0) / total, 1) : 0;
33
44
  const filled = circumference * pct;
34
45
  const gap = circumference - filled;
35
- return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children: Math.round(pct * 100) }), !hideLabel && (_jsx("span", { className: `ring-chart__label ${dim <= 96 ? 'caption-2' : 'caption-1'}${dim <= 96 || size === 'medium' ? ' ring-chart__label--tight' : ''}`, children: label }))] }))] }));
46
+ return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children: 퍼센트(pct) }), !hideLabel && (_jsx("span", { className: `ring-chart__label ${dim <= 96 ? 'caption-2' : 'caption-1'}${dim <= 96 || size === 'medium' ? ' ring-chart__label--tight' : ''}`, children: label }))] }))] }));
36
47
  }
37
48
  /**
38
49
  * 도넛 링 차트 — 전체 대비 완료 비율을 원형 호로 표현.
@@ -44,7 +55,7 @@ export default function RingChart({ titleKey, headRight, icon, showTotal, totalL
44
55
  const { t } = useTranslation();
45
56
  if (!titleKey)
46
57
  return _jsx(RingChartCanvas, { ...ringProps });
47
- const pct = (ringProps.total ?? 0) > 0 ? Math.round(Math.min((ringProps.done ?? 0) / ringProps.total, 1) * 100) : 0;
58
+ const pct = (ringProps.total ?? 0) > 0 ? 퍼센트(Math.min((ringProps.done ?? 0) / ringProps.total, 1)) : 0;
48
59
  // size='small' — 대시보드 작은 그리드용 블럭. 컨트롤(headRight)만 생략하고 total/legend는 medium과 동일하게 유지
49
60
  const isSmall = ringProps.size === 'small';
50
61
  // size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
@@ -51,7 +51,7 @@ function SideNavButton(props) {
51
51
  }
52
52
  onClick?.(key, e.currentTarget, fromPointer);
53
53
  }, [isDisabled, key, onClick]);
54
- return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label": "loading" }))] }));
54
+ return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
55
55
  }
56
56
  /** @public */
57
57
  export default function SideNav(props) {
@@ -6,6 +6,7 @@
6
6
  'use client';
7
7
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import { useMemo } from 'react';
9
+ import { useTranslation } from 'react-i18next';
9
10
  import Check from 'lucide-react/dist/esm/icons/check.mjs';
10
11
  import Volume2 from 'lucide-react/dist/esm/icons/volume-2.mjs';
11
12
  import User from 'lucide-react/dist/esm/icons/user.mjs';
@@ -15,6 +16,7 @@ import './SoundCompass.css';
15
16
  const lerp = (a, b, t) => a + (b - a) * t;
16
17
  /** @public */
17
18
  export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSpeakerId = null, playedIds, selectedId = null, answerId = null, showFeedback = false, pulseSelected = false, pulseSelectedDuration, pulseScale, clickable = false, onSelectSpeaker, buttonSize = 40, showIcon = true, onCenterClick, centerLabel, centerDisabled = false, centerAttention = false, shortcutAnchor = 'top-right', shortcutScale, scaleShortcut = false, }) {
19
+ const { t: 번역 } = useTranslation();
18
20
  const t = Math.max(0, Math.min(1, viewT));
19
21
  const CENTER = size / 2;
20
22
  // ── 탑뷰 좌표 ──────────────────────────────────────────────────────────
@@ -119,6 +121,13 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
119
121
  }
120
122
  // ── 스피커 시각 상태를 props에서 파생 ──────────────────────────────────
121
123
  const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
124
+ const stateLabel = (state) => ({
125
+ idle: '',
126
+ active: 번역('soundCompass.state.active', '소리 나는 중'),
127
+ played: 번역('soundCompass.state.played', '들음'),
128
+ correct: 번역('soundCompass.state.correct', '정답'),
129
+ wrong: 번역('soundCompass.state.wrong', '오답'),
130
+ })[state];
122
131
  const getSpeakerState = (sp) => {
123
132
  if (activeSpeakerId === sp.id)
124
133
  return 'active';
@@ -209,6 +218,6 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
209
218
  ...(pulseScale !== undefined
210
219
  ? { ['--sc-pulse-scale']: String(pulseScale) }
211
220
  : null),
212
- } })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": sp.label, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
221
+ } })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": stateLabel(state) ? `${sp.label}, ${stateLabel(state)}` : sp.label, "aria-pressed": selectedId != null ? sp.id === selectedId : undefined, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
213
222
  })] }) }));
214
223
  }
@@ -77,17 +77,27 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
77
77
  const defaultColsRef = useRef([]);
78
78
  // fit 모드에서는 이웃(next)에서 같은 만큼 덜어 오므로 그 컬럼의 시작 폭도 들고 있어야 한다
79
79
  const dragRef = useRef(null);
80
- const [colWidths, setColWidths] = useState(() => {
81
- if (!storeKey || typeof window === 'undefined')
80
+ /* 저장한 폭 읽기 — 숫자이기만 하면 받던 것을 **유한하고 최소 폭 이상인 수**만 받는다(0·음수·NaN 이 들어오면 격자 틀이
81
+ 무효가 됐다). */
82
+ const 저장폭읽기 = (key) => {
83
+ if (!key || typeof window === 'undefined')
82
84
  return null;
83
85
  try {
84
- const arr = JSON.parse(window.localStorage.getItem(storeKey) ?? 'null');
85
- return Array.isArray(arr) && arr.every((n) => typeof n === 'number') ? arr : null;
86
+ const arr = JSON.parse(window.localStorage.getItem(key) ?? 'null');
87
+ return Array.isArray(arr) && arr.length > 0 && arr.every((n) => typeof n === 'number' && Number.isFinite(n) && n > 0) ? arr : null;
86
88
  }
87
89
  catch {
88
90
  return null;
89
91
  }
90
- });
92
+ };
93
+ const [colWidths, setColWidths] = useState(() => 저장폭읽기(storeKey));
94
+ /* 마운트된 채 resizeStorageKey 가 바뀌면 **새 열쇠의 폭**을 읽는다 — 마운트 때 한 번만 읽어, 열 수가 같으면 앞 열쇠의 폭이
95
+ 다른 열에 그대로 씌워졌다. 렌더 중에 맞춘다(바뀐 그 렌더에서 옛 폭이 한 번이라도 그려지지 않게). */
96
+ const [읽은열쇠, set읽은열쇠] = useState(storeKey);
97
+ if (읽은열쇠 !== storeKey) {
98
+ set읽은열쇠(storeKey);
99
+ setColWidths(저장폭읽기(storeKey));
100
+ }
91
101
  // 최신 colWidths를 참조로 유지 (드래그 종료 시 저장용)
92
102
  const colWidthsRef = useRef(colWidths);
93
103
  useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
@@ -87,6 +87,11 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
87
87
  return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
88
88
  ? 'chevron-right'
89
89
  : item.badgeCount
90
- ? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-label": `${item.badgeCount}` })
91
- : undefined, label: item.label, ariaLabel: item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
90
+ ? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-hidden": true })
91
+ : undefined, label: item.label,
92
+ /* 개수는 이름에 붙인다 — ariaLabel 이 글자뿐이라 배지 수가 이름에서 빠져 "알림" 으로만 읽혔다
93
+ (SideNav·BottomNav 와 같은 nav.badgeLabel "알림, 5개"). */
94
+ ariaLabel: item.label && item.badgeCount && item.badgeCount > 0
95
+ ? t('nav.badgeLabel', { label: item.label, count: item.badgeCount, defaultValue: '{{label}}, {{count}}개' })
96
+ : item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
92
97
  }
@@ -47,7 +47,9 @@ export default function TrainingBlockButton(props) {
47
47
  if (btnHover && !open && useDescriptionTooltip)
48
48
  setBtnAnchorRect(btnRef.current?.getBoundingClientRect() ?? null);
49
49
  }, [btnHover, open, useDescriptionTooltip]);
50
- const handleKeyDown = useCallback((e) => { if (e.key === 'Enter' || e.key === ' ') {
50
+ /* 블록 자신에서 난 키만 — 안의 '더보기'·'미리 듣기' 단추에서 누른 Enter 를 가로채 훈련 팝업을 열었다(메뉴를 키보드로 못 엶) */
51
+ const handleKeyDown = useCallback((e) => { if (e.target !== e.currentTarget)
52
+ return; if (e.key === 'Enter' || e.key === ' ') {
51
53
  e.preventDefault();
52
54
  handleActivate();
53
55
  } }, [handleActivate]);
@@ -203,6 +203,9 @@ function TrainingPagePopupInner(props) {
203
203
  setMainVolume,
204
204
  }), [noiseSettings, mergeAndSaveSettings, registerAnalyser, mainAnalyser, noiseAnalyser, playbackRate, setPlaybackRate, mainVolume, setMainVolume]);
205
205
  const panelRef = useRef(null);
206
+ /* '다시하기' 횟수 — 단계들의 key 에 넣어 **처음부터 다시 그린다.** 단계 컴포넌트는 내용(문장·보기)이 바뀔 때만 제 상태를
207
+ 비워, 같은 문항을 다시 풀면 옛 선택 강조·옛 배치(순서화는 정답 배치일 수도)가 그대로 남았다. */
208
+ const [replayCount, setReplayCount] = useState(0);
206
209
  const [mode, setMode] = useState('center');
207
210
  const [closing, setClosing] = useState(false);
208
211
  // 닫힘 애니메이션(PAGE_POPUP_CLOSE_MS) 동안 다른 닫기 경로(ESC/닫기 버튼/완료)가 또 실행되면
@@ -728,16 +731,38 @@ function TrainingPagePopupInner(props) {
728
731
  return null;
729
732
  }
730
733
  };
734
+ /* 다음 문제로 넘어가면 포커스를 새 문제의 첫 칸으로. '다음 문제' 는 누르는 즉시 비활성이 되어 포커스가 body 로 빠졌다 —
735
+ 키보드 사용자는 매 문제 판 처음부터 Tab 을 다시 밟았다. 포커스가 판 안 다른 곳에 있으면 건드리지 않는다. */
736
+ const 앞쪽Ref = useRef(undefined);
737
+ useEffect(() => {
738
+ const 앞 = 앞쪽Ref.current;
739
+ 앞쪽Ref.current = effectivePageIndex;
740
+ if (앞 === undefined || 앞 === effectivePageIndex)
741
+ return;
742
+ const panel = panelRef.current;
743
+ if (!panel)
744
+ return;
745
+ const active = document.activeElement;
746
+ const 잃음 = !active || active === document.body || !panel.contains(active) || active.disabled === true;
747
+ if (!잃음)
748
+ return;
749
+ const 첫칸 = panel.querySelector(`.training-steps ${FOCUSABLE_SELECTOR.split(', ').join(', .training-steps ')}`)
750
+ ?? Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).find((el) => !el.closest('[aria-hidden="true"]'));
751
+ try {
752
+ 첫칸?.focus();
753
+ }
754
+ catch (e) { }
755
+ }, [effectivePageIndex]);
731
756
  const scenarioContent = hasScenario
732
757
  ? scenarioPages.length > 0
733
- ? _jsx("div", { className: "training-steps", children: currentScenarioSteps.map((step, index) => renderScenarioStep(step, index)) })
758
+ ? _jsx("div", { className: "training-steps", children: currentScenarioSteps.map((step, index) => renderScenarioStep(step, index)) }, `replay-${replayCount}`)
734
759
  : (_jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "pp-empty", children: scenario?.emptyMessage || t('training.noQuestions', '문제가 없습니다.') }) }))
735
760
  : null;
736
761
  const childrenContent = isChildrenMode
737
762
  ? (_jsxs(TrainingPagePopupContext.Provider, { value: childrenContextValue, children: [currentChildrenPage?.pageTop ? (_jsx("div", { className: "training-page-top", children: currentChildrenPage.pageTop })) : null, _jsx("div", { className: "training-steps", children: compoundChildrenSteps.map((child, index) => {
738
763
  const el = child;
739
764
  return cloneElement(el, { ...el.props, _stepIndex: index, _totalSteps: compoundChildrenSteps.length, _stepKey: `step-${index}`, _pageIndex: effectivePageIndex });
740
- }) }), currentChildrenPage?.pageBottom ? (_jsx("div", { className: "training-page-bottom", children: currentChildrenPage.pageBottom })) : null] }))
765
+ }) }, `replay-${replayCount}`), currentChildrenPage?.pageBottom ? (_jsx("div", { className: "training-page-bottom", children: currentChildrenPage.pageBottom })) : null] }))
741
766
  : null;
742
767
  const handleResultDone = useCallback(() => {
743
768
  // 완료 정본 기록 (문장/단어/오늘의 듣기 공통) — 결과를 닫기 전에 캡처
@@ -796,6 +821,7 @@ function TrainingPagePopupInner(props) {
796
821
  contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
797
822
  onReplay: () => {
798
823
  setResultOverlay(null);
824
+ setReplayCount((n) => n + 1);
799
825
  if (hasScenario) {
800
826
  setFlowState(createScenarioFlowState());
801
827
  if (typeof scenario?.onReplay === 'function') {
@@ -28,6 +28,8 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
28
28
  return "active";
29
29
  return "disabled";
30
30
  }, [stepIndex, index]);
31
+ /** 옮기기 단추·끌어놓기를 여는가 — 진행 중이거나 끝낸 단계(아래 move 머리말) */
32
+ const 조작가능 = currentState !== 'disabled';
31
33
  // context 변경 시 items/shuffled를 다시 초기화하는 의도된 동기화다
32
34
  /* eslint-disable react-hooks/set-state-in-effect */
33
35
  useEffect(() => {
@@ -75,11 +77,13 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
75
77
  /**
76
78
  * 카드 한 칸 옮기기.
77
79
  *
78
- * **조작은 진행 중(active)일 때만 열려 있다.** 예전에는 끝낸 단계(done)에서도 단추와
79
- * 끌어놓기가 살아 있었는데, 여기와 handleDrop 은 `currentState === 'active'` 일 때만
80
- * onDone 을 부른다 — 그래서 화면 배치는 바뀌는데 **기록된 점수는 그대로**였다.
81
- * 되돌아본 사람은 옮겨 놓은 배치를 보면서 옛 점수를 읽게 된다. 끝난 단계는 결과를
82
- * 그대로 보여 주기만 한다.
80
+ * **조작은 진행 중(active)과 끝낸(done) 단계에서 열려 있다.** onDone 은 진행 중일 때만 부른다(단계를 넘기는 신호).
81
+ * 옮길 때마다 flipSetShuffled 가 onChange 로 새 배치·점수를 내보내고, OrderListStep 은 그것을 **이 단계 번호로**
82
+ * 기록하므로 끝낸 단계에서 옮겨도 점수가 따라간다.
83
+ *
84
+ * 한때(92b71ac) done 에서 조작을 닫았다 — "끝낸 단계에서 옮기면 점수가 안 나간다" 고 봤는데 위 onChange 기록을
85
+ * 놓친 판단이었고, 그 결과 **카드를 한 번 옮기는 순간**(첫 이동의 onDone 이 단계를 넘긴다) 단추·끌어놓기가 모두
86
+ * 사라져 문장 순서화를 끝까지 맞출 수 없었다(2026-09-14 훈련 검수 — 말귀 OrderTrainingPagePopup).
83
87
  */
84
88
  const move = (idx, dir) => {
85
89
  const arr = [...shuffled];
@@ -423,7 +427,7 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
423
427
  map.set(it.id, el);
424
428
  else
425
429
  map.delete(it.id);
426
- }, className: `order-list__draggable ${draggedIndex === idx ? "dragging" : ""}`, "data-disabled": currentState === 'disabled', "aria-disabled": currentState === 'disabled', tabIndex: currentState === 'disabled' ? -1 : 0, draggable: currentState === 'active', onDragStart: currentState === 'active' ? (e) => handleDragStart(idx, e) : undefined, onDragEnter: currentState === 'active' ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragOver: currentState === 'active' ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragLeave: currentState === 'active' ? () => {
430
+ }, className: `order-list__draggable ${draggedIndex === idx ? "dragging" : ""}`, "data-disabled": currentState === 'disabled', "aria-disabled": currentState === 'disabled', tabIndex: currentState === 'disabled' ? -1 : 0, draggable: 조작가능, onDragStart: 조작가능 ? (e) => handleDragStart(idx, e) : undefined, onDragEnter: 조작가능 ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragOver: 조작가능 ? (e) => handleDragOverWithClass(idx, e) : undefined, onDragLeave: 조작가능 ? () => {
427
431
  try {
428
432
  if (dragLeaveTimeoutRef.current !== null)
429
433
  clearTimeout(dragLeaveTimeoutRef.current);
@@ -440,6 +444,6 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
440
444
  }, 80);
441
445
  }
442
446
  catch (err) { }
443
- } : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
447
+ } : undefined, onDrop: 조작가능 ? (e) => handleDrop(idx, e) : undefined, onDragEnd: 조작가능 ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), 조작가능 ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
444
448
  }) }) }));
445
449
  }
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
70
70
  fontSize: `${fontSize}px`,
71
71
  lineHeight: `${lineHeight}px`,
72
72
  }), [fontSize, lineHeight]);
73
- return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
73
+ return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
74
74
  }
@@ -223,6 +223,10 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
223
223
  // 읽히는데 정작 고를 수가 없어, 훈련 문항을 키보드만으로 풀 수 없었다.
224
224
  tabIndex: selectable && !disabled ? 0 : undefined, "aria-disabled": selectable && disabled ? true : undefined, onKeyDown: selectable && !disabled
225
225
  ? (e) => {
226
+ /* 카드 자신에서 난 키만 — 안의 '소리 듣기' 에서 누른 Enter 가 올라오면 전에는 소리 대신 **그 카드를 답으로
227
+ 골라** 되돌릴 수 없었다. */
228
+ if (e.target !== e.currentTarget)
229
+ return;
226
230
  // Space 는 기본 동작이 스크롤이라 막고, 둘 다 클릭과 같은 선택으로 잇는다.
227
231
  if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
228
232
  e.preventDefault();
@@ -24,6 +24,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
24
24
  // 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
25
25
  const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
26
26
  const noiseTypeLoadedRef = useRef(null);
27
+ /* 바꿔 끼울 소음 파일 — 종류를 바꾸면 여기에 두고, 페이드아웃이 끝나거나 **다음 페이드인이 시작될 때** 끼운다.
28
+ 전에는 페이드아웃의 완료 콜백에만 걸어 두어, 150ms 안에 다른 페이드(다음 트랙의 fadeInNoise·문장 종료의
29
+ fadeOutNoise·탭 숨김)가 끼면 콜백이 사라졌다 — 종류 표시·파형은 새 것인데 **소리는 이전 파일**이었다. */
30
+ const pendingNoiseUrlRef = useRef(null);
27
31
  // volume = 소음 <audio> 볼륨(noiseElementVolume — 전체 소리 × 소음 비율 ÷ 파일 gain, 상한 1)
28
32
  const noiseSettingsRef = useRef(null);
29
33
  const noiseFadeRafRef = useRef(null);
@@ -43,7 +47,19 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
43
47
  }, []);
44
48
  const fadeInNoise = useCallback((targetVolume, durationMs = 250) => {
45
49
  const noiseEl = noiseAudioRef.current;
46
- if (!noiseEl || !noiseEl.src)
50
+ if (!noiseEl)
51
+ return;
52
+ const pending = pendingNoiseUrlRef.current;
53
+ if (pending) {
54
+ pendingNoiseUrlRef.current = null;
55
+ try {
56
+ noiseEl.pause();
57
+ }
58
+ catch (e) { }
59
+ noiseEl.src = pending;
60
+ noiseEl.load();
61
+ }
62
+ if (!noiseEl.src)
47
63
  return;
48
64
  noiseFadeTokenRef.current += 1;
49
65
  cancelNoiseFade();
@@ -235,7 +251,12 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
235
251
  if (url) {
236
252
  noiseTypeLoadedRef.current = ns.type;
237
253
  setActiveNoiseUrl(url); // waveform이 실제 재생 파일을 그리도록
254
+ pendingNoiseUrlRef.current = url;
238
255
  fadeOutNoise(150, () => {
256
+ // 그 사이 다음 페이드인이 이미 끼워 넣었으면(pending 이 비었으면) 다시 끼우지 않는다
257
+ if (pendingNoiseUrlRef.current !== url)
258
+ return;
259
+ pendingNoiseUrlRef.current = null;
239
260
  noiseEl.src = url;
240
261
  noiseEl.load();
241
262
  const mainAudio = audioRef.current;
@@ -13,9 +13,14 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
13
13
  const ttsStartRef = useRef(null);
14
14
  const ttsEstimatedDurationRef = useRef(0);
15
15
  const ttsRafRef = useRef(null);
16
+ /* 지금 말하는 발화의 번호. 정지(cancel)·다시 시작·언마운트에서 올린다 — Chrome·Safari 는 cancel() 한 발화에도
17
+ end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
18
+ 언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
19
+ const ttsTokenRef = useRef(0);
16
20
  useEffect(() => {
17
21
  // cleanup TTS resources on unmount
18
22
  return () => {
23
+ ttsTokenRef.current++;
19
24
  try {
20
25
  if (ttsRafRef.current) {
21
26
  cancelAnimationFrame(ttsRafRef.current);
@@ -52,6 +57,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
52
57
  return;
53
58
  }
54
59
  const synth = window.speechSynthesis;
60
+ const myToken = ++ttsTokenRef.current;
55
61
  try {
56
62
  try {
57
63
  synth.cancel();
@@ -99,6 +105,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
99
105
  catch (e) { }
100
106
  };
101
107
  utt.onend = () => {
108
+ if (myToken !== ttsTokenRef.current)
109
+ return;
102
110
  try {
103
111
  if (ttsRafRef.current) {
104
112
  cancelAnimationFrame(ttsRafRef.current);
@@ -149,6 +157,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
149
157
  }
150
158
  }, [ttsText, playbackRate, mainVolume, fadeInNoise, fillRef, waveProgressRef, setProgress, setPlaybackState, setStateLocal, noiseSettingsRef, onDoneRef, handleEndRef]);
151
159
  const stopTTS = useCallback(() => {
160
+ ttsTokenRef.current++;
152
161
  try {
153
162
  if (window.speechSynthesis) {
154
163
  try {
@@ -105,9 +105,10 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
105
105
  }
106
106
  return [...byLabel.entries()].map(([label, its]) => ({ label, items: its }));
107
107
  }, [groups, orderedGroupItems, pageClamped, pageSize]);
108
- // 정렬/페이지크기 변경·결과 개수 변동 시 1페이지로.
108
+ // 정렬/페이지크기 변경·결과 개수 변동·**검색어 변경** 시 1페이지로. 검색어를 안 보면 결과 수가 같은 다른 검색에서
109
+ // 보던 쪽 번호(3쪽)가 남아 새 결과의 3쪽이 떴다.
109
110
  /* eslint-disable react-hooks/set-state-in-effect */
110
- useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount]);
111
+ useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount, searchQuery]);
111
112
  /* eslint-enable react-hooks/set-state-in-effect */
112
113
  // 결과가 있으면(flat/그룹 모두) 툴바(정렬·페이지크기·페이지네이션) 노출.
113
114
  // (그룹 뷰도 flat과 동일하게 페이지네이션 — 카드 4 결정 1안. 그룹은 페이지 단위로 재그룹핑되어 표시.)
@@ -233,7 +233,8 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
233
233
  const saveAsTrimmed = saveAsName.trim();
234
234
  const saveAsCollides = saveAsTrimmed !== '' && namesLower.includes(saveAsTrimmed.toLowerCase());
235
235
  const openSaveAsPopup = () => {
236
- setSaveAsName(trimmedName ? `${trimmedName} (복사)` : '');
236
+ // 복사본 이름도 사전을 탄다 — 박혀 있어 영어 화면에 'My table (복사)' 가 채워졌다
237
+ setSaveAsName(trimmedName ? t('wordTable.save.copyName', { name: trimmedName, defaultValue: '{{name}} (복사)' }) : '');
237
238
  setShowSaveAsPopup(true);
238
239
  };
239
240
  const handleSaveAsConfirm = () => {
@@ -53,4 +53,12 @@ export declare const MONTH_OPTIONS: {
53
53
  year: number;
54
54
  month: number;
55
55
  }[];
56
+ /** 지금 시각 기준 월 옵션 — 모듈 상수(MONTH_OPTIONS)는 불러온 순간의 달에 굳어, 탭을 열어 둔 채 달이 넘어가면 이번 달이
57
+ * 옵션에서 빠졌다. 컨트롤 훅은 자료가 바뀔 때마다 이것으로 다시 만든다. */
58
+ export declare function monthOptionsNow(): {
59
+ value: string;
60
+ label: string;
61
+ year: number;
62
+ month: number;
63
+ }[];
56
64
  export declare const KO_ORD: string[];
@@ -48,6 +48,24 @@ export function buildMonthChartData(points) {
48
48
  byMonth.set(key, []);
49
49
  byMonth.get(key).push(p);
50
50
  }
51
+ /* **앞 달 칸도 만든다.** 첫 월요일 이전 날(9/1~9/6)은 앞 달의 마지막 주에 속하는데, 앞 달에 자료가 없으면 그 달 칸이
52
+ 안 생겨 그 날들이 **어느 막대에도 안 들어갔다**(신규 회원·한 달 쉰 회원의 달 초 훈련이 사라짐). 오늘이 든 주도 앞 달
53
+ 마지막 주면 주 보기 기본 선택이 미래 주로 갔다. 그 날에 자료가 있거나 오늘이 그 날이면 앞 달을 빈 칸으로라도 둔다. */
54
+ for (const [key, pts] of [...byMonth.entries()]) {
55
+ const m = key.match(/^(\d+)년 (\d+)월$/);
56
+ if (!m)
57
+ continue;
58
+ const year = Number(m[1]);
59
+ const month0 = Number(m[2]) - 1;
60
+ const 첫월 = firstMondayOfMonth(year, month0);
61
+ const 오늘이앞 = key === curKey && now.getDate() < 첫월;
62
+ if (!오늘이앞 && !pts.some(p => p.date.getDate() < 첫월))
63
+ continue;
64
+ const prev = new Date(year, month0, 0);
65
+ const prevKey = 달키(prev.getFullYear(), prev.getMonth());
66
+ if (!byMonth.has(prevKey))
67
+ byMonth.set(prevKey, []);
68
+ }
51
69
  const 칸들 = new Map();
52
70
  for (const [key, pts] of byMonth) {
53
71
  const m = key.match(/^(\d+)년 (\d+)월$/);
@@ -138,5 +156,8 @@ function genMonthOptions() {
138
156
  return opts.reverse();
139
157
  }
140
158
  export const MONTH_OPTIONS = genMonthOptions();
159
+ /** 지금 시각 기준 월 옵션 — 모듈 상수(MONTH_OPTIONS)는 불러온 순간의 달에 굳어, 탭을 열어 둔 채 달이 넘어가면 이번 달이
160
+ * 옵션에서 빠졌다. 컨트롤 훅은 자료가 바뀔 때마다 이것으로 다시 만든다. */
161
+ export function monthOptionsNow() { return genMonthOptions(); }
141
162
  // 한글 서수(주차) — '첫째주', '둘째주' …
142
163
  export const KO_ORD = ['첫째', '둘째', '셋째', '넷째', '다섯째', '여섯째', '일곱째'];
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { useState, useEffect, useMemo, useCallback } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { firstMondayOfMonth, todayWeekValue, MONTH_OPTIONS, KO_ORD } from './barChartData';
4
+ import { firstMondayOfMonth, todayWeekValue, monthOptionsNow, KO_ORD } from './barChartData';
5
5
  /**
6
6
  * BarChart 기간 상태/옵션/네비게이션을 캡슐화한 훅.
7
7
  * 여러 BarChart가 하나의 컨트롤을 공유하려면 이 훅을 상위에서 호출해 control을 내려준다.
@@ -10,7 +10,8 @@ import { firstMondayOfMonth, todayWeekValue, MONTH_OPTIONS, KO_ORD } from './bar
10
10
  export function useBarChartControl(data) {
11
11
  const { t, i18n } = useTranslation();
12
12
  const [period, setPeriod] = useState('week');
13
- const DATA_MONTH_OPTIONS = useMemo(() => MONTH_OPTIONS.filter(o => o.value in data), [data]);
13
+ // 달 옵션·오늘은 자료가 바뀔 때 다시 센다 — 모듈 로드 때 한 번 세면 자정을 넘긴 탭에서 이번 달·오늘 주가 옛날에 굳었다
14
+ const DATA_MONTH_OPTIONS = useMemo(() => monthOptionsNow().filter(o => o.value in data), [data]);
14
15
  const WEEK_OPTIONS = useMemo(() => {
15
16
  const opts = [];
16
17
  for (const o of DATA_MONTH_OPTIONS) {
@@ -34,7 +35,8 @@ export function useBarChartControl(data) {
34
35
  })), [t, DATA_MONTH_OPTIONS]);
35
36
  const weekOptions = useMemo(() => WEEK_OPTIONS.map(o => ({ value: o.value, label: weekOptionLabel(o) })), [WEEK_OPTIONS, weekOptionLabel]);
36
37
  // 진입 시 기본 선택을 오늘 기준으로 — 오늘이 속한 월/주(데이터에 있으면), 없으면 최신
37
- const today = useMemo(() => todayWeekValue(), []);
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ const today = useMemo(() => todayWeekValue(), [data]);
38
40
  const defaultMonth = useMemo(() => DATA_MONTH_OPTIONS.find(o => o.value === today.monthKey)?.value ?? DATA_MONTH_OPTIONS[0]?.value ?? '', [DATA_MONTH_OPTIONS, today]);
39
41
  const defaultWeek = useMemo(() => WEEK_OPTIONS.find(o => o.value === today.value)?.value ?? WEEK_OPTIONS[0]?.value ?? '', [WEEK_OPTIONS, today]);
40
42
  const [monthRange, setMonthRange] = useState('');
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { ensureTrainingDataLoaded } from '../utils/getTrainingData';
4
+ import { getTodayKst } from '../utils/date';
4
5
  import { hasItems, resolveSelection, } from '../utils/dailySelectionUtils';
5
6
  // ─── 훅 ──────────────────────────────────────────────────────────────────────
6
7
  /**
@@ -29,6 +30,19 @@ export function useDailyTrainingSelection(userId) {
29
30
  // 가를 수가 없어** 설명 없는 빈 화면을 그리게 된다(실제로 그런 화면이 있다).
30
31
  // loaded 의 뜻을 바꾸면 지금 쓰는 곳이 깨지므로, 가를 수 있는 값을 따로 낸다.
31
32
  const [hasData, setHasData] = useState(false);
33
+ /* 오늘(KST) — 자정을 넘기면 바뀌어 아래 선택을 **새 날짜 시드로** 다시 계산하게 한다. 전에는 userId 만 봐서 화면을 열어 둔 채
34
+ 자정을 넘기면 어제 시드의 세트가 '오늘' 로 보이는데 완료 기록에는 새 날짜가 찍혔다. */
35
+ const [오늘, set오늘] = useState(() => getTodayKst());
36
+ useEffect(() => {
37
+ const 다음자정까지 = () => {
38
+ const 지금 = Date.now();
39
+ const kst = new Date(지금 + 9 * 3600_000);
40
+ const 자정 = Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate() + 1) - 9 * 3600_000;
41
+ return Math.max(1000, 자정 - 지금 + 1000);
42
+ };
43
+ let t = setTimeout(function 넘김() { set오늘(getTodayKst()); t = setTimeout(넘김, 다음자정까지()); }, 다음자정까지());
44
+ return () => clearTimeout(t);
45
+ }, []);
32
46
  useEffect(() => {
33
47
  let cancelled = false;
34
48
  let timer;
@@ -90,7 +104,9 @@ export function useDailyTrainingSelection(userId) {
90
104
  tryFill();
91
105
  timer = setInterval(tryFill, 250);
92
106
  return () => { cancelled = true; stop(); };
93
- }, [userId]);
107
+ // 오늘 은 날짜가 바뀌었다는 신호 — resolveSelection 이 안에서 오늘 날짜를 읽는다
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [userId, 오늘]);
94
110
  const getTrainingPerType = useCallback((typeKey) => {
95
111
  const src = showYesterday ? yesterdayRef.current : todayRef.current;
96
112
  const fallback = showYesterday ? todayRef.current : yesterdayRef.current;
@@ -3,6 +3,12 @@
3
3
  *
4
4
  * @public
5
5
  */
6
+ /**
7
+ * 같은 항목인가 — 같은 참조이거나, **둘 다 비지 않은 같은 id 를 가지면** 같다(빈 id 는 새로 만든 로컬 항목이라 견주지 않는다).
8
+ * 서버에서 목록을 다시 받으면 항목이 모두 새 객체가 되는데, 고른 항목·지울 항목을 참조로만 견주면 옛 참조가 아무것도
9
+ * 못 찾았다(수정 저장 → 재조회 → 같은 상세에서 '방문' 을 누르면 예정이 안 지워져 방문예정·방문완료 양쪽에 떴다).
10
+ */
11
+ export declare function isSameEditableItem<T>(a: T, b: T): boolean;
6
12
  export declare function useEditableList<T>(emptyItem: T, initialList?: T[]): {
7
13
  list: T[];
8
14
  setList: import("react").Dispatch<import("react").SetStateAction<T[]>>;
@@ -5,6 +5,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
5
5
  *
6
6
  * @public
7
7
  */
8
+ /**
9
+ * 같은 항목인가 — 같은 참조이거나, **둘 다 비지 않은 같은 id 를 가지면** 같다(빈 id 는 새로 만든 로컬 항목이라 견주지 않는다).
10
+ * 서버에서 목록을 다시 받으면 항목이 모두 새 객체가 되는데, 고른 항목·지울 항목을 참조로만 견주면 옛 참조가 아무것도
11
+ * 못 찾았다(수정 저장 → 재조회 → 같은 상세에서 '방문' 을 누르면 예정이 안 지워져 방문예정·방문완료 양쪽에 떴다).
12
+ */
13
+ export function isSameEditableItem(a, b) {
14
+ if (a === b)
15
+ return true;
16
+ const ia = a?.id;
17
+ const ib = b?.id;
18
+ return ia != null && ia !== '' && ia === ib;
19
+ }
8
20
  export function useEditableList(emptyItem, initialList = []) {
9
21
  const [list, setList] = useState(initialList);
10
22
  const [selected, setSelected] = useState(null);
@@ -42,7 +54,14 @@ export function useEditableList(emptyItem, initialList = []) {
42
54
  return;
43
55
  prevInitialRef.current = initialList;
44
56
  setList(initialList);
57
+ // 고른 항목도 새 목록의 같은 항목(id)으로 옮긴다 — 옛 참조로 남으면 지우기·수정 대상 찾기가 헛돈다
58
+ setSelected(prev => (prev == null ? prev : initialList.find(v => isSameEditableItem(v, prev)) ?? prev));
45
59
  }, [initialList]);
60
+ /* 편집 중이 아닐 때는 편집 폼을 고른 항목에 맞춘다 — 취소하거나(서버 거절로) 되돌려도 폼이 저장 안 된 값을 들고 있어,
61
+ 상세 화면에 거절된 값이 보이고 다시 '수정하기' 를 누르면 그 값에서 시작했다. */
62
+ // eslint-disable-next-line react-hooks/set-state-in-effect
63
+ useEffect(() => { if (!isEditing && selected != null)
64
+ setEditForm({ ...selected }); }, [isEditing, selected]);
46
65
  const openDetail = useCallback((item) => {
47
66
  setSelected(item);
48
67
  setIsEditing(false);
@@ -56,7 +75,7 @@ export function useEditableList(emptyItem, initialList = []) {
56
75
  setIsEditing(false);
57
76
  }, []);
58
77
  const remove = useCallback((item) => {
59
- setList(prev => prev.filter(v => v !== item));
78
+ setList(prev => prev.filter(v => !isSameEditableItem(v, item)));
60
79
  setSelected(null);
61
80
  }, []);
62
81
  return {
@@ -43,6 +43,10 @@ export function useTablePaging(rows, resetOn, opts) {
43
43
  대부분의 경우 빈 표가 된다 */
44
44
  const setPageSize = useCallback((v) => { setPageSizeRaw(v); setPage(1); }, []);
45
45
  const total = Math.max(1, Math.ceil(rows.length / Number(pageSize)));
46
+ /* 행이 줄어 범위를 넘으면 **상태도** 끝 쪽으로 맞춘다 — 보이는 쪽만 조이고 상태를 두면, 필터로 줄었다가 다시 늘 때 예전
47
+ 5쪽으로 튀었다. 렌더 중에 맞춘다(위 resetOn 과 같은 꼴). */
48
+ if (page > total)
49
+ setPage(total);
46
50
  const 이쪽 = Math.min(page, total);
47
51
  const pageRows = useMemo(() => rows.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize)), [rows, 이쪽, pageSize]);
48
52
  return {