@ihabdevteam/core 0.134.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 (40) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/components.cjs.js +174 -174
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +3 -3
  5. package/dist/ihabdevteam-core.cjs.js +181 -181
  6. package/dist/ihabdevteam-core.d.ts +1 -1
  7. package/dist/packages/core/src/components/BarChart.js +3 -1
  8. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +34 -21
  9. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -1
  10. package/dist/packages/core/src/components/Popup/ListPopup.js +7 -2
  11. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +13 -3
  12. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +5 -1
  13. package/dist/packages/core/src/components/Report/ReportDocument.js +35 -10
  14. package/dist/packages/core/src/components/RingChart.js +14 -3
  15. package/dist/packages/core/src/components/Table.js +15 -5
  16. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  17. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +28 -2
  18. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +11 -7
  19. package/dist/packages/core/src/components/Training/TrainingWordCard.js +4 -0
  20. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -1
  21. package/dist/packages/core/src/components/barChartData.d.ts +8 -0
  22. package/dist/packages/core/src/components/barChartData.js +21 -0
  23. package/dist/packages/core/src/components/useBarChartControl.js +5 -3
  24. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +17 -1
  25. package/dist/packages/core/src/hooks/useEditableList.d.ts +6 -0
  26. package/dist/packages/core/src/hooks/useEditableList.js +20 -1
  27. package/dist/packages/core/src/hooks/useTablePaging.js +4 -0
  28. package/dist/packages/core/src/hooks/useVisitHistory.js +2 -2
  29. package/dist/packages/core/src/hooks/useVisitSchedule.js +5 -4
  30. package/dist/packages/core/src/hooks/useWordMeanings.js +13 -4
  31. package/dist/packages/core/src/i18n/locales/en.json +2 -1
  32. package/dist/packages/core/src/i18n/locales/ko.json +2 -1
  33. package/dist/packages/core/src/utils/getTrainingData.js +11 -4
  34. package/dist/packages/core/src/utils/imageConfig.js +6 -3
  35. package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +1 -1
  36. package/dist/packages/core/src/utils/wordTrainingRuntime.js +11 -4
  37. package/dist/packages/core/src/version.d.ts +1 -1
  38. package/dist/packages/core/src/version.js +1 -1
  39. package/dist/utils.cjs.js +1 -1
  40. package/package.json +1 -1
@@ -1369,7 +1369,7 @@ export declare function copyText(text: string): Promise<boolean>;
1369
1369
  *
1370
1370
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1371
1371
  */
1372
- export declare const CORE_VERSION = "0.134.0";
1372
+ export declare const CORE_VERSION = "0.135.0";
1373
1373
 
1374
1374
  /** @public */
1375
1375
  declare const coreDefault: Record<string, unknown>;
@@ -40,7 +40,9 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
40
40
  }, [padded]);
41
41
  const [tooltip, setTooltip] = useState(null);
42
42
  const handleBarLeave = useCallback(() => setTooltip(null), []);
43
- return (_jsxs("div", { className: "manager-bar-chart-wrap", 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) => (_jsx("div", { className: "manager-bar-chart-gridline", style: { top: i * (h / (ticks.length - 1)) }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
43
+ return (_jsxs("div", { className: "manager-bar-chart-wrap", 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) => (
44
+ /* 눈금선은 **적힌 값의 높이**에 둔다 — 가운데 값을 반올림(최대 3 → '2')하면서 자리는 절반(1.5)에 두어 선과 숫자가 어긋났다 */
45
+ _jsx("div", { className: "manager-bar-chart-gridline", style: { top: (1 - t / padded) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
44
46
  const isFuture = d.a === -1;
45
47
  /* 판 높이를 넘지 않는다 — maxOverride(진행률 100)보다 큰 값이 와도 막대가 눈금 위로 삐져나가지 않게 */
46
48
  const 높이 = (v) => Math.min(h, Math.max(0, (수(v) / padded) * h));
@@ -18,6 +18,32 @@ export default function ConfirmPopup(props) {
18
18
  // 흐름(결제/환불)에서 더블클릭 시 RPC가 2회 발화될 수 있어, 여기서 in-flight를 직접 막는다.
19
19
  const busyRef = useRef(false);
20
20
  const [busy, setBusy] = useState(false);
21
+ /* 확인·추가 행동을 **한 번만** 돌린다. 전에는 잠금을 onConfirm 이 끝나자마자 finally 에서 풀어, 닫힘 애니메이션(260ms)
22
+ 동안 한 번 더 누르면 onConfirm 이 또 불렸다(말귀 단어표 삭제·이용권 삭제가 두 번 나가 성공 뒤 실패 토스트).
23
+ 닫기로 끝나면 잠금을 풀지 않는다 — 창이 사라지는 중이다. 닫지 않고 끝나면(closeOnConfirm=false·실패) 풀고, 처리 중에
24
+ 단추가 꺼지며 body 로 떨어진 포커스를 누른 단추로 되돌린다. */
25
+ const 한번만 = async (일) => {
26
+ if (busyRef.current)
27
+ return;
28
+ busyRef.current = true;
29
+ const 누른곳 = typeof document !== 'undefined' ? document.activeElement : null;
30
+ setBusy(true);
31
+ let 닫는중 = false;
32
+ try {
33
+ 닫는중 = await 일();
34
+ }
35
+ finally {
36
+ if (!닫는중) {
37
+ busyRef.current = false;
38
+ setBusy(false);
39
+ requestAnimationFrame(() => {
40
+ const 지금 = document.activeElement;
41
+ if ((!지금 || 지금 === document.body) && 누른곳?.isConnected)
42
+ 누른곳.focus();
43
+ });
44
+ }
45
+ }
46
+ };
21
47
  return (_jsxs(Popup, { title: title, onClose: onClose,
22
48
  // onConfirm(서버 요청 등)이 진행 중일 때 X/ESC로 닫으면, 팝업은 즉시 사라지는데
23
49
  // 이미 시작된 요청은 계속 진행돼 완료 후 부모 state/토스트가 뒤늦게 갱신된다 —
@@ -31,19 +57,13 @@ export default function ConfirmPopup(props) {
31
57
  // 없는 일(삭제·연결 해제)이라, 두 번 눌려 두 번 나가면 첫 번째만 뜻이 있고
32
58
  // 두 번째는 엉뚱한 실패로 돌아온다.
33
59
  onClick: async ({ closeWithAnimation }) => {
34
- if (busyRef.current)
35
- return;
36
- busyRef.current = true;
37
- setBusy(true);
38
- try {
60
+ await 한번만(async () => {
39
61
  await extraAction.onClick(closeWithAnimation);
40
- if (extraAction.closeOnClick ?? true)
62
+ const 닫음 = extraAction.closeOnClick ?? true;
63
+ if (닫음)
41
64
  closeWithAnimation();
42
- }
43
- finally {
44
- busyRef.current = false;
45
- setBusy(false);
46
- }
65
+ return 닫음;
66
+ });
47
67
  },
48
68
  }] : []),
49
69
  ...(hideCancel ? [] : [{
@@ -57,19 +77,12 @@ export default function ConfirmPopup(props) {
57
77
  variant: confirmVariant,
58
78
  disabled: confirmDisabled || busy,
59
79
  onClick: async ({ closeWithAnimation }) => {
60
- if (busyRef.current)
61
- return;
62
- busyRef.current = true;
63
- setBusy(true);
64
- try {
80
+ await 한번만(async () => {
65
81
  await onConfirm(closeWithAnimation);
66
82
  if (closeOnConfirm)
67
83
  closeWithAnimation();
68
- }
69
- finally {
70
- busyRef.current = false;
71
- setBusy(false);
72
- }
84
+ return closeOnConfirm;
85
+ });
73
86
  },
74
87
  },
75
88
  ], children: [topContent != null && _jsx("div", { className: "confirm-popup__top", children: topContent }), details && details.length > 0 && (_jsx(Panel, { children: details.map((d, i) => (_jsx(PanelRow, { label: d.label, children: _jsx("span", { className: "panel-row__text body-2", children: d.value }) }, i))) })), _jsx("p", { className: "body-2 confirm-popup__message", children: message }), children] }));
@@ -28,10 +28,13 @@ export default function CouponInputPopup({ onApply, onCancel }) {
28
28
  catch (e) {
29
29
  setError(getErrorMessage(e));
30
30
  setLoading(false);
31
+ /* 처리 중 입력칸이 꺼지며 포커스가 body 로 떨어졌다 — 오류를 본 사람이 곧바로 고쳐 칠 수 있게 칸으로 되돌린다 */
32
+ requestAnimationFrame(() => inputRef.current?.focus());
31
33
  }
32
34
  }, [code, onApply]);
33
35
  const handleKey = useCallback((e, closeWithAnimation) => {
34
- if (e.key === 'Enter')
36
+ // 한글 조합을 끝내는 Enter 는 적용이 아니다(TextField 와 같다)
37
+ if (e.key === 'Enter' && !e.nativeEvent.isComposing)
35
38
  void handleApply(closeWithAnimation);
36
39
  }, [handleApply]);
37
40
  const handleChange = (e) => {
@@ -52,7 +52,12 @@ export default function ListPopup(props) {
52
52
  });
53
53
  }, [items, search, sortKey, sortAsc, searchEnabled, effectiveSearchFn]);
54
54
  const totalPages = Math.max(1, Math.ceil(filtered.length / Number(pageSize)));
55
- const paged = filtered.slice((page - 1) * Number(pageSize), page * Number(pageSize));
55
+ /* 목록이 줄어 보던 쪽이 범위를 넘으면 끝 쪽으로 — 전에는 3쪽을 보다 항목이 5개로 줄면 '항목이 없습니다' 와 '총 5개' 가 함께
56
+ 떴다. 렌더 중에 상태도 맞춘다. */
57
+ if (page > totalPages)
58
+ setPage(totalPages);
59
+ const 이쪽 = Math.min(page, totalPages);
60
+ const paged = filtered.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize));
56
61
  const handleSort = useCallback((key) => {
57
62
  if (sortKey === key)
58
63
  setSortAsc(a => !a);
@@ -91,7 +96,7 @@ export default function ListPopup(props) {
91
96
  sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
92
97
  })), [columns, sortKey, sortAsc, handleSort]);
93
98
  const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
94
- return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
99
+ return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: 이쪽, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, resizable: true, fields: tableFields, items: paged, onRowClick: onSelect, renderRow: (it) => {
95
100
  const isSelected = selectedIds != null
96
101
  ? (it.id != null && selectedIds.has(it.id))
97
102
  : (selectedId != null && it.id === selectedId);
@@ -41,10 +41,20 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
41
41
  }, [previewW, previewH, preview]);
42
42
  const isDuplicateName = !!existingNames &&
43
43
  existingNames.some(n => n.trim().toLowerCase() === name.trim().toLowerCase());
44
- const handleSave = useCallback((animateThen) => animateThen(() => onSave(name.trim(), description.trim() || undefined)), [name, description, onSave]);
44
+ /* 저장은 **한 번만** — 저장 단추의 닫힘 애니메이션(260ms) 동안 이름칸에서 Enter 를 치면 onSave 가 또 불렸다(Safari 는 단추를
45
+ 눌러도 포커스가 칸에 남아 쉽게 난다). */
46
+ const 저장함 = useRef(false);
47
+ const handleSave = useCallback((animateThen) => {
48
+ if (저장함.current)
49
+ return;
50
+ 저장함.current = true;
51
+ animateThen(() => onSave(name.trim(), description.trim() || undefined));
52
+ }, [name, description, onSave]);
45
53
  const handleEnter = useCallback(() => {
46
- if (name.trim() && !isDuplicateName)
47
- onSave(name.trim(), description.trim() || undefined);
54
+ if (저장함.current || !name.trim() || isDuplicateName)
55
+ return;
56
+ 저장함.current = true;
57
+ onSave(name.trim(), description.trim() || undefined);
48
58
  }, [name, description, isDuplicateName, onSave]);
49
59
  return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
50
60
  width: previewW, height: previewH,
@@ -95,7 +95,11 @@ export default function TrainingResultPopup(props) {
95
95
  // 래퍼(.trp-result__scroll)를 하나 더 두어 이중 스크롤이 됐고, 안쪽이 overscroll:contain으로
96
96
  // 휠을 삼키면서 "커서가 바깥 스크롤바 위에 있을 때만 스크롤되는" 문제가 있었다.
97
97
  // 하단 버튼은 Popup의 footer 슬롯으로 넘겨 스크롤과 무관하게 고정된다.
98
- return (_jsx(Popup, { onClose: onClose, inline: inline,
98
+ return (_jsx(Popup, { onClose: onClose,
99
+ /* 바탕 누르기로는 닫지 않는다 — 이 창의 닫기(onClose)는 '완료'(기록 확정 + 훈련 닫기)라, 결과를 보다 어두운 바탕을
100
+ 잘못 누르면 기록이 확정되고 '다시하기' 를 쓸 수 없었다(0.128.0 에서 Popup 바탕 닫기가 기본이 되며 생김).
101
+ 완료·다시하기 단추(와 Esc)로만 닫는다. */
102
+ closeOnBackdrop: false, inline: inline,
99
103
  /* 이 팝업은 Popup 의 title 슬롯을 안 쓰고 본문 안에 제 제목(h1)을 그린다 —
100
104
  그래서 다이얼로그 자체에 접근명이 없었다(운영 경고 9건, 08-17 ~ 08-30,
101
105
  /today · /training · /my-word-training · /my-homework). 화면에 보이는 그
@@ -44,7 +44,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
44
44
  const measureRef = useRef(null);
45
45
  const chromeRef = useRef(null);
46
46
  const rulerRef = useRef(null);
47
- const [pages, setPages] = useState(null);
47
+ /* 쪽 나눔은 **어느 블록 구성(shape)으로 잰 것인지**와 함께 둔다. 블록 수가 줄면 다시 재는 layout effect 가 렌더 뒤에야 돌아,
48
+ 그 사이 렌더가 옛 쪽의 번호로 새 blocks 를 읽다 `blocks[i]` 가 없어 **화면 전체가 깨졌다**(선택 섹션을 끄거나 자료를 다시
49
+ 받아 행이 줄 때). 지금 구성의 것이 아니면 없는 것으로 본다. */
50
+ const [measured, setMeasured] = useState(null);
48
51
  /* **넘칠 때만 탭 자리를 만든다.** A4 한 장은 794px 이라 좁은 자리에서는 가로로
49
52
  넘치는데, 이 상자 안에는 포커스 갈 곳이 하나도 없다 — 그러면 키보드만 쓰는
50
53
  사람은 오른쪽 절반을 **영영 못 본다**(WCAG 2.1.1). 실측: 겉 560px 에 안 794px.
@@ -64,7 +67,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
64
67
  const ro = new ResizeObserver(재자);
65
68
  ro.observe(el);
66
69
  return () => ro.disconnect();
67
- }, [pages]);
70
+ }, [measured]);
68
71
  // 블록 구성이 바뀌었는지를 값 하나로 본다. blocks 배열 자체는 렌더마다 새로
69
72
  // 만들어지는 일이 흔해 의존성에 그대로 넣으면 측정 → setPages → 렌더 → 측정
70
73
  // 이 끝나지 않는다.
@@ -73,13 +76,31 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
73
76
  통째로 건너뛴다 — 고친 사람의 변경을 볼 수도 찾을 수도 없다.
74
77
  돌리는 값은 예전과 한 바이트도 다르지 않다. */
75
78
  const shape = blocks.map((b) => `${b.id}\u0000${b.section ?? ''}\u0000${b.group ?? ''}`).join('\u0001');
79
+ const pages = measured && measured.shape === shape ? measured.pages : null;
80
+ /* 자(ruler)의 높이 — 0 에서 크기가 생기면(숨겨져 있다 보이게 됨) 다시 잰다 */
81
+ const [자크기, set자크기] = useState(0);
82
+ useEffect(() => {
83
+ const ruler = rulerRef.current;
84
+ if (!ruler || typeof ResizeObserver === 'undefined')
85
+ return;
86
+ const ro = new ResizeObserver(() => set자크기(ruler.offsetHeight));
87
+ ro.observe(ruler);
88
+ return () => ro.disconnect();
89
+ }, []);
76
90
  useLayoutEffect(() => {
77
91
  const el = measureRef.current;
78
92
  const ruler = rulerRef.current;
79
93
  if (!el || !ruler)
80
94
  return;
81
- const chromeH = chromeRef.current?.offsetHeight ?? 0;
82
- const budget = ruler.offsetHeight - chromeH;
95
+ /* display:none 안에서 처음 그리면 자·블록이 모두 0 이라 한 갈래가 한 장에 몰리는데, 보이게 된 뒤에도 구성이 같아 다시
96
+ 재지 않았다 — 자의 높이(자크기)를 의존성에 두어 크기가 생기면 다시 잰다. */
97
+ /* 머리·바닥 높이는 **갈래마다** 잰다 — 제목이 있는 갈래와 없는 갈래는 머리 높이가 달라, 첫 갈래 것 하나로 재면 제목 있는
98
+ 뒤 갈래의 장이 넘쳤다. */
99
+ const chromeOf = new Map();
100
+ chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
101
+ chromeOf.set(node.dataset.chromeSection ?? '', node.offsetHeight);
102
+ });
103
+ const budgetFor = (section) => ruler.offsetHeight - (chromeOf.get(section ?? '') ?? 0);
83
104
  const heights = Array.from(el.children).map((c) => c.offsetHeight);
84
105
  // 이웃한 같은 group 을 한 덩어리로 묶는다.
85
106
  const groups = [];
@@ -103,6 +124,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
103
124
  };
104
125
  groups.forEach((grp) => {
105
126
  const groupH = grp.reduce((sum, i) => sum + heights[i], 0);
127
+ const budget = budgetFor(blocks[grp[0]].section);
106
128
  // 갈래가 바뀌면 새 장에서 시작한다.
107
129
  if (cur.length && blocks[cur[0]].section !== blocks[grp[0]].section)
108
130
  flush();
@@ -124,10 +146,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
124
146
  }
125
147
  });
126
148
  flush();
127
- setPages(packed);
149
+ setMeasured({ shape, pages: packed });
128
150
  // blocks 는 위 shape 로 대신 본다.
129
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
130
- }, [shape]);
152
+ }, [shape, 자크기]);
131
153
  /* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
132
154
  문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
133
155
  (kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
@@ -140,9 +162,9 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
140
162
  header: (_jsxs("div", { className: "report-doc__header", children: [_jsx("h2", { className: "report-doc__title h3b", children: (section != null && sectionTitles?.[section]) || null }), logo] })),
141
163
  footer: (_jsxs("div", { className: "report-doc__footer caption-1", children: [_jsx("span", { className: "report-doc__footer-left", children: footerLeft }), _jsx("span", { className: "report-doc__page-no", children: pageLabel(page, total) })] })),
142
164
  });
143
- // 머리·바닥이 쓰는 높이를 재기 위한 견본. 쪽 번호 자릿수만큼만 다르므로
144
- // 첫 장의 것으로 대신한다.
145
- const sampleChrome = chrome(1, 1, blocks[0]?.section);
165
+ // 머리·바닥이 쓰는 높이를 재기 위한 견본 — 갈래마다 하나(제목 유무로 머리 높이가 다르다). 쪽 번호 자릿수만큼만
166
+ // 다르므로 첫 장 번호로 잰다.
167
+ const chromeSections = Array.from(new Set(blocks.map((b) => b.section ?? '')));
146
168
  const vars = {
147
169
  ...(pageWidth ? { '--report-page-w': pageWidth } : null),
148
170
  ...(pageHeight ? { '--report-page-h': pageHeight } : null),
@@ -152,7 +174,10 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
152
174
  return (_jsxs("div", { ref: rootRef, className: cx('report-doc', className), style: vars,
153
175
  /* 굴릴 것이 있을 때만 포커스를 받는다. 이름 없이 포커스만 주면 낭독기가
154
176
  문서 전체를 읽어 버리므로 역할과 이름을 함께 준다. */
155
- tabIndex: 넘침 ? 0 : undefined, role: 넘침 ? 'group' : undefined, "aria-label": 넘침 ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsxs("div", { ref: chromeRef, children: [sampleChrome.header, sampleChrome.footer] }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
177
+ tabIndex: 넘침 ? 0 : undefined, role: 넘침 ? 'group' : undefined, "aria-label": 넘침 ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsx("div", { ref: chromeRef, children: chromeSections.map((sec) => {
178
+ const c = chrome(1, 1, sec || undefined);
179
+ return _jsxs("div", { "data-chrome-section": sec, children: [c.header, c.footer] }, sec);
180
+ }) }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
156
181
  const { header, footer: pageFooter } = chrome(p + 1, pages.length, blocks[idxs[0]].section);
157
182
  return (_jsxs("section", { className: "report-doc__page", children: [header, idxs.map((i) => (_jsx("div", { children: blocks[i].content }, blocks[i].id))), pageFooter] }, p));
158
183
  }) }))] }));
@@ -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열)
@@ -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]);
@@ -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
  }
@@ -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();
@@ -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('');